What server-sent events are and how the protocol works

Server-sent events (SSE) are a one-way stream from server to client over a plain HTTP response. The client makes an ordinary GET request. The server replies with Content-Type: text/event-stream and keeps the connection open, writing events as they occur. SSE needs no protocol upgrade or custom framing, and it works with your existing load balancer as long as the proxy does not buffer the response.

The wire format

The stream is UTF-8 text made of lines. Each line is a field:

  • data: carries the payload. Multiple data: lines in one event are joined with newlines, so multi-line JSON or text survives intact.
  • event: sets a named type. If you omit it, the event type is message.
  • id: sets the last event ID. The client remembers it for reconnection.
  • retry: tells the client how many milliseconds to wait before reconnecting.

A line beginning with a colon is a comment, and the client ignores it. Servers send comments as heartbeats to keep idle connections from being closed. A blank line ends the event and triggers dispatch. If the server never writes that blank line, the client never sees the event.

How the browser consumes it

The EventSource API opens the connection, parses fields, and fires events. Use onmessage for unnamed events and addEventListener for named ones. When the connection drops, EventSource reconnects on its own and sends the last ID in a Last-Event-ID request header.

Teams use SSE for live feeds, notifications, job progress, and streaming responses. It suits backend and platform engineers who need server push without running a second protocol.

Implementing a minimal SSE endpoint in Node.js

Start with a plain http.createServer handler that matches the stream route and calls res.writeHead(200, ...) with Content-Type: text/event-stream and Cache-Control: no-cache. The content type tells the browser to parse the body as an event stream. Without it, EventSource rejects the response. no-cache keeps intermediaries from storing or replaying a response that is meant to stay live. On HTTP/1.1 you can also send Connection: keep-alive, but leave it out on HTTP/2, which forbids connection-specific headers. Call res.flushHeaders() so the client sees the open stream right away instead of waiting for the first event.

Each event is a block of field: value lines ending with a blank line, for example id: 42, event: update, data: {...}, followed by \n\n. Write it with res.write() and never call res.end(). Ending the response closes the stream, and the browser treats that as a reason to reconnect.

Set an interval that writes : ping\n\n every 15–30 seconds. Lines that start with a colon are comments, so the client ignores them. The traffic still stops proxies and load balancers from closing a connection they see as idle, and writing to a dead socket helps it surface sooner.

Register res.on('close', ...) to clean up when the client goes away. In that handler, clear the heartbeat interval, unsubscribe from whatever feeds the stream, and remove any listeners you attached to shared emitters. If you skip this, every disconnected tab leaves behind a timer and a closure, and memory grows with each one.

On the client, new EventSource('/events') opens the stream. Use onmessage for unnamed events, addEventListener('update', handler) for named ones, and onerror for logging. The browser reconnects automatically.

// server.js: minimal SSE endpoint using only node:http
const http = require('node:http');
const { EventEmitter } = require('node:events');

const feed = new EventEmitter(); // shared source that many clients subscribe to
let nextId = 0;
setInterval(() => feed.emit('update', { at: Date.now() }), 5000); // demo producer

http.createServer((req, res) => {
  if (req.url !== '/events') { res.writeHead(404); res.end(); return; }

  res.writeHead(200, {
    'Content-Type': 'text/event-stream', // required, or EventSource rejects the response
    'Cache-Control': 'no-cache',         // intermediaries must not store a live stream
    'Connection': 'keep-alive',          // HTTP/1.1 only; omit on HTTP/2
  });
  res.flushHeaders(); // client sees the open stream before the first event

  // One event = field lines + blank line. Never call res.end() here.
  const onUpdate = (payload) => {
    res.write(`id: ${++nextId}\nevent: update\ndata: ${JSON.stringify(payload)}\n\n`);
  };
  feed.on('update', onUpdate);

  // Comment heartbeat: ignored by the client, keeps idle-timeout proxies from closing the connection
  const heartbeat = setInterval(() => res.write(': ping\n\n'), 20000);

  // Client went away: release everything this connection holds
  res.on('close', () => {
    clearInterval(heartbeat);
    feed.off('update', onUpdate);
  });
}).listen(3000);

