Skip to content
HomeWorkBlogResume
Blog

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.

November 1, 20264 min read
0

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.

ExpressTypeScriptioredisRedis StreamsServer-Sent EventsWeb PushZod

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:

typescript
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.

typescript
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:

typescript
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:

typescript
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-ID header 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, XRANGE from that ID, send what was missed, then switch to pub/sub.
  • One Redis connection per open tab. subscribeToRealtime calls redisSubscriber.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 /notify has 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.
0
All posts

SreeCharan Desu

Engineer at Manuscripts · Open to remote

HomeWorkBlogResume
Chat

© 2026 SreeCharan Desu. All rights reserved.