Most web apps poll. A client asks the server every few seconds whether anything changed, and almost every time the answer is no. A dashboard on a five-second interval costs 720 requests an hour, per tab, to mostly learn nothing — and it's still seconds behind reality.
So you go looking for push. And you find that every option charges a tax that has very little to do with pushing.
The tax
Standalone hubs — Mercure, Centrifugo — are a second service to deploy, monitor, and keep alive. Worse: that service has never seen your user table. Because it can't answer "may this person read this?", you have to build a whole authorization subsystem before one message flows. Minting tokens, scoping them to topics, expiry, rotation, revocation. That subsystem is usually the largest part of the integration, and it exists only because the hub lives outside your application.
Sync engines — ElectricSQL, PowerSync, Zero — are genuinely excellent, and they solve a much bigger problem than the one you have. They replace your data layer rather than augmenting it: a client-side database, a replication protocol, conflict resolution, a migration story. If you want offline-first local writes, use one. If you just wanted the number on the screen to be current, you've adopted a new architecture to get it.
Hand-rolled SSE is fifteen lines that work perfectly on your laptop and fail in production for reasons nobody on the team remembers a month later:
- compression middleware buffers the stream, so nothing arrives until the connection ends
- a proxy reaps the connection as idle, and it silently stops delivering
- subscribers leak on disconnect, one per tab that ever connected
- updates go missing across reconnects, with nothing reporting it
That last one is the reason this post exists.
The failure mode nobody alerts on
A client showing stale data forever is worse than one that polls, because nothing fails. No error is logged. No retry fires. No alert goes off. The page just quietly lies, and the first you hear about it is a support ticket saying the numbers look wrong.
Every other failure in your stack announces itself. This one doesn't. And once you start looking for it, you find it's the default behaviour of almost every naive push implementation: the socket drops, it reconnects, it resumes from somewhere, and nobody checks whether "somewhere" was far enough back.
That gap — between what the server accepted and what the client actually received — is the thing worth engineering around.
Making the gap loud
I've been building aghoz (AH-gohz, from the Filipino agos, "flow") around exactly that idea. It's a small, dependency-free event-stream library that mounts into the app you already have as a route.
Two things follow from mounting in-process.
First, authorization is inherited rather than invented. By the time the handler runs, your session middleware has already established who the user is. So the answer to "may this user read this topic?" is a function call against a request you already parsed:
app.use(session()) // already there
app.use(loadUser) // already there — sets req.user
app.get('/events', hub.handler({
authorize: (req, topic) => topic.startsWith(`org/${req.user.orgId}/`),
}))
That one line replaces the entire token subsystem a standalone hub requires. No token exchange, no CORS when the UI and API share an origin, no second service.
Second, an interrupted stream can fail loudly. Two loss conditions are detected and reported through a single callback:
-
history-truncated— the client reconnected with a cursor older than retained history -
slow-consumer— the client couldn't drain its socket and was disconnected rather than left to starve, quietly diverging
<AghozProvider url="/events" onGap={() => queryClient.invalidateQueries()}>
Wire that one prop to a refetch and a client stops trusting a stream the server knows is incomplete. On the client, the migration is about as small as it gets:
- const { data } = useQuery({
- queryKey: ['revenue'], queryFn: fetchRevenue,
- refetchInterval: 5000, // 720 requests per hour, per tab
- })
+ const data = useTopic(`org/${orgId}/revenue`, initial)
Already on TanStack Query? Keep it. Delete the interval and let the stream invalidate:
useTopicInvalidation(`org/${orgId}/orders`, ['orders'])
Two bugs that prove the point
Here's the part I actually want to share, because it's the most useful thing I learned: I shipped this exact bug, twice, in the library whose entire purpose is to prevent it.
Bug one: the empty ring
Truncation was computed the obvious way — truncated = cursor < oldest_retained_event. That's wrong in both directions.
The false positive: 0-0 is the cursor handed out before anything is published, and it sorts below every real id. So on a freshly booted hub, the very first connect replayed everything correctly and reported a gap that couldn't possibly have happened. Every first page load after a deploy refetched. A signal that fires when nothing is wrong is a signal people learn to ignore.
The false negative was worse. An event larger than the entire history budget gets evicted by the push that stored it, leaving the ring empty. With no oldest retained entry, there was nothing to compare against — so the guard short-circuited and a real loss was reported as "nothing missed." Silent staleness, reachable from one oversized publish.
The fix is to track the highest id ever evicted instead:
truncated = last_trimmed !== null && cursor < last_trimmed
Over-reporting is a false alarm. Under-reporting is data loss with no symptom. Those are not equally bad, and the rule should be shaped by which one you'd rather have.
Bug two: the restart
Then, while building persistent history as a performance item, I probed the behaviour it was meant to improve:
life 1: publish …872-0, publish …873-0, process dies
life 2: client reconnects with last-event-id: …872-0
→ last-event-id-checkpoint: …872-0
An echo. The hub told a resuming client it had missed nothing, while the event published just before shutdown was gone for good. On every restart of every deployment. Since v0.1.
The cause: the previous rule asks "did I drop something you hadn't seen?" — and a restarted hub has dropped nothing, because it remembers nothing. An empty ring and a fresh install are indistinguishable from the inside.
A cursor is also unvouchable from the other end — newer than every id the hub has ever issued:
truncated = evicted || cursor > hub.cursor()
Two lines. A hub that has never issued an id that high cannot know what came after it.
One existing test had asserted the opposite — "an empty hub cannot report truncated, there is nothing to have lost." That intuition is wrong, and it's exactly what hid the bug: an empty hub having nothing doesn't mean the client lost nothing. It means the hub cannot tell. And "cannot tell" must resolve to "refetch," never to "you're fine."
Both bugs existed identically in two independent implementations. That's what convinced me the rules needed to live in one place.
Why Rust — and not for the reason you'd guess
Not for speed. I measured it: a Rust implementation of the hot path, compiled to wasm and called from Node, was slower than plain JavaScript at realistic payload sizes. The Rust logic is genuinely 2.2x faster, but marshalling strings across the boundary gives all of it back and then some — 0.65x at 2 KB payloads. The measurement overturned the plan.
The real reason is one implementation of the rules that must never differ: id assignment (including when the clock moves backwards), topic validation, frame encoding, and the atomicity that makes the checkpoint honest.
This isn't hypothetical. The very first run of the conformance corpus caught a real divergence: the JavaScript hub measured topic length in UTF-16 code units while Rust measured UTF-8 bytes. An 86-character Japanese topic is 258 bytes — rejected by one, accepted by the other, on the exact field whose validation exists to prevent forged frames. Every language has its own wrong answer for "length."
So the protocol is pinned by two language-neutral corpora: 97 vectors for the protocol core and 42 scenarios for the HTTP layer over a real socket. The truncation bugs above each became corpus vectors, verified to fail against the old code first — because fixing it in two places without a vector just leaves the next implementation free to reintroduce it.
The honest costs: a C ABI can't be written without unsafe, so there are 33 unsafe sites in one auditable file, verified in CI by Miri. The protocol logic itself carries #![forbid(unsafe_code)].
What it deliberately doesn't do
Scope is a feature, so the exclusions come before the install line:
- It will not work on serverless. Vercel, Lambda, Cloudflare Workers can't hold a long-lived connection. No workaround, none planned.
- It is not a sync engine. No offline support, no local writes, no CRDTs, no client-side database. Writes go through the API you already have; the stream is one-way, permanently.
- It is not bidirectional. If you need that, use Socket.IO.
-
Multiple processes need a backplane.
@aghoz/redisadds Redis Streams; without it there are zero dependencies at all.
If you need offline-first local writes, use a real sync engine — you'll be happier than with a bad imitation. aghoz is for the case where the server already knows something and the browser should stop asking.
Try it
v0.4.2 is on npm. Node 22+, Express / Fastify / NestJS on the server, React / Vue / Svelte in the browser.
pnpm add @aghoz/server @aghoz/client @aghoz/react
It's early — the API may still change and there's no deprecation policy yet, which is stated at the top of the README rather than discovered later. The protocol is written down, every significant decision is recorded with its evidence (including the three that got reversed), and the example app runs end to end in CI.
Repo: github.com/thinkgrid-labs/aghoz · MIT OR Apache-2.0
If you've hit the silent-staleness failure in your own stack, I'd genuinely like to hear how you found it. In my experience it's never an alert. It's always a support ticket.