// Browser client:
// const es = new EventSource('/events');
// es.onmessage = (e) => console.log('unnamed', e.data);
// es.addEventListener('update', (e) => console.log('update', JSON.parse(e.data)));
// es.onerror = (e) => console.warn('stream error; browser will retry', e);

A plain Node.js http handler that streams named SSE events from a shared emitter, sends ': ping' heartbeats, and removes its interval and listener on 'close', followed by the matching browser EventSource client.

Reconnection and resumable streams with Last-Event-ID

When a connection drops, EventSource reconnects on its own. The browser picks a default delay, and the server can override it by sending a retry: field with a value in milliseconds. The client keeps using that value for later reconnects. Reconnection only happens after network errors or a clean close. If the server responds with a non-200 status or the wrong Content-Type, the browser stops trying, which gives the server a way to end a stream on purpose.

Resuming where the client left off

Give every event an id: that sorts in order, such as a per-stream sequence number or a log offset. Random UUIDs don't work here because the server has to answer "what came after this?" On reconnect, the browser sends the last id it saw in the Last-Event-ID request header. Parse it and replay every event with a greater id before you start sending live events.

Where the replay comes from depends on how much loss you can tolerate:

  • Bounded in-memory ring buffer: cheap, but local to one process and lost on restart.
  • Durable log, such as a database table or an append-only stream: survives deploys and works when the client reconnects to a different instance.

If the requested id has already been evicted, don't quietly resume from the newest event. Send a named reset event so the client refetches its state, or send a full snapshot followed by live events.

Avoiding reconnect storms

A deploy disconnects every client at the same moment. If they all use the same retry: value, they all reconnect at the same moment too. Send a randomized retry: per connection, for example a base delay plus a random amount, so reconnects spread out over a window.

const http = require('node:http');

const BUFFER_SIZE = 1000; // bounded replay window, local to this process
const buffer = [];        // oldest first: { id, data }
let nextId = 1;           // monotonic per-stream sequence, so ids sort in order
const clients = new Set();

const format = (e) => `id: ${e.id}\ndata: ${JSON.stringify(e.data)}\n\n`;

function publish(data) {
  const e = { id: nextId++, data };
  buffer.push(e);
  if (buffer.length > BUFFER_SIZE) buffer.shift(); // evict oldest
  for (const res of clients) res.write(format(e));
}

http.createServer((req, res) => {
  if (req.url !== '/events') return res.writeHead(404).end();
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });

  // Jittered retry (3-10 s) so clients dropped by a deploy don't reconnect in lockstep.
  res.write(`retry: ${3000 + Math.floor(Math.random() * 7000)}\n\n`);

  const lastId = Number.parseInt(req.headers['last-event-id'], 10);
  if (Number.isInteger(lastId)) {
    const oldest = buffer.length ? buffer[0].id : nextId;
    if (lastId + 1 < oldest || lastId >= nextId) {
      // Gap is evicted (or id is from a previous process): tell the client to refetch.
      // The id moves the client's Last-Event-ID forward so the next reconnect resumes normally.
      res.write(`event: reset\nid: ${nextId - 1}\ndata: {}\n\n`);
    } else {
      for (const e of buffer) if (e.id > lastId) res.write(format(e)); // replay the gap
    }
  }

  clients.add(res); // live events start only after the replay
  req.on('close', () => clients.delete(res));
}).listen(8080);

setInterval(() => publish({ ts: Date.now() }), 1000); // stand-in event source

A Node.js SSE endpoint that assigns sequential ids, sends a jittered retry value, replays missed events from a bounded in-memory ring buffer based on Last-Event-ID, and sends a reset event when the requested id has been evicted.

Running SSE behind proxies: nginx buffering, timeouts and HTTP/2

Most "SSE doesn't work in production" reports come from a proxy that buffers the response. By default nginx buffers upstream responses, so events sit in its buffer until it fills or the stream closes, and the client sees long silences followed by bursts. Set proxy_buffering off in the SSE location, or have Node send X-Accel-Buffering: no on the event-stream response. The header is handy when the application team doesn't control the nginx config.

