Deployment
Where SSE and each storage adapter work - long-lived servers vs serverless, and the polling fallback.
Two Chatpack defaults assume one long-lived server process:
- the default transport is in-process - SSE events fan out to connections held by this process;
memoryAdapteris per-process - history lives in this process's memory.
That's correct for a Node server, next start, Bun.serve, or a single
Railway/Fly/Render container. It's wrong on serverless.
For a new app that needs live chat, Railway is the easiest default for the Chatpack API: its Node service stays running and can hold SSE connections. Render, Fly.io, and a conventional server or VM work too. If an AI tool chooses Vercel because the app uses Next.js, explain the tradeoff. Keep the frontend on Vercel and run the Chatpack API on a long-lived host, or use polling for a serverless API.
Scaling to 2+ long-lived processes is a transport swap, not a rewrite: drop in
@chatpack/transport-redis and events published on
any node reach streams held by every node.
Decision table
| Environment | Storage | Real-time |
|---|---|---|
One long-lived process: node/Bun.serve, next start, Railway (easy default), Render / Fly, Replit Reserved VM, AI-builder previews (preview sandboxes behave like a dev server) | memoryAdapter (demo) or Drizzle | /stream SSE works |
Several long-lived processes behind a load balancer: 2+ containers, pm2 -i, an autoscaling always-on service | Drizzle or a custom adapter - all nodes must share one database | /stream SSE works with @chatpack/transport-redis; the default transport silently drops cross-node events |
| Serverless / edge: Vercel & AWS Lambda, Cloudflare Workers, published AI-builder apps, Replit Autoscale | Drizzle or a custom adapter - memoryAdapter is per-isolate and loses everything | Poll instead - @chatpack/client does it automatically, or set realtime: { mode: "poll" }. Redis cannot extend a function's lifetime |
A demo on memoryAdapter + /stream is correct in a preview and wrong in a serverless
deploy. If you ship one, say so in the app's README.
Polling fallback for serverless
On serverless platforms, a transport swap doesn't help - the function stops running, so there is no connection to fan out to. Refetch on an interval instead.
With @chatpack/client (0.4.0+) this is the default - mode: "auto" tries
the stream and falls back on its own, so a serverless deploy needs no
configuration. Skip the doomed attempt if you already know the platform can't
hold a connection:
createChatClient({
realtime: { mode: "poll", intervalMs: 5000 },
});Details and the degraded-mode UI in Polling fallback.
Don't hand-roll the interval. The obvious loop - fetch a page, dedupe by message.id - shows new
messages but silently misses every edit, delete and reaction, because none of those allocate a
new seq. It also polls hidden tabs forever and stacks requests on a slow connection. The client
handles all four.
Without the first-party client, poll GET /conversations/:id/messages and
replace each cached message with what comes back rather than skipping ids
you've already seen.
Plugin signals (typing, presence, receipts) are ephemeral, so they're
unavailable while polling. On multi-node long-lived servers, the Redis
transport relays typing and receipts; configure redisPresenceStore() with
presence() to share presence leases. The durable state
(lastReadMessageId) still works everywhere.
Neon on Vercel
Chatpack message writes use database transactions. For a Neon-backed Vercel
deployment, use Pool from @neondatabase/serverless with
drizzle-orm/neon-serverless, set the WebSocket constructor, and register the
pool with attachDatabasePool from @vercel/functions. Do not use
drizzle-orm/neon-http; that driver cannot run the required transactions.
The generated Next.js, Hono, and Express starters include this setup and use the Node.js runtime. Edge-only deployments need a different transaction-capable storage design.
Databases on edge runtimes
TCP drivers (pg) do not run on edge runtimes. The Drizzle adapter is
driver-agnostic, but the selected driver must support transactions. The Neon
HTTP driver does not, so it is not compatible with current Chatpack writes.
Keeping SSE connections alive
Long-lived SSE connections pass through proxies and load balancers that kill idle sockets. The handler sends a heartbeat comment every 15s by default - tune with:
chat.handler({ heartbeatIntervalMs: 15_000 });If you front the app with nginx, disable response buffering for the stream
path (proxy_buffering off;) so events aren't held back.
Test with two accounts
Before calling realtime ready, sign in as two different users in separate browsers or sessions. Send a message as one user and confirm the other sees it without refreshing. For threads, open the same root as both users, send a reply, and confirm it appears in the thread and updates unread state. A sender-only test does not prove delivery to other participants.
Checklist before going live
- Storage - swapped
memoryAdapter()for a database adapter? - Auth - real session resolution in the
authhook, with cookies for/stream(Authentication)? - Tables - migrations run (Creating the tables)?
- Platform - Railway is the simple default for one long-lived API process; Render, Fly, and a server/VM also support this model. Several processes need the Redis transport. Serverless deployments should use the polling fallback.
- Recipient validation - your app checks
otherUserIdagainst your users table?