Implementing SSE Real-Time Sync
Adding SSE to a FastAPI server and connecting desktop and mobile clients — the problems we hit along the way.
Implementing SSE Real-Time Sync
In the previous post, we decided to use SSE for real-time sync between mobile and desktop. This post is about what happened when we actually built it.
Server: FastAPI + In-Memory Pub/Sub
The server is Python FastAPI. SSE is implemented with StreamingResponse — a generator function yields events to the client.
The core is EventManager. It manages subscribers (asyncio.Queue) per user. When someone modifies a task, the event goes into every subscriber’s Queue. Each SSE stream’s generator pulls from its Queue and yields.
class EventManager:
def subscribe(self, achiever_id: str) -> Subscriber:
# Create a Queue, add to subscriber set
async def publish(self, achiever_id, event_type, data, exclude_connection_id):
# Push event to all subscriber Queues for this user
Simple structure. In-memory, no Redis or message queue. One server, so this is enough.
Excluding the Sender
First problem. When you complete a task on mobile, mobile itself gets the event. The UI already reflected the change, but now it fetches again unnecessarily.
Solution: when a client connects via SSE, the server assigns a unique connection_id. The client attaches it as an X-Connection-Id header on every subsequent REST request. The server excludes that connection when publishing.
SSE connect → server: "your connection_id is abc123"
REST request → client: "X-Connection-Id: abc123"
server publish → skip abc123, send to everyone else
We added this to every mutation endpoint. About 100 of them.
Desktop: Fetch Streaming
Desktop runs on Tauri (web-based), so the browser’s EventSource API seemed like the obvious choice. But EventSource doesn’t support custom headers. We need to send a Bearer token.
So we built it with fetch + ReadableStream. Read the response body with a reader, parse events by splitting on \n\n.
Vite Proxy Buffering
SSE didn’t work in development. The server was sending events, but the client never received them.
The cause: Vite’s dev server HTTP proxy was buffering the SSE response. We had to add a separate proxy config for the SSE path and disable buffering.
"/api/achiever/events/stream": {
target: "http://localhost:8001",
configure: (proxy) => {
proxy.on("proxyRes", (proxyRes) => {
proxyRes.headers["x-accel-buffering"] = "no";
});
},
},
Not an issue in production — no Vite proxy in the way.
Mobile: Fetch Doesn’t Stream
We tried fetch with response.body.getReader() in React Native. The connection opened, but no data came through. React Native’s fetch implementation doesn’t support reading the body progressively on a never-ending response. SSE is exactly that — a response that never ends.
Solution: switch to XMLHttpRequest. XHR’s onprogress event fires every time new data arrives. We read responseText from the last known position forward.
xhr.onprogress = () => {
const newData = xhr.responseText.substring(this.lastIndex);
this.lastIndex = xhr.responseText.length;
// Split newData by \n\n and parse events
};
This works reliably.
Foreground Only
Mobile apps in the background get their network connections killed by the OS. iOS is especially aggressive. Keeping SSE alive would drain battery.
So we watch AppState — connect when foregrounded, disconnect when backgrounded. The existing syncAt-based incremental sync catches up on missed changes when the app returns.
Disconnection and Reconnection
SSE connections can drop at any time. Server restart, network change, client sleep.
We manage connections with AbortController and auto-reconnect after 3 seconds. Server-side, we check for client disconnection every 5 seconds and clean up dead subscribers.
Subscriber Leak
Found during debugging: the server’s subscriber count kept growing. Clients disconnected, but the server never unsubscribed them.
Two causes. The server’s disconnect check interval was 30 seconds — too slow. And the client’s disconnect() wasn’t aborting the in-flight fetch, so the connection lingered as a zombie.
Detail Panel Sync
The list updated, but the detail panel didn’t. The detail component manages local state (title, description, etc.) separately. When the prop changed, local state stayed stale.
The fix: add task.revision to the effect dependency that resets local state. The server increments revision on every edit, so changes from other devices trigger a reset.
Result
Complete a task on mobile, the desktop list and detail panel update instantly. Change a title on desktop, mobile reflects it immediately. No refresh needed.
In-memory pub/sub, SSE streams, connection_id-based sender exclusion. Real-time sync on top of plain HTTP, no complex infrastructure.