Live updates with Server-Sent Events

When the server just needs to push updates one way — live scores, progress bars, notifications — SSE is simpler than WebSockets: a plain HTTP stream the browser reconnects for you. Verified with curl -N.

Not every live feature needs a two-way socket. When updates only flow one way — server to browser — Server-Sent Events (SSE) are the simplest tool for the job: an ordinary HTTP response that the server keeps open and writes to over time. The browser has built-in support (EventSource) that even reconnects automatically. This guide builds a working SSE endpoint on your one Falix port.

At a glance
You need A server running the Node.js application
Background Build an Express website + API
Plan Any plan
Time Twenty minutes

SSE vs WebSockets — pick the simpler one that fits

SSE WebSockets / Socket.IO
Direction Server → browser only Both ways
Protocol Plain HTTP (a long response) A protocol upgrade
Browser client Built-in EventSource WebSocket / a library
Auto-reconnect Built in, free You (or Socket.IO) add it
Best for Live scores, progress, feeds, notifications Chat, games, collaboration

If the browser never needs to send real-time messages back — it just wants to be told when something changes — SSE gives you live updates with a fraction of the moving parts. When you need two-way traffic, reach for WebSockets or Socket.IO.

The endpoint

SSE needs no library — it's a normal Express route with the right headers and a response you don't end. This one streams a numbered "tick" every second:

const express = require('express');
const app = express();

app.get('/events', (req, res) => {
  res.set({
    'Content-Type': 'text/event-stream', // the SSE content type
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });
  res.flushHeaders(); // send headers now; keep the body open

  // If the browser reconnects, it sends the last id it saw.
  let id = Number(req.headers['last-event-id']) || 0;

  const timer = setInterval(() => {
    id++;
    res.write(`id: ${id}\n`);
    res.write(`event: tick\n`);
    res.write(`data: ${JSON.stringify({ id, time: new Date().toISOString() })}\n\n`);
  }, 1000);

  req.on('close', () => clearInterval(timer)); // stop when the client leaves
});

const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));

The whole protocol is that text format. Each message is a block of field: value lines ending in a blank line:

  • data: — the payload (the only required field). Send JSON as a string and parse it on the client.
  • event: — an optional name, so the client can listen for tick specifically.
  • id: — an optional id the browser remembers and sends back as Last-Event-ID when it reconnects, so you can resume.

The req.on('close', ...) cleanup is essential: without it, the interval keeps running for every client that ever connected, and the timers pile up.

Verify it works

You can watch the raw stream with curl -N (the -N disables buffering so events appear live):

curl -N http://YOUR_ADDRESS:PORT/events

Events tick in once a second, exactly as written:

id: 1
event: tick
data: {"id":1,"time":"2026-07-18T03:36:55.875Z"}

id: 2
event: tick
data: {"id":2,"time":"2026-07-18T03:36:56.876Z"}

To prove the resume path, send a Last-Event-ID header and the server picks up right after it:

curl -N -H "Last-Event-ID: 42" http://YOUR_ADDRESS:PORT/events
# -> id: 43, id: 44, ...

That's the whole reconnection story, and the browser does it for you automatically.

The browser side

EventSource is built into every browser — no library, no reconnect code:

const source = new EventSource('/events');

source.addEventListener('tick', (e) => {
  const data = JSON.parse(e.data);
  console.log('tick', data.id, data.time);
});

source.onerror = () => {
  // The browser is ALREADY reconnecting. Usually there's nothing to do here.
};

🎯 Good to know: When the connection drops, EventSource reconnects on its own and sends the last id: it received as the Last-Event-ID header — the exact header the server reads above. Between the two, a phone changing networks or a brief hiccup heals with zero code from you. That built-in reconnect is SSE's best feature.

Making it useful

The ticking counter is a stand-in. In a real app you keep the open res objects in a list and write to them whenever something happens — a new order, a finished job, a chat post from elsewhere:

const clients = new Set();
app.get('/events', (req, res) => {
  res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
  res.flushHeaders();
  clients.add(res);
  req.on('close', () => clients.delete(res));
});

// Call this from anywhere to push to every connected browser:
function broadcast(event, data) {
  for (const res of clients) {
    res.write(`event: ${event}\n`);
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  }
}

That's a live notifications feed in a dozen lines.

Honest limits

  • One direction only. The browser can't push real-time messages back over an SSE stream. It still makes normal requests (fetch/POST) — a "like" button is a plain POST, and the result comes back down the SSE feed. If you need true two-way, use WebSockets.
  • Each stream is an open connection holding a little memory. Hundreds are fine; tens of thousands need care on the free plan's 2.5 GB shared RAM (Out of memory).
  • Behind the Reverse Proxy, SSE works over HTTPS unchanged; just make sure buffering doesn't hold events back (the Cache-Control: no-cache header above helps). See Domains and HTTPS.

The EventSource API and the event-stream format are web standards — MDN's Server-sent events guide is the canonical reference.

Troubleshooting

  • curl hangs with no output — that's SSE working (an open stream). Add -N so events print as they arrive instead of buffering.
  • Events arrive all at once, not live — something is buffering. Confirm Content-Type: text/event-stream and Cache-Control: no-cache, and that you call res.flushHeaders().
  • Timers pile up / memory climbs — you didn't clean up on disconnect. Handle req.on('close', ...) to clear intervals and drop the client.
  • Reconnect loops fast — the server closed the stream. SSE is meant to stay open; keep the response open and only write to it.

Next steps

Was this guide helpful?