I built a real-time monitoring dashboard with SSE instead of polling
I moved a server monitoring screen from 2-second polling to SSE. The Express and EventSource code, and how flushpackets fixed events arriving in bunches behind Apache.
#SSE #Realtime #React #Backend
I built a monitoring dashboard for watching server status (CPU, memory, disk). It's real time, so the data has to keep updating, and at first I just did it with polling, calling the API again every 2 seconds. It worked, but when I opened the network tab, the same request was going back and forth every 2 seconds without end. The screen looked fine, but something about it bothered me. So I switched to SSE (Server-Sent Events) . In this post I go over why I dropped polling and how I moved to SSE, and also why something that worked fine locally stopped arriving once it was on the server. Why wasn't polling good enough? Polling is where the client keeps asking the server "give me the data" every 2 seconds. It's nice because it's simple, but it wasn't a good fit for a screen that stays open once you turn it on. It sends a request even when the data hasn't changed, which is waste, and there's always a delay as long as the request interval, so it's a little late to call real time. On top of that, each one is a new HTTP request, so overhead like headers is repeated on every request. Leave one dashboard tab open all day and that's more than 40,000 requests. Most of them carry exactly the same content as the previous response. With SSE, the server sends first SSE goes the other direction. Once the client opens a connection, the server pushes data in on its own whenever there's an update. The client doesn't need to keep asking and only has to sit and receive. It's lighter than WebSocket, and if all you need is one direction, from server to client, SSE is enough. Monitoring is exactly that case. The browser has nothing to send to the server and only needs to receive. The backend opens the response with a text/event-stream header and writes data periodically without closing the connection. // db/metrics.js router.get('/stream', (req, res) = { // Token check (EventSource can't carry headers, so I took it in the query. This comes up again later) try { jwt.verify(req.query.token, JWT_SECRET); } catch { return res.status(401).end(); } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }); res.flushHeaders(); async function send() { const data = await collectMetrics(); res.write(`data: ${JSON.stringify(data)}\n\n`); } send(); // Once right on connect const interval = setInterval(send, 2000); // Then every 2 seconds req.on('close', () = clearInterval(interval)); // Clean up on disconnect }); The format is simple. Write the content after data: and end with one blank line, and the browser takes that as one message. You can put JSON in as is. The thing you have to take care of here is req.on('close'). When the client closes the tab, the setInterval has to be stopped. Otherwise timers that keep firing data at dead connections pile up. Even if I'm the only person opening and closing the dashboard, opening it ten times a day means ten timers. Leave this out and the server's memory slowly leaks. On the frontend, one EventSource is all it takes Browsers have a built-in EventSource object for receiving SSE. No separate library is needed. Connect, receive with onmessage, close on unmount, and that's it. // hooks/useSSE.ts (the first version) export function useSSE(path, onData) { useEffect(() = { const token = localStorage.getItem('monitor_token'); const es = new EventSource(`${path}?token=${token}`); es.onmessage = (e) = onData(JSON.parse(e.data)); es.onerror = () = { /* handle disconnect */ }; return () = es.close(); // Clean up }, [path]); } With it wrapped in a hook like this, the dashboard component only has to call useSSE and the data flows in on its own. The setInterval, the cancellation handling and the duplicate request guard from the polling version all went away, so it actually got cleaner. As a bonus, EventSource reconnects by itself when the connection drops. I didn't have to write that part either. It worked locally, but nothing arrived on the server When I finished and put it on the server, the events didn't come. More precisely, they came in a bunch after a long wait. What had arrived steadily every 2 seconds locally came in fits and starts on the server, several at once. I kept staring at the Node code, but the problem was in front of it. Everything on my server runs behind an Apache reverse proxy, and while proxying, Apache was collecting the response in a buffer and sending it out all at once. Trickling data out continuously is all SSE does, but from the proxy's point of view that was just a buffer that wasn't full yet. # monitor-ssl.conf ProxyPass / http://localhost:5020/ flushpackets=on Adding the single word flushpackets=on fixed it right away. It's an option that says to send out packets received from the backend immediately instead of collecting them. Finding it took about two hours. There's no Apache locally, so I couldn't reproduce it, and the server code was identical to local, so the code couldn't have been the problem, and yet the code was all I looked at. Counting the places on this server where this option is set right now, it's three config files. For monitoring I set it separately on just the three stream paths, and for the blog and the couples app it's on the whole ProxyPass. That means those are exactly the services that stream something in real time. Now when a new service gets SSE, this config is what I check first, before the code. What changed afterward: the token no longer goes in the URL There's one part of the code above that bothered me. EventSource can't attach request headers, so I passed the JWT in the query string. But the query string is part of the URL, so it's written to the Apache access log as is. When I searched the logs, there really were lines with the raw token in them. I wrote that story up separately in my 100-day retrospective on the monitoring server. So now, right before connecting, the client gets a one-time ticket that lasts 30 seconds and passes that instead. It's a random string that exists only in server memory, so even if it ends up in a log it can't be used again. // hooks/useSSE.ts (the current version) const connect = async () = { const ticket = (await getStreamTicket()).data.ticket; // POST /api/auth/stream-ticket es = new EventSource(`${path}?ticket=${encodeURIComponent(ticket)}`); es.onmessage = (e) = onDataRef.current(JSON.parse(e.data)); es.onerror = () = { // The ticket is one-time, so the browser's auto-reconnect ends in a 401. Reconnect manually with a new ticket es?.close(); if (!disposed) retry = setTimeout(connect, 3000); }; }; The cost is that I can no longer use the automatic reconnect that EventSource gave me for free. Reconnecting with the same ticket gets a 401, so every time it drops, the client gets a new ticket and reconnects by hand. The automatic reconnect I used for convenience turned into an obstacle once I tightened security. If the screen only needs data streamed from the server to the browser, I'd recommend trying SSE first without going as far as WebSocket. The code is shorter than polling, but two things, proxy buffering and where to carry the auth value, are better thought through together from the start.