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 fortickspecifically.id:— an optional id the browser remembers and sends back asLast-Event-IDwhen 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,
EventSourcereconnects on its own and sends the lastid:it received as theLast-Event-IDheader — 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-cacheheader 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
curlhangs with no output — that's SSE working (an open stream). Add-Nso events print as they arrive instead of buffering.- Events arrive all at once, not live — something is buffering. Confirm
Content-Type: text/event-streamandCache-Control: no-cache, and that you callres.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
- Real-time apps with WebSockets — when you need two-way
- Real-time apps with Socket.IO — rooms and fallbacks
- Build an Express website + API — the base this builds on
- Out of memory — many open streams