First app where the core stack itself isn't enough. Serverless Route Handlers are stateless and short-lived — they cannot hold a persistent WebSocket connection open. This app forces a real architectural fork: a second, always-on service just for realtime delivery, plus the horizontal-scaling problem that creates.
1. Problem framing
Two or more people want to exchange messages and see them appear instantly, without refreshing. They also want to know, roughly, who else is online. Unlike every previous app, "the server responds to a request" isn't the right model — the server has to be able to push to a client that never asked for anything.
2. PRD
Goal: Let members of a conversation send and receive messages in near real time, and see who's currently online.
Non-goals:
- Read receipts / typing indicators
- File/image attachments
- End-to-end encryption
- Group conversations beyond a fixed member list set at creation
User stories:
- As a user, I want to send a message and have it appear for other members instantly.
- As a user, I want to see message history when I open a conversation.
- As a user, I want to see which members are currently online.
- As a user, I want messages I missed while offline to be there when I reconnect.
Success metrics:
- Message delivery latency under 200ms for two online recipients
- Zero message loss — a message that reaches the server is never lost, even if realtime delivery to a specific client fails
MVP cut line: 1:1 and fixed-member group conversations, message history, presence. Typing indicators and read receipts are deferred.
3. Wireframes & UX flow
Screen inventory:
| Screen | Purpose |
|---|---|
| Conversation list | All conversations, unread indicator, online dot per member |
| Conversation view | Message history + composer, live-updating |
4. HLD
This is the architectural fork. Everything non-realtime still goes through the usual Next.js Route Handlers. Realtime message delivery goes through a separate, persistent WebSocket server — a small standalone Node process, not a Vercel serverless function.
Two clients in the same conversation can be connected to different instances of the WS server. In-process broadcast can't reach across instances — that's exactly the problem Redis Pub/Sub solves here: every instance subscribes to the channels for conversations its local clients care about, and republishes incoming messages to them.
5. Data model / ERD
Presence is not in this diagram — deliberately. It lives in Redis as
short-TTL keys (presence:user_id), not in Postgres. See ADR-4.
6. API design + flow mapping
| Channel | Purpose | Auth |
|---|---|---|
GET /api/conversations | list + last message preview | required |
GET /api/conversations/:id/messages?before= | paginated history | required, member only |
POST /api/conversations | create conversation with member list | required |
WS /realtime?token= | connect, subscribe to own conversations | short-lived token |
Flow: send a message, delivered across two WS instances
Client A's own copy is confirmed via the direct ack; Client B receives it purely through the Redis fan-out, because Client B is connected to a different WS instance entirely. Neither client needed to know which instance the other was on.
Flow: connect + presence
7. LLD
Module breakdown:
ws-server/index.ts — standalone Node process, not part of Next.js build
ws-server/auth.ts — verifies short-lived token on WS upgrade
ws-server/presence.ts — Redis TTL heartbeat read/write
app/api/conversations/** — normal Next.js Route Handlers (history, CRUD)
Edge cases worth naming explicitly:
- Client-side optimistic send + reconciliation — same pattern as app 1: render the message immediately with a temp id, replace it with the real id once the server acks. On reconnect after a drop, the client must not re-render a message it already has.
- Missed messages while disconnected — realtime delivery is best-effort;
the durable source of truth is Postgres. On reconnect, the client always
does a normal paginated
GET .../messagesfetch to resync, rather than trusting that nothing was missed over the WS connection. - Authz on the WS channel itself — a client can only subscribe to
conv:*channels for conversations they're actually a member of; this is checked at connect time by loading the user's conversation ids, not trusted from client input. - Instance restart — if a WS server instance restarts, its connected clients drop and reconnect (likely to a different instance); because messages are persisted before being published, no message is lost — only the realtime push is momentarily interrupted for those clients.
8. NFRs
- Performance: delivery latency under 200ms end to end for two online clients on different instances (dominated by the Redis publish/subscribe hop, which is sub-millisecond, plus network).
- Security / authz: WS connections authenticate via a short-lived token issued by the main Next.js app (not the long-lived session cookie — cookies don't attach cleanly to arbitrary WebSocket upgrade requests across origins/ports); channel subscription is scoped to the user's own conversations.
- Observability: track connected-client count per instance, message publish-to-deliver latency, and reconnect rate — a spike in reconnects is usually the first visible symptom of a WS instance problem.
9. ADRs
ADR-1: A dedicated, persistent WebSocket server — not Next.js serverless functions
Context: Vercel-style serverless functions are stateless and can be frozen
or recycled between requests; they cannot hold a WebSocket
connection open indefinitely.
Decision: run a small standalone Node process (using `ws`) on a platform
that supports long-lived processes, alongside the existing
Next.js app on Vercel.
Consequence: two deployable services instead of one — real added
operational surface, taken on only because this app's core
requirement (push delivery) cannot be met otherwise.
ADR-2: Redis Pub/Sub for cross-instance message fan-out
Context: horizontally scaling the WS server means two members of the same
conversation can be connected to different instances.
Decision: every message is published to a per-conversation Redis channel;
every instance subscribes to channels for its connected clients'
conversations and forwards matching messages.
Consequence: WS server instances stay stateless with respect to each
other — any instance can serve any client — at the cost of a
Redis hop on every message.
ADR-3: Postgres is the durable source of truth; realtime delivery is best-effort
Context: a WS push can be lost (dropped connection, instance restart)
without that meaning the message itself is lost.
Decision: persist every message to Postgres synchronously before/alongside
publishing to Redis; clients resync via a normal paginated fetch
on reconnect rather than trusting the WS stream alone.
Consequence: message durability never depends on the realtime layer
staying up — the realtime layer can fail without data loss.
ADR-4: Presence lives in Redis with TTL heartbeats, not in Postgres
Context: presence changes constantly (every 15s per user) and is wrong the
instant a laptop lid closes regardless of any write we make.
Decision: `SET presence:userId EX 30`, refreshed by client heartbeat; no
durable table, no "last seen" column maintained transactionally.
Consequence: presence is approximate by nature (up to ~30s stale) and
that's fine — treating it as durable state would be over-
engineering a value that's inherently ephemeral.
10. Deploy plan
- Environments: the Next.js app deploys to Vercel as before; the WS server deploys separately to a platform that supports persistent processes (e.g. Fly.io or Railway), with its own health check and at least two instances in production specifically to prove the pub/sub fan-out path is exercised, not accidentally single-instance.
- CI: typecheck/lint for both services; an integration test that opens two WS connections pinned to different local instances (via a test harness) and asserts a message sent on one arrives on the other through Redis.
- Definition of done: two browser tabs in the same conversation see each other's messages in under a second; killing one WS instance doesn't drop messages for clients on the other.
- Rollback: the two services roll back independently; because Postgres is the source of truth, rolling back the WS server never risks message data, only momentary realtime delivery.
11. Retro — "at 10x"
At 10x, the failure mode isn't 1:1 chats — it's one enormous group conversation with thousands of members, where every message fans out to every subscribed instance regardless of how few of its members are actually online right now. Plain Redis Pub/Sub also gives no delivery guarantees or backpressure — under enough load it will silently drop messages for slow subscribers rather than queue them. At that scale I'd either shard very large rooms across multiple channels or move to a purpose-built realtime messaging backbone with actual delivery guarantees, and I'd reconsider whether "every message triggers a Postgres write" still holds up under extreme message volume — likely batching writes the way app 7's queue-based patterns do.