Add proxy_http_version 1.1 and set the Connection header to an empty value. This keeps the upstream connection persistent instead of closing it after each request. Raise proxy_read_timeout above your heartbeat interval, because nginx closes a connection that has been idle longer than that timeout, which is 60 seconds by default. Apply all of these settings to the SSE location only. Disabling buffering and extending timeouts across the whole server would hurt ordinary request handling.

Compression middleware in Node causes the same symptom inside the application. It collects bytes so it can compress them efficiently, which holds back small events. Either exclude text/event-stream from compression or flush after each event.

Over HTTP/1.1, browsers allow roughly six concurrent connections per origin. Each open EventSource uses one of them, so a few open tabs can block the app's normal requests. HTTP/2 multiplexes many streams over one connection and removes this limit. If you can't use HTTP/2, multiplex several logical channels onto a single SSE stream instead.

Some corporate proxies and antivirus tools buffer streaming responses no matter how your server is configured. Detect a stalled stream on the client and fall back to polling.

upstream node_app {
    server 127.0.0.1:3000;
    keepalive 16;  # reuse upstream connections
}

server {
    listen 443 ssl http2;  # HTTP/2 lifts the ~6 connections-per-origin limit for EventSource
    server_name app.example.com;
    ssl_certificate     /etc/nginx/certs/app.example.com.crt;
    ssl_certificate_key /etc/nginx/certs/app.example.com.key;

    # Ordinary requests keep nginx defaults (buffering on, 60s read timeout).
    location / {
        proxy_pass
    }

    # SSE-only overrides, scoped to the event-stream endpoint.
    location /events {
        proxy_pass

        # Persistent upstream connection: HTTP/1.1 and no "Connection: close".
        proxy_http_version 1.1;
        proxy_set_header Connection "";

        # Forward each event as soon as Node writes it.
        proxy_buffering off;
        proxy_cache off;

        # Must exceed the server heartbeat interval (e.g. a comment line every 15s),
        # otherwise nginx drops the idle stream after the 60s default.
        proxy_read_timeout 3600s;

        # Don't let nginx gzip hold back small chunks.
        gzip off;
    }
}

An nginx config that turns off buffering, keeps the upstream connection persistent, and extends the read timeout only for the /events SSE location, and serves the site over HTTP/2 so open EventSource streams don't use up the browser's per-origin connection limit.

Scaling across Node.js instances and handling backpressure

Once you run more than one Node.js process, the process holding a client's connection is rarely the one that produced the event. Decouple the two. Producers publish to a pub/sub channel or message broker, and every instance subscribes and fans each event out to its own local connections. Any instance can then serve any client, and the load balancer doesn't need sticky sessions.

The replay buffer for Last-Event-ID has to be shared for the same reason. A client that reconnects usually lands on a different instance. If event history lives in process memory, that instance has nothing to replay, and the gap goes unnoticed. Keep recent events, keyed by their IDs, in a shared store with a retention window.

Plain pub/sub is typically at-most-once. If a subscriber is disconnected or restarting, it never sees the message. Clients that resume with Last-Event-ID expect at-least-once delivery. To close that gap, write each event to the durable log first and treat the pub/sub notification as a wake-up signal only. Clients should deduplicate by event ID, because replay and live delivery can overlap.

Backpressure

res.write returns false once the socket's buffer is full. Stop writing and wait for 'drain'. If you ignore this, a slow client grows memory without bound. Decide on a policy for each stream:

  • Drop events the client can afford to miss.
  • Coalesce state updates down to the latest value.
  • Disconnect the client and let it resume from its last ID.

Capacity depends on concurrent open connections, not requests per second. Each idle stream still holds a socket, a file descriptor and some buffer memory. Raise descriptor limits and size instances by connection count.

import http from 'node:http';

const clients = new Set();
const MAX_PENDING = 1000; // illustrative cap on coalesced keys per client

// Called by this instance's broker subscriber for every event it receives.
export function broadcast(evt) {
  for (const c of clients) send(c, evt);
}

