Real-time without WebSockets code: MQTT for whiteboard, chat and notifications
One Mosquitto broker behind the app carries collaborative drawing, staff↔member chat and a live notification bell — and once locked itself out.
- lines
- 31
- the server-side publisher
- lines
- ~75
- the browser client, reconnects included
- read timeout
- 24 h
- on the proxied /mqtt WebSocket
Context
Three parts of MemberHub need the screen to change without anybody asking it to: a whiteboard several people draw on at once, a chat thread where staff and a member talk with both pages open, and a notification bell that should increment the moment a ticket is filed or an invoice is paid. Everything else is a request and a response, and is happier that way.
The constraint is what the backend already is: one synchronous Django monolith, operated by the person who wrote it. The default answer, Django Channels, would have meant an ASGI deployment beside the WSGI one, a channel layer, Redis and async views — too much to pay for three features.
So real-time lives outside the application: a Mosquitto broker beside it, speaking plain TCP to Django and WebSockets to browsers. The rule that came with it, and that everything downstream obeys: the database is the source of truth, the bus only makes things instant, and no fact is allowed to live on the bus alone.
What I built
The backend half is a 31-line module: one lazily-connected paho client per gunicorn worker, reconnecting if the connection dropped, behind a single publish(topic, payload) that JSON-encodes the payload, prefixes every topic with the product's namespace, and publishes at QoS 1. Every failure path logs and returns, so a broker outage can never take down an API request.
Browsers cannot speak raw MQTT, so the broker also runs a WebSocket listener on a loopback address and the reverse proxy passes /mqtt through to it with the upgrade headers and a 24-hour read timeout: same origin, same TLS certificate, no extra port open to the world.
The browser half is about 75 lines: the mqtt.js client, a five-second reconnect period, and a Map of topic to callback sets whose connect handler replays every tracked topic, so subscriptions survive a reconnect. One decision above that made the rest cheap: at startup the app subscribes once to the wildcard and re-broadcasts every message as a DOM CustomEvent on window. Most components therefore never import the MQTT client at all — they add a window listener when they mount and drop it when they unmount. Only chat and the whiteboard editor hold scoped subscriptions of their own.
- memberhub/ — the namespace every topic carries
- org-wide — flat domain/event; the client filters by org
- chat — chat/{org}/{thread} and /typing
- whiteboard — whiteboard/{board}/shape, /cursor, /laser
- memberhub/ — org-wide
- memberhub/ — chat
- memberhub/ — whiteboard
The notification center is one model and five endpoints, with exactly one write path: create the row, then publish it, wrapped in a try/except that returns None — the eleven tools calling it must never fail their own operation over a notification. The header refetches the unread count on load and on every organization switch, so the badge reconciles from the database; the live message only makes it increment sooner.
The whiteboard persists each shape mutation into the board's JSON document and rebroadcasts it in presigned form, so live subscribers render an embedded image without a follow-up fetch. Cursors, laser strokes and chat's typing indicators go browser-to-broker and are never written down at all — presence falls out of that for free, participants expiring ten seconds after their last cursor message.
Decisions
MQTT instead of Django Channels. The Python side stays a plain WSGI app with no async story. The cost is a second daemon I own, and a transport with no notion of my application's users — which is exactly where the trouble came from later.
The database first, the broker second. The alternative — publish and let the client treat the message as the record — is faster and lossy. Here a closed tab or a broker hiccup costs nothing: the row is written, and the next REST fetch reconciles.
One connection per tab, and ephemeral state that never touches the server. Every message reaches every listener in the tab, which at these payload sizes is free; cursors, laser strokes and typing indicators get the durability they deserve, which is none.
Asymmetric write paths for members and guests. A signed-in editor publishes each shape straight to the broker and autosaves the whole document on a two-second debounce. Anonymous guests on a public board can do neither: their edits go through a narrow endpoint where the server persists one shape operation and rebroadcasts it — the server is the only trusted writer for anonymous input.
What went wrong
The broker locked the application out. The broker is shared with another application on the same box, and that application's own security hardening turned off anonymous access globally and pointed the broker at a password file. Nothing in MemberHub had changed or been deployed; every client, the server-side publisher and every browser tab alike, simply started getting a "not authorized" refusal at connect, and the browsers retried it every six seconds, forever.
The fix was to become a first-class tenant of the broker rather than an anonymous guest: one broker identity for the backend publisher, a second for browsers, and three small endpoints that hand a browser its credentials, each gated by whatever already authorises the surface it serves: staff authentication for the app, a valid thread token for the member chat page, an actually-public board for the public whiteboard. Three front-end call sites learned to fetch credentials before connecting.
Two honest notes. There are still no per-topic access rules on the broker, so any authenticated identity on it can publish or subscribe anywhere — the known next piece of hardening, and the reason org-wide events carry only low-sensitivity metadata. And the design that keeps a broker outage from breaking an API request, log the failure and return, is what kept this one quiet on the server side: resilience and silence are the same property seen from two directions.
The service worker that held data hostage. The hand-written service worker had been rewritten once already, after a version that cached everything cache-first stranded users on old JavaScript across a deploy. The rule that came out of that: JavaScript under the static path is network-first, everything else — "CSS, images, fonts" — is cache-first.
Months later, edits to the translation catalogs stopped reaching returning users: new files on disk, new strings on a hard reload, old labels for anyone who had visited before. Those catalogs are JSON fetched at runtime — data, not code — and the freshness policy is keyed on file extension, so they fell on the cache-first side of a line drawn for images. Nothing was broken; the system did what it had been told. The only eviction path is the cache-generation string at the top of the worker, so any change to a non-JavaScript static asset now ships with a version bump.
Outcome
Three features that feel live are served by a 31-line server module, a 75-line browser module and one broker: collaborative drawing with live cursors and presence, staff↔member chat that needs no member account, and a notification bell fed by eleven tools through one function. The chat schema has never needed a second migration.
What is still owed is written down rather than hidden: per-topic access rules on the broker, per-user read state on notifications, and the plain fact that the cache-generation counter — now in the fifties — is human discipline rather than a guarantee.