Lindsay Edwards

A connected socket is not server push

A real-time notifications feature looked finished. There was a hub class on the server with a method for sending a notification. The client connected to it, with automatic reconnect, and the socket showed as connected. The notifications even appeared in the UI. Every visible sign said done.

It was not done. The server never pushed anything. What looked like real-time was the client quietly polling in the background, and the polling was masking a completely dead push path. For a user that means the alert meant to arrive the instant something happens instead trickles in whenever the page next checks on its own, and the day someone trims that background check to save load, the alerts stop arriving and nobody notices.

The bit that looks like the feature, and isn’t#

The confusion is a genuine one about how these frameworks work. A method on a hub class is client-to-server: it is something a connected client can call. Having one, and having a connected client, makes it look like you have real-time. But a server-initiated broadcast is a different mechanism entirely. To push from the server, from a background job, a request handler, anywhere that is not “inside a message the client sent”, you have to resolve the framework’s hub context and send through that.

Nothing in the codebase ever did. The jobs wrote notification rows to the database and stopped. No hub context was injected anywhere, so no message ever left the server. The socket was connected to a hub that only knew how to listen.

The fix was a small service that both persisted the notification and pushed it via the hub context. And a dependency-injection detail worth knowing, because it trips people up: that service is request-scoped (it needs the scoped database context), while the hub context is a singleton, and injecting a singleton into a scoped consumer is fine. It is the other direction, a scoped thing captured by a singleton, that burns you.

“The socket is connected” tells you the client can reach the server. It tells you nothing about whether the server ever speaks first. Those are two different features, and only one of them was built.

How to actually check#

The tell, and the thing I now grep for before believing any real-time feature works, is an actual injection of the hub context somewhere on the server. No hub context, no push, no matter how healthy the connection looks. And be suspicious of a polling fallback sitting next to a push feature, because polling is very good at making a dead push path look alive, right up until you turn the polling off and the screen stops updating.

(One more small thing from the same corner: the client’s cleanup called the socket’s stop method without catching its rejection, so a failed stop became an unhandled promise rejection. Teardown paths need their error handling too. The end of a lifecycle is still part of the lifecycle.)

It is the same lesson as a lot of my bugs, really. A thing that looks complete from the outside, a connected socket, a green test, a saved record, is not the same as the thing actually happening. You have to check the mechanism, not the appearance.

Keep reading