Realtime does not mean WebSockets everywhere
One socket, one group, one message type. How we make a fully cached site update instantly without turning the backend into a message bus.
Hnin Ei Phyu
Senior Backend Engineer

There is a failure mode where "make it realtime" becomes "put everything on a WebSocket", and six months later you have a bespoke message bus with no schema, no replay, and a connection count nobody is monitoring.
The site you are reading is fully realtime — save something in the admin and every open browser updates within a tick, no refresh — and it uses exactly one socket, one group, and one message type.
The insight: push the *invalidation*, not the data
The expensive, complicated version of realtime pushes new content down the socket. Now your socket layer needs to know about serialisation, permissions, partial updates and ordering. It becomes a second API with none of the first one's discipline.
The cheap version pushes a *name*:
{ "type": "content.updated", "resource": "portfolio", "action": "updated" }That is the entire payload. The client knows what portfolio means, invalidates its cache for that resource, and refetches through the normal HTTP API — the same code path, the same permissions, the same serialisers, the same caching. The socket carries no content, so it can never carry *wrong* content.
The server side
The channel layer send happens through Celery rather than inline. This is not premature optimisation, it is a scar:
Calling async_to_sync(channel_layer.group_send)(...) from a view spins an asyncio event loop in the worker's thread. Under a gevent worker every greenlet shares that thread, so while the loop awaits Redis, gevent switches to another greenlet — and the moment that greenlet touches the ORM, Django's @async_unsafe guard finds the first greenlet's loop and raises SynchronousOnlyOperation. The second greenlet did nothing wrong. It lost a race.
That produced a uniform ~3% error rate across every endpoint in a production system of ours, and it took an embarrassingly long time to diagnose because the errors had no correlation with the code that caused them.
Routing the broadcast through Celery moves the event loop into a prefork worker where there are no shared-thread greenlets, and takes the Redis round-trip off the request path as a bonus.
The client side
In Next.js, the resource name maps onto a fetch tag:
socket.onmessage = (event) => {
const { resource } = JSON.parse(event.data)
revalidateResource(resource) // server action -> revalidateTag(resource)
router.refresh()
}Server components re-run, refetch with a busted tag, and the page repaints. No client-side store, no cache reconciliation, no optimistic update to roll back.
What we deliberately did not build
Per-user channels. There is nothing user-specific on a marketing site. One group, every visitor.
Bidirectional messaging. The only frame a client may send is a heartbeat ping. Anything else is ignored rather than echoed, so a visitor cannot use the socket to reach other visitors. There is no content a visitor can push, so there is nothing to authenticate.
Reconnection state. On reconnect the client refetches everything. It is one round-trip and it is always correct, which is worth more than a resume token that is right most of the time.
The whole realtime layer is about 120 lines of Python and 60 lines of TypeScript. Realtime is not a scale of infrastructure. It is a decision about what you put on the wire.


