Subscriber connected, zero events — a 5-point checklist before you blame the hub
A pub/sub subscriber that logs "subscribed" and never errors, but never invokes its message handler either, almost always has one of five specific, checkable causes — none of which show up as an exception, because each one is a silent drop by design.
The signal that gives it away
- The subscriber's own log looks *healthy*: "subscribed: <url>" (or equivalent), no exceptions, no reconnect spam — yet whatever downstream state it's supposed to trigger (a cache warm, a UI panel, a background job) never updates.
- The hub's own log (docker logs <hub> or equivalent) shows the publish actually happened — the update was accepted and dispatched to a topic. So the break is not "nothing was published," it's "published, subscriber connected, and still nothing crossed the wire into application logic."
- Symptom often looks *time-delayed* rather than *permanently broken*, which is misleading: if only one code path in a multi-path system forgets a required field, that path's events vanish while others work, so the resource "eventually" gets state — hours later — once some *other* event with the field set finally lands. This makes it look like a slow warm-up bug when it's actually a total, permanent drop on one specific path.
- After roughly an hour of container/process uptime, the subscriber starts spamming 403 Forbidden on every reconnect attempt, with exponential backoff that never recovers — until something restarts the process, at which point it works again for about an hour before repeating. That specific rhythm (works right after restart, dies ~1h later, forever) is a distinct signal from "never worked since deploy."
How to exploit it — checklist in order
Work through these in order; each one is a five-minute grep, and together they cover the overwhelming majority of "connected but silent" cases seen in the field.
**1. Does the publisher put the topic *inside* the JSON payload, not just in the publish request?**
This is the single most common cause, and it's a property of the Mercure protocol itself, not a bug in any one client library: per the [Mercure specification](https://mercure.rocks/spec), a publish request carries topic as a form field used by the hub for fan-out/routing to matching subscriptions — but the SSE data: line a subscriber actually receives is *only* the data field the publisher chose to send. The protocol does not auto-inject the topic into that payload. If your subscriber-side routing logic reads payload.get("topic") to decide what to do with an event, and the publisher never explicitly copied the topic into the JSON body, every such event has topic: None and gets dropped before it reaches any handler — silently, with no error, because from the subscriber's point of view a payload missing an optional field is not a protocol violation.
Fix: on the publish side, before serializing, do the equivalent of payload.setdefault("topic", topic) — populate it defensively (setdefault, not unconditional assignment) so you never clobber a value the caller already set. Audit every publisher module in the codebase for this: grep for where the JSON body is constructed and confirm topic (or whatever routing key your app convention uses) is actually written into it, not just passed to the hub's form field.
2. Is the subscriber's auth token minted fresh on every reconnect, not once before the loop?
If the subscriber authenticates with a signed, TTL-bound token — a JWT per the [Mercure spec's mercure.subscribe claim](https://mercure.rocks/spec), which requires an array of topic selectors in the JWS presented to the hub — and that token is minted *once*, outside the reconnect loop, then it works fine for as long as the *initial* connection survives (SSE auth is typically checked on connect, not per-message, so a long-lived connection outlives its own token's TTL without issue). The failure appears only at the first reconnect after the token has expired: reconnect fires with the same stale token, gets 403 (the spec's recommended response for a subscriber whose JWS fails verification or scope, not 401), backs off, retries with the *same* stale token, gets 403 again — forever. This is exactly the "works ~1h after restart, then dies" rhythm described above; the TTL is the hour, and every subsequent reconnect attempt after expiry is doomed by construction.
Fix: move token minting *inside* the reconnect loop body, so every iteration (i.e. every connection attempt, not just the first) gets a fresh token with a fresh TTL window. Concretely, the token generation call must be the first thing inside the while True: try: block, not a line above it. Audit: grep for the minting call near any while True reconnect loop and confirm it's inside, not above.
3. Does the subscriber's topic pattern actually match what's published, variable-for-variable?
Mercure topics support URI-template selectors (e.g. things/{id}/{event}), and the hub does literal template matching. If the publisher emits on things/{id}/{kind}/{event} (three variables) but the subscriber's subscription list only requests things/{id}/{event} (two variables), the templates don't match and the hub never dispatches — this is normal, spec-correct behavior, not a bug in the hub. It reads as "subscriber connected, publish happened, nothing arrived" because both sides are individually correct and only their *shapes* disagree.
Fix: diff the subscription list your client requests against the exact topic string format every publisher constructs. Every variable segment must line up positionally and by name.
4. Has the topic's scoping key changed identity (e.g. from one kind of ID to another) without updating every publisher?
A related shape of #3: if topics were historically scoped by one identifier (say, a coarser grouping key) and the convention later moved to a finer-grained one (say, a per-session key), any publisher still emitting on the old key silently never reaches subscribers now listening on the new one. No error on either side — the hub is just correctly not matching two different strings.
Fix: grep for the old scoping convention across all publisher modules and confirm none of them still use it. Standardize on one identity for all new topics going forward.
5. Is the field your application-level routing depends on actually populated in the payload, for every publisher, not just the common one?
Even with topic + auth + subscription pattern all correct, an application-level handler often has its own internal gate — e.g. "skip this event if its session_id field is empty" — that isn't part of the pub/sub protocol at all, it's your own code. If one publisher extracts that field implicitly from the topic string (works fine) while a different publisher constructs its topic differently and forgets to set the field explicitly, that publisher's events pass every protocol-level check and still get silently discarded by your own application-level guard clause.
Fix: audit every publisher for whether it explicitly sets every field your own downstream handlers key on, rather than assuming it's always derivable from the topic.
Quick test (five minutes, do this before diving into any of the five points above)
1. Publish one synthetic test event through the specific publisher you suspect, with an easily-grep-able marker session/topic id.
2. docker logs <subscriber> --since 30s | grep <marker> — this MUST show the event. If it doesn't, the drop is on the subscriber/application side (points 1, 3, 4, or 5).
3. docker logs <hub> | grep <marker> — this MUST show the hub actually dispatched it. If the hub log is also empty, the problem is upstream of the hub entirely (publisher never sent it, or auth failed on the *publish* side) — don't waste time on the subscriber-side checklist.
4. If the hub published it but the subscriber stayed silent → it's point 1 (topic missing from JSON) or point 3 (subscription pattern mismatch) — check the payload shape first, it's the more common of the two.
5. If neither log shows anything → it's point 2 (auth/token expired, so the subscribe request itself never got that far) or a publisher-side token/permission problem.
Related: Reconnect without replay loses events in the gap — the fix is Last-Event-ID, not a faster reconnect (once the subscriber is confirmed to receive events correctly, the next failure mode is losing events *during* a reconnect gap, not before the connection even authenticates), 401 on a resumed session: a helper preferred the expired token snapshotted in state over the fresh one in Redis (a sibling stale-credential bug: preferring a persisted, aging token over a freshly-refreshed one, in a different part of the system), A long-running tool's follow-up turn is generated server-to-server and never streamed to the browser — the UI needs an F5 to show what already happened (a distinct silent-delivery failure where the event is never even attempted, versus attempted-and-dropped here).
What doesn't work
- Assuming the hub auto-propagates the topic into the payload. It's tempting to treat "the hub knows what topic this is, why would I need to repeat it in the JSON" as obviously true. It isn't: per the protocol, the topic used for fan-out routing and the data content delivered to subscribers are two separate channels of the same publish request, and only the publisher can put the topic into the second one. Nothing in the hub does this for you.
- Restarting the process as a fix for the 403 loop. A restart resets the token-mint-once bug's clock back to zero, so it looks fixed — for about an hour, until the same token expires again. Treating "restart it" as the resolution instead of moving the mint call inside the reconnect loop just defers the same outage on a recurring schedule.
- Setting an effectively infinite token TTL to make the problem go away. This does eliminate the reconnect-expiry class of bug, but at the cost of a leaked or logged token remaining valid forever — a real security regression traded for a symptom fix. Minting fresh per reconnect attempt gets you the same reliability without that trade-off.
- Debugging the render/handler layer first because that's where the symptom is visible. The application code that's supposed to react to the event is rarely where the bug lives when the subscriber's own log shows a clean, healthy connection. Start with the five-minute synthetic-publish test above; it tells you in one shot whether the drop is between hub-and-subscriber or between subscriber-and-application-code, which halves the search space before you read a single line of handler code.
- Trusting that "connected" in the log means "correctly subscribed to everything you think it is." A subscription can be live, authenticated, and receiving *some* topics fine while being silently blind to one specific shape of topic (a mismatched template, a stale scoping key) — a healthy-looking connection log is not proof that the subscription list matches the publisher's actual topic strings.
Verified against
10 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.