The Doorbell, Not the Parcel: Realtime Chat Without WebSockets
I'm building a B2B messaging platform on top of the WhatsApp Business API. The send path was solved early: user hits send, the API accepts it, a worker calls Meta, done. The receive path is where it gets interesting.
When a customer replies on WhatsApp, Meta calls our webhook and the message eventually lands in Postgres. If the agent's app is in the background, a push notification covers it. But if the agent is staring at the conversation list, or sitting inside that exact conversation, the message should just appear. No pull-to-refresh, no polling, no "tap to load new messages."
That sounds like a solved problem until you look at how the message actually arrives.
The ingestion path works against you
Our webhook ingestion is deliberately indirect. Meta doesn't talk to our application servers at all. Each tenant gets a small serverless edge: API Gateway in front of a Lambda that verifies the x-hub-signature-256 HMAC, drops the raw payload onto an SQS queue, and returns 200 to Meta immediately.
A long-running poller on a worker box drains the queue, and the poller is not just moving bytes into a table. It is the processor. Every payload it pulls is one of two things, and each gets real work:
- An inbound message. The handler checks the provider message ID against an event log, so a redelivered webhook becomes a no-op. It resolves or creates the recipient and the conversation, encrypts the message body (bodies are AES-256-GCM ciphertext at rest), inserts the message row, and updates the conversation's preview and unread count.
- A delivery status. The handler updates the matching message by provider ID, but only forward: sent, then delivered, then read, never backwards.
Only after all of that commits does the poller delete the SQS message. A failure leaves it on the queue for retry; three failures send it to the DLQ. Postgres, at the end of this line, is the single source of truth.
This design buys us three properties. Meta gets acknowledged in milliseconds regardless of what our processing is doing. Bursts and retries get absorbed by the queue instead of our database. And a poison payload gets parked instead of blocking the line.
It also creates the problem this post is about. In a normal web app, data reaches the screen because the client asked for it: request in, response out. Here, nobody asked. The new message was discovered by a background worker while the app sat idle, so there is no request/response cycle to attach it to. We need a channel the server can push on without waiting to be asked.
Why not WebSockets
Every realtime chat discussion starts with WebSockets, so let's deal with that first.
The question to ask is: what would the client actually send upstream over the socket? In our system, nothing. Sending a message is a normal REST POST that gets queued and forwarded to Meta. Typing indicators and presence aren't in scope. Every byte of realtime traffic flows in exactly one direction, server to client.
A WebSocket buys you a bidirectional channel and charges for it whether you use both directions or not. The protocol upgrade needs special handling at every hop: Nginx Upgrade headers, load balancer idle timeouts. Heartbeats, reconnection, and backoff are yours to implement on both sides. And it's opaque to ordinary HTTP tooling.
Server-Sent Events are just HTTP. The client opens GET /events/stream with the same auth headers as every other API call, and the response simply never ends. The server writes a : ping comment every 25 seconds to keep proxies from cutting the connection. On our side the client is a streamed HTTP request plus a small reconnect loop.
Because it's plain HTTP, debugging is trivial. Point curl -N at the stream endpoint from a terminal and watch events scroll by live. No special client, no protocol inspector.
The honest costs: SSE is text-only and strictly one-way, and browsers on HTTP/1.1 cap you at six connections per origin (a native app has no such limit). For us, one-way is the whole point, so these costs round to zero.
So the transport is settled. The more interesting decision is what to put inside the events.
Two patterns, one decision
Martin Fowler names two patterns here, and the distinction does real work.
Event notification. The event is a doorbell. It says "something happened to entity 42" and nothing else. Any consumer that cares goes back to the source of truth and fetches the current state. An order service publishes OrderPlaced {id: 42}; the invoicing service hears it and calls GET /orders/42 before generating an invoice.
Event-carried state transfer. The event is a parcel. It carries everything the consumer will ever need, so the consumer never calls back. It keeps its own copy. A customer service publishes AddressChanged with the full new address; the shipping service stores it locally and can print labels even when the customer service is unavailable.
| Property | Notification | State transfer |
|---|---|---|
| Event size | Tiny (IDs + metadata) | Full payload |
| Event schema over time | Stable | Grows with every consumer |
| Extra traffic to source | Yes, one fetch per event | None |
| Consumer survives source down | No | Yes |
| Lost event costs | One missed fetch, recoverable | Silent divergence |
| Out-of-order delivery | Harmless | Needs sequencing or clocks |
| Consistency model | Source is always right | Eventual, replicated |
Neither is better in general. State transfer is the right call when the consumer needs the data constantly, or must keep working while the producer is unavailable. Notification is the right call when the consumer needs occasional, fresh access to data someone else owns.
Why Event Notification wins here
Incident 1: Redis drops an event. Our fan-out bus is fire-and-forget, and it drops events in both designs equally: a failover, a reset of the subscriber connection, a queue overflow on a stalled client. The difference is the aftermath. With notification, every fetch is cumulative from a cursor, ?after_id={last known}, so the next poke of any kind pulls everything that was missed along the way. A dropped event delays a message by seconds and heals itself. With state transfer, each event vouches only for itself: if the event for m6 is lost and m7's arrives, the screen shows m5 then m7, and nothing in the protocol ever notices the hole. Detecting it needs sequence numbers, and filling it needs a fetch, which means building the notification pattern anyway, underneath the fat events.
Incident 2: an event arrives while a fetch is in flight. The app fetches constantly while a chat is open: entering the screen, paginating history, reconnecting after a blip, returning from background. Every response is a snapshot from a moment ago. With state transfer, both fetches and events write to the screen, and they race: a read event lands mid-fetch and paints the ticks blue, then the older snapshot arrives and flips them back to grey. The same race makes a just-rendered message vanish. Preventing it means merge rules and per-message versions in the app, shipped to devices that upgrade on their own schedule. With notification the race cannot exist, because events never write anything. Only fetch responses touch the screen, and the newest fetch always wins. One writer instead of two.
Incident 3: a message body has to reach the screen. Bodies are AES-256-GCM ciphertext in Postgres. With notification, plaintext exists in exactly one place: the response of an authenticated, tenant-scoped REST call. With state transfer, the worker decrypts on the publish path and plaintext flows through Redis and down every SSE write. The sharpest way to see the cost: a wrong-channel routing bug in the hub leaks a message ID under notification, and leaks customer message content under state transfer. Same bug, different incident class.
Notice the shape. In every incident, notification's answer is "the next fetch returns the truth." State transfer's answer is a new mechanism: sequence numbers, merge rules, a wider security boundary.
The Event Schema
The one concession: metadata is fine. The event carries IDs and a timestamp so the client knows what to refetch and can dedupe. That's still notification. The line not to cross is message content.
We ship three event types, all the same shape:
{
"event": "message_arrived",
"data": {
"conversation_id": "c_9f2e",
"message_id": "m_4471",
"ts": 1784177800
}
}
{
"event": "message_status_updated",
"data": {
"conversation_id": "c_9f2e",
"message_id": "m_4471",
"ts": 1784177803
}
}
Plus a conversation_updated for list-level changes. A ring of the doorbell, and which door.
The full loop
On the worker side, the publish happens strictly after the commit. Publishing inside the transaction is the classic footgun: the client refetches before the commit lands, gets nothing, and the message doesn't appear until the next event.
db.commit() # message is now durable and visible
redis.publish(
f"user:{user_id}:events",
json.dumps({
"event": "message_arrived",
"data": {"conversation_id": conv_id, "message_id": msg_id, "ts": now},
}),
)
On the API side, each process runs one Redis subscriber task and keeps a plain in-process dict of user_id → set of asyncio queues, one queue per open stream. Redis is a bus, never a socket store, so there's no sticky load balancing: every API process hears every event and delivers only to the streams it holds. The SSE endpoint itself is small:
@router.get("/events/stream")
async def stream(user=Depends(authenticate_user)):
q: asyncio.Queue = asyncio.Queue(maxsize=100)
hub.register(user.id, q)
async def gen():
try:
while True:
try:
event = await asyncio.wait_for(q.get(), timeout=25)
yield f"event: {event['event']}\ndata: {json.dumps(event['data'])}\n\n"
except asyncio.TimeoutError:
yield ": ping\n\n"
finally:
hub.unregister(user.id, q)
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
The client side does three things. On message_arrived, call the ordinary messages API with an after_id cursor; if the user is inside that conversation, the bubble appears, and if they're on the list view, the row bumps and the badge increments. On message_status_updated, refetch the affected message and update the ticks. On any disconnect, reconnect with backoff and do the same after_id fetch once before trusting the stream again.
That last part is the property that makes the whole design boring, in the good way: gap recovery and normal operation are literally the same code path.
One more channel runs alongside all of this: push. The worker fires an FCM/APNs notification for every inbound message regardless of whether a stream is open. Sockets drop silently, apps get backgrounded mid-scroll, and the OS kills connections to save battery, so push is the delivery path we can't lose, and SSE is the fast path when the screen is on. The client dedupes by message_id, so a message that arrives through both renders exactly once. Foreground gets the doorbell, background gets the notification, and neither side has to know about the other.
Sharp edges worth naming
A few things that will bite anyone building this, all cheap to fix up front.
Status transitions only move forward. A read can reach you before its delivered. Rank the statuses and refuse downgrades, or the ticks flicker backwards:
UPDATE messages
SET status = :new_status
WHERE provider_message_id = :wamid
AND status_rank(:new_status) > status_rank(status);
Bound the per-connection queues. An unbounded queue on a stalled client is a slow memory leak. Cap it small; on overflow, drop everything and send a single resync event. The events are just IDs, so a resync costs the client one fetch:
try:
q.put_nowait(event)
except asyncio.QueueFull:
while not q.empty():
q.get_nowait()
q.put_nowait({"event": "resync", "data": {}})
Respect the timeout chain. The 25-second heartbeat must be shorter than every idle timeout above it: the server's receive timeout, Nginx's proxy_read_timeout, the load balancer's idle timeout. One misconfigured link and healthy connections get cut in silence.
Conclusion
Two decisions, each made by looking at what the system actually does rather than what realtime systems usually use.
WebSockets lost because our realtime traffic is strictly one-directional. Paying for a bidirectional protocol, with its upgrade handshakes, custom heartbeats, and hand-rolled reconnection, to use half of it made no sense. A long-lived HTTP response does the job with the auth, proxies, and debugging tools we already had.
Event-carried state transfer lost because everything about our pipeline favors a single source of truth: encrypted bodies that should leave through one door, a fan-out bus that's allowed to drop events, and delivery semantics that are at-least-once and unordered at every hop. Thin events make all of that somebody else's problem, specifically Postgres's, which already solved it.
The pattern has a name, event notification, and it's older than any of the infrastructure in this post. The push channel gets to be cheap, lossy, and dumb because it only ever says one thing: something changed, come look. The client has exactly one way to load data, whether it's opening the app, recovering from a dropped connection, or answering a doorbell. Fewer code paths, fewer states, fewer ways to be wrong.