
One write, two deliveries
The notification service behind SynapStore, built during a hackathon. Redis Streams for history, pub/sub for speed, web push for closed tabs, and what I would change reading it a year later.
SynapStore is a platform for store owners and their suppliers. I led a team of four on it at the Zenith Hackathon at MLRIT in December 2025, and we won. Most of the app was the usual shape: an Express API, Prisma, BullMQ for background jobs and a React frontend. The smallest piece was a separate notification service of under 500 lines that I wrote on December 11.
The requirement sounded simple. When something happens, like a supplier confirming an order, the store owner should see it. Right away if the dashboard is open, and as a system notification if it is not.
Three ways to deliver one event
A notification has to show up instantly in an open tab, still exist if nobody was looking, and reach someone whose tab is closed. No single Redis feature does that.
Pub/sub is instant, but a message published with no subscriber is gone. Streams keep history, but reading new entries for many streams at once means a blocking XREAD loop, and the service could have one stream per store. Web push reaches a closed tab, but only through the browser's push service.
So the publisher does all three, in order:
const eventId = await redis.xadd(
streamKey,
'*', // auto-generate ID
'data',
JSON.stringify(payload),
'timestamp',
Date.now().toString()
);
// 1. Broadcast via PubSub for connected SSE clients (In-App)
const pubSubPayload = JSON.stringify({ id: eventId, payload });
await redis.publish(channelKey, pubSubPayload);The stream entry is the record. The pub/sub message is only a wake-up call that carries the same payload and the stream ID. That ID matters: the server-sent events handler writes it as the SSE id: field, so every event a browser sees can be traced back to a stream entry.
const cleanup = await subscribeToRealtime(target, (id, payload) => {
res.write(`id: ${id}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
});I picked server-sent events over WebSockets because the data only flows one way. SSE is plain HTTP, the browser reconnects on its own, and the server code is a few res.write calls.
Push subscriptions go stale
Web push has the most ways to fail. Browsers hand out a push subscription, the service stores it in a Redis set per store, and every notification is sent to every subscription in that set. The catch is that subscriptions do not live forever. A user clears site data or the browser rotates the endpoint, and the push service starts answering with 410 Gone or 404.
Sending to dead endpoints forever is wasteful, so a failed send with either status removes that subscription:
if (err.statusCode === 410 || err.statusCode === 404) {
// Subscription expired/invalid, remove it
await redis.srem(subKey, subStr);
}Each send has its own try block, so one dead endpoint does not stop the rest, and the whole push step sits inside a wider try so a push failure never fails the request. The event is already in the stream by then. Last, the stream is trimmed so history cannot grow without bound:
await redis.xtrim(streamKey, 'MAXLEN', '~', 1000);The ~ lets Redis trim approximately, which is cheaper than trimming to an exact count on every write.
The response tells you who heard it
The POST /notify response reports realtime_connections from PUBSUB NUMSUB and push_subscribers from SCARD on the subscription set. That makes debugging easier: if a notification did not appear, the response already says whether anyone was listening.
Reading it again a year later
I went back to this code to write the README in January and February 2026, and some things stand out.
- History is written but never replayed. Every event goes into a stream with an ID, and every SSE event carries that ID, but the handler never reads the
Last-Event-IDheader a reconnecting browser sends. A tab that drops for ten seconds misses whatever was published in those ten seconds. The fix is small: on connect,XRANGEfrom that ID, send what was missed, then switch to pub/sub. - One Redis connection per open tab.
subscribeToRealtimecallsredisSubscriber.duplicate()for every SSE client. That is fine for a hackathon and a problem at a few thousand tabs. One shared subscriber that fans out in memory would scale much further. - The store is a hostname. The service identifies a store by the hostname of
websiteUrl, and/notifyhas no auth. A code comment admits it should be an API key or a store ID. Anyone who can reach the service can notify any store.