function send(c, evt) {
  if (c.blocked) {
    // Slow consumer policy: coalesce state updates down to the latest value per key.
    c.pending.delete(evt.key); // re-insert so queued events stay in ID order
    c.pending.set(evt.key, evt);
    // Too far behind: disconnect and let the client resume from Last-Event-ID.
    if (c.pending.size > MAX_PENDING) c.res.destroy();
    return;
  }
  const ok = c.res.write(`id: ${evt.id}\ndata: ${JSON.stringify(evt.data)}\n\n`);
  if (!ok) {
    // Socket buffer is full: stop writing until 'drain' fires.
    c.blocked = true;
    c.res.once('drain', () => {
      c.blocked = false;
      const queued = [...c.pending.values()];
      c.pending.clear();
      for (const e of queued) send(c, e); // re-blocks if the buffer fills again
    });
  }
}

http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
  const c = { res, blocked: false, pending: new Map() };
  clients.add(c);
  // Each open stream holds a socket and file descriptor until it closes.
  res.on('close', () => clients.delete(c));
}).listen(3000);

An SSE fan-out loop that checks the return value of res.write, waits for 'drain', coalesces updates for slow clients, and disconnects clients that fall too far behind.

SSE vs WebSockets and other alternatives

SSE is not the only way to push data to a browser. The right choice depends mostly on which direction the data flows and how much connection logic you want to write yourself.

SSE WebSockets Long polling Short polling
Direction Server → client Bidirectional Server → client, one response per request Client pulls on a timer
Data format UTF-8 text events Text or binary frames Any HTTP body Any HTTP body
Transport Ordinary HTTP response HTTP upgrade, then its own framing Ordinary HTTP Ordinary HTTP
Automatic reconnection Built into EventSource You implement it You implement the request loop Not applicable
Resume support Last-Event-ID header You design it Cursor parameter you design Cursor parameter you design
Proxy handling Buffering must be disabled Upgrade headers must be forwarded Timeouts must exceed hold time No special handling
Auth from the browser Cookies or query string Cookies, query string or first message Any header Any header

Authentication is where SSE is most limited, because EventSource cannot set custom headers such as Authorization. That leaves three practical options:

  • Cookies. They work with no extra client code. However, a long-lived GET that relies on ambient credentials needs CSRF consideration. Cross-origin streams also need withCredentials, plus CORS that names an explicit origin and allows credentials.
  • Short-lived query-string tokens. They are simple to add, but URLs end up in access logs, proxy logs and browser history. Keep their lifetime in seconds and make them single-purpose.
  • A fetch-based streaming client. You can send any header you like. In exchange, you have to reimplement reconnection, retry handling and Last-Event-ID yourself.

When to use SSE, when to avoid it, and the impact on cost and engineering time

Choose SSE when the server pushes and the client mostly listens: notifications, job progress, live dashboards, token streaming. The engineering saving comes from what you don't build. EventSource already handles reconnects, retry and Last-Event-ID, and the stream is ordinary HTTP that your auth, logging and load balancers understand. With WebSockets you would write and test the upgrade path, reconnect backoff and resume protocol yourself.

Situation SSE Better fit
One-way text push Good n/a
Chat, collaborative editing, games Poor (each client message is a separate request) WebSockets
Binary payloads Poor (requires base64 overhead) WebSockets, plain HTTP fetches
Intermediaries that buffer or cut long responses Fragile Polling

The main ongoing costs are:

  • Memory and file descriptors. Every open connection holds both.
  • Fan-out infrastructure. Every published event costs broker operations, and durable log storage is what makes resume possible.
  • Proxy configuration. Buffering and timeout settings have to be correct at every hop.
  • Connection draining. Deploys and scale-in force mass reconnects, which also replay from your log.

Illustrative estimate (assumed numbers, not a benchmark)

Use this formula: connections per instance = min(memory budget ÷ per-connection memory, fd limit − reserved fds).

Assume a 1.5 GB budget, 20 KB per connection, a 65,536 fd limit and 1,000 reserved descriptors:

  • Memory allows 1,572,864 KB ÷ 20 KB ≈ 78,600 connections.
  • Descriptors allow 64,536 connections.

File descriptors are the binding limit. For 200,000 peak connections you need ⌈200,000 ÷ 64,536⌉ = 4 instances, plus one more so an instance can drain during deploys. That makes 5. Measure your real per-connection memory before relying on these numbers.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to server-sent events node.js, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.