From SSE to WebSocket — Because We Couldn't Throw Messages Away
We'd built realtime sync with SSE and it worked. Then XHR kept every byte it received and memory crept up. So we moved to WebSocket.
From SSE to WebSocket — Because We Couldn’t Throw Messages Away
In Fecit, a change on one device shows up on another right away. Complete a task on your phone and the desktop view updates; top up dana and the balance climbs within seconds. We first built this with SSE (Server-Sent Events) — a lightweight, one-way push from the server. It worked.
Then one day, leaving the app open for a while made memory creep up.
The culprit: XHR that can’t let go
On mobile (React Native) there’s no browser-style EventSource, so we were reading SSE through XHR (XMLHttpRequest) streaming. That’s where the problem lived.
Every time a chunk arrives over the stream, XHR keeps appending it to responseText. We only want the newly arrived slice, but as far as XHR is concerned, it has to hold “everything received so far” as one string. Which means every event that flows through, for the entire life of the connection, stays in memory.
A realtime connection stays open for hours. All the events that passed through in that time are still sitting inside responseText — even the messages we’d already handled and forgotten, XHR was faithfully remembering.
WebSocket receives and discards
The fix was to swap the transport. WebSocket. It hands you one message and that’s it. You take it in onmessage, handle it, and it’s gone. No accumulation.
ws.onmessage = (e) => {
if (typeof e.data !== "string") return;
this.handleMessage(e.data); // handle it and done. nothing piles up.
};
The key was to swap only the transport and leave everything above it alone. Code all over the app subscribes to this client with things like on("achiever_updated", ...). By keeping the public API (connect/disconnect/on/connectionId) and the event types identical, consumer code didn’t change by a single line. New engine, same wheel.
And then desktop
After moving mobile, desktop stayed on SSE for a while. Desktop reads with fetch + ReadableStream, not XHR — it processes each chunk and drops it — so it didn’t have the same leak. There was no need to touch it.
Still, having two different transports nagged at me, so eventually desktop got unified onto WebSocket too. Here I ran into a few web-only traps.
You can’t attach headers. The WebSocket constructor in browsers (and Tauri) doesn’t support custom headers. We couldn’t pass the token via an Authorization header like usual, so it went into a query parameter.
const url = `${wsBase}/api/achiever/events/stream-ws?token=${encodeURIComponent(token)}`;
The proxy paths overlap. In development, requests go through the Vite proxy to the backend, and forwarding a WebSocket needs ws: true. But the new path /…/stream-ws shares a prefix with the existing SSE path /…/stream. Unless you register the more specific -ws one first, the SSE proxy grabs it and the WebSocket handshake breaks.
Don’t miss anything when it drops
WebSockets drop. Wi-Fi blips, the server restarts. So when it drops, we reconnect automatically after three seconds.
The real question is the changes that happened while we were disconnected. What if another device edited a task in the meantime? So every time it connects, the server hands over a connection ID in a connected message, and on a reconnect we use that as a signal to re-pull “everything since the last point we saw” and fill the gap. Drops, yes — but no losses.
Lessons
-
In streaming, “do you discard what you receive” matters. XHR holds everything; WebSocket lets go. The longer the connection lives, the more that difference piles up as memory.
-
Separate the transport from the logic on top, and swapping is easy. Keep the public API identical and you can replace the whole engine while consumers stay untouched.
-
The same code has different traps in different environments. RN accepts headers, the web doesn’t; the leak only happened on XHR. “It worked on mobile, so the web is fine” is wrong more often than not.
Realtime is invisible when it works and only announces itself when it doesn’t. Memory’s the same way.