MQTT.Agent - open protocol for AI agents

Back to blog
Notifications Real-time

Notifications the Broker Handles, So Your App Doesn't

How we replaced polling and SSE plumbing with a topic-per-user pattern that scales without code.

December 15, 2025
6 min read
By CloudSignal Team

In-app notifications are the kind of feature that looks simple in a screenshot and turns into three weeks of plumbing in practice. The bell icon, the unread badge, the toast that slides in from the corner: those are an afternoon of UI work. Everything underneath, the part that decides whether a notification reaches the right tab at the right second, is where teams quietly spend their time. We have shipped notifications three different ways over the years, and only one of them stopped feeling like a maintenance tax.

Three ways teams ship in-app notifications

The first instinct is long-polling. The client asks the server every few seconds whether anything new has happened, the server says no most of the time, and a notification eventually shows up on the next poll. It works on day one. It breaks when traffic grows, because each poll opens a database connection to check an unread table, and the connection pool is the first thing to saturate. Latency floats up to whatever your poll interval is, and shortening the interval makes the database problem worse.

Server-sent events are the second instinct, and they are a real improvement. The connection stays open, the server pushes when it has something to say, and the browser handles the wire format. The catch is that SSE is one-way, so any action the client needs to take (mark as read, dismiss, snooze) goes through a separate HTTP call. Reverse proxies are the first thing to break: most of them idle out long-lived connections after thirty or sixty seconds, and the reconnect logic, including replay of missed events, is yours to write.

Websockets fix the directionality. You get a full duplex pipe and can push state in both directions. The price is the code you write around it. Reconnect after disconnect is a state machine: track the last event id, replay from the server, deduplicate on the client, handle the case where the server has rolled past your cursor. We have written this code. It is the kind of code that is fine until the day it isn’t.

The topic-per-user pattern

The cleaner alternative is to stop writing transport code and let a broker do the routing. Each user subscribes to a topic that is theirs alone, scoped by organization. Backends publish to that same topic when something happens. The broker fans out to whichever clients are connected. The application has no socket logic, no event id bookkeeping, no resume cursor.

client.subscribe(`notifications/${org}/${userId}`, { qos: 1 });
client.on('message', (topic, payload) => {
  showToast(JSON.parse(payload.toString()));
});

That is the entire subscribe side. The publish side, from any backend that can hit a REST endpoint, posts a JSON body to the same topic. We did not build a notifications service. We picked a topic shape.

Offline delivery

The part of this pattern that earns its keep is offline delivery, and it is a property of the protocol rather than a feature we shipped. With QoS 1 and a persistent MQTT session, the broker tracks which messages a client has acknowledged. While the client is offline, the broker queues every notification destined for that user. When the client reconnects, the queue drains in order, and the application sees the same callback fire for the backlog that fires for live messages.

We have no unread notifications table. We have no cron that walks pending records and dispatches them on next login. The broker is the queue, the session is the cursor, and the protocol guarantees that a QoS 1 message marked for a clean session of false will be there when the client comes back. The application can render a list, mark items as read, archive them, all of that is your product. The transport is finished by the time your code runs.

Fanout to multiple devices

A user with the app open on a phone and a laptop subscribes to the same topic from two clients. The broker fans the message out to both. The application code does not know how many devices are connected, does not maintain a device registry, and does not need to publish twice. Whoever is listening, gets the message.

There is one edge case worth naming. The broker delivers, but it does not track read state across devices. If a user dismisses a notification on their phone, their laptop has no way to know that without an explicit signal. The fix is to publish read events as their own messages on a sibling topic and let each device update its local state when it sees one. That is application logic on top of the primitive, not part of it.

Where this isn’t enough

Push notifications to a closed or backgrounded app are a separate concern. When the tab is not open and the process is not running, no client is subscribed and the broker has nothing to deliver to. That is what web push, APNs, and FCM are for, and they sit alongside this transport rather than competing with it. The natural product shape is in-app while the app is running, OS-level push when it is not, and they share a publish event upstream.

Digest emails for unread notifications are an application concern. The broker does not know whether a notification has been seen, so summarizing yesterday’s unread items into a morning email is something your service decides. Server-side badge count rendering (the number that appears on an icon for users who are not currently connected) belongs to whatever service holds your read state. Expiry policies for old notifications, retention windows, archival behavior: all application. Saying this plainly matters, because the temptation with a flexible transport is to push every concern into it, and most concerns belong upstairs.

What we want to add next

A topic-per-user pattern is the substrate. On top of it we want to ship things that feel like notifications but are not strictly transport. Reusable templates, so a backend can publish a structured event and the client renders it consistently, are the obvious next step. Read-state sync as its own topic, with retained messages so a newly opened device sees the same unread set as a device that has been connected for hours, is the other one. Both of these are application patterns we keep rewriting, and both want to live close enough to the components that drop-in adoption stays possible. We are still figuring out which parts to ship as a library and which parts to leave as a documented pattern.

Ready to get started?

Try CloudSignal free and connect your first agents in minutes.

Start Building Free