How to test server sent events

Run curl -sN http://127.0.0.1:8731/sse and read the raw stream. One event is a group of data: lines closed by a blank line. A group with no event: field reaches the browser as message, never a named listener. Drop the -N and curl holds every byte until the stream closes.

Why check this

Server-sent events fail in a way that looks like nothing happening. The stream is open, the bytes are on the wire, and the page shows no update. Run this check when a realtime feature moves to staging, and again after any proxy, gateway or compression change in front of the endpoint.

The failure it prevents is a listener bound to a name the server never sends. The server emits data: with no event: field, the front end calls addEventListener('update', ...), and the handler is never called. Neither side raises an error.

Prerequisites

import { createServer } from 'node:http';

const wait = (ms) => new Promise((r) => setTimeout(r, ms));

createServer(async (req, res) => {
  if (req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end('<!doctype html><meta charset="utf-8"><title>sse local</title>');
    return;
  }
  if (!req.url.startsWith('/sse')) { res.writeHead(404).end(); return; }
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-store',
    Connection: 'keep-alive',
  });
  res.write('retry: 3000\n\n');
  res.write('id: 1\ndata: {"seq":1,"state":"queued"}\n\n');
  await wait(400);
  res.write('event: price\nid: 2\ndata: {"seq":2,"eur":41.18}\n\n');
  await wait(400);
  res.write('id: 3\ndata: first line\ndata: second line\n\n');
  await wait(400);
  res.write(': keep-alive\n\n');
  await wait(400);
  res.write('id: 5\ndata: {"seq":5,"state":"done"}\n\n');
  res.end();
}).listen(8731, '127.0.0.1', () => console.log('sse on 127.0.0.1:8731'));

Steps

  1. Step 1.

    Read the response headers without reading the body.

    curl -sN -D - -o /dev/null http://127.0.0.1:8731/sse
    
    HTTP/1.1 200 OK
    Content-Type: text/event-stream
    Cache-Control: no-store
    Connection: keep-alive
    Date: Fri, 11 Sep 2026 22:52:19 GMT
    Transfer-Encoding: chunked

    text/event-stream is what makes a browser parse the response as a stream. Transfer-Encoding: chunked means there is no Content-Length, so the response has no declared end.

  2. Step 2.

    Read the stream as bytes, with cat -A marking every line end.

    curl -sN http://127.0.0.1:8731/sse | cat -A
    
    retry: 3000$
    $
    id: 1$
    data: {"seq":1,"state":"queued"}$
    $
    event: price$
    id: 2$
    data: {"seq":2,"eur":41.18}$
    $
    id: 3$
    data: first line$
    data: second line$
    $
    : keep-alive$
    $
    id: 5$
    data: {"seq":5,"state":"done"}$
    $

    Each $ is one newline. The blank line, two newlines in a row, is the only thing that ends an event. Five groups arrived: a retry group with no data, an unnamed group, one named price, one carrying two data: lines, and a comment group whose line starts with a colon.

  3. Step 3.

    Stamp every line with the second it arrived, so the pacing is visible.

    ts() { local s=$EPOCHREALTIME; while IFS= read -r l; do printf '%6.3f  %s\n' "$(awk -v a=$EPOCHREALTIME -v b=$s 'BEGIN{print a-b}')" "$l"; done; }; curl -sN http://127.0.0.1:8731/sse | ts
    
     0.037  retry: 3000
    0.070  
    0.104  id: 1
    0.133  data: {"seq":1,"state":"queued"}
    0.164  
    0.446  event: price
    0.481  id: 2
    0.514  data: {"seq":2,"eur":41.18}
    0.546  
    0.853  id: 3
    0.893  data: first line
    0.927  data: second line
    0.961  
    1.264  : keep-alive
    1.300  
    1.670  id: 5
    1.692  data: {"seq":5,"state":"done"}
    1.721  

    Groups start at 0.037, 0.446, 0.853, 1.264 and 1.670 seconds, the 400 ms spacing the server was written with. The 30 ms between lines inside one group is the cost of the awk call in the loop, not the server.

  4. Step 4.

    Run the same pipeline in the same shell with -N removed.

    curl -s http://127.0.0.1:8731/sse | ts
    
     1.682  retry: 3000
    1.711  
    1.751  id: 1
    1.783  data: {"seq":1,"state":"queued"}
    1.812  
    1.856  event: price
    1.904  id: 2
    1.940  data: {"seq":2,"eur":41.18}
    1.977  
    2.012  id: 3
    2.045  data: first line
    2.080  data: second line
    2.119  
    2.155  : keep-alive
    2.194  
    2.229  id: 5
    2.269  data: {"seq":5,"state":"done"}
    2.303  

    Nothing at all until 1.682 seconds, then the whole stream at once. That is the moment the server closed the connection. On a stream that stays open, this command prints nothing for as long as you are willing to wait.

  5. Step 5.

    Open http://127.0.0.1:8731/ in Chrome, open DevTools, Console tab, and paste this. It registers three listeners: the default one, one for the name the server does send, and one for a name it does not.

    const t0 = performance.now();
    const log = (tag, e) => console.log(`${((performance.now() - t0) / 1000).toFixed(3)}  ${tag.padEnd(9)} id=${e.lastEventId || '-'}  ${JSON.stringify(e.data)}`);
    const es = new EventSource('/sse');
    es.onopen = () => console.log('0.000  open');
    es.onmessage = (e) => log('message', e);
    es.addEventListener('price', (e) => log('price', e));
    es.addEventListener('update', (e) => log('update', e));
    es.onerror = () => console.log(`error     readyState=${es.readyState}`);
    
    error: Failed to load resource: the server responded with a status of 404 (Not Found)
    0.000  open
    0.003  message   id=1  "{\"seq\":1,\"state\":\"queued\"}"
    0.407  price     id=2  "{\"seq\":2,\"eur\":41.18}"
    0.816  message   id=3  "first line\nsecond line"
    1.631  message   id=5  "{\"seq\":5,\"state\":\"done\"}"
    error     readyState=0

    The first line is the browser asking for /favicon.ico, which this server does not serve. Read the rest against step 2. onmessage received ids 1, 3 and 5. The price listener received id 2, and onmessage did not. The update listener was never called. The comment at 1.26 seconds produced no line at all. The multi-line group arrived as one string with a newline inside it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Content-Type: text/event-stream | The browser parses it as a stream | Continue to the wire format in step 2. | | Any other content type | EventSource refuses the response | Fix the header before testing anything else. See How to check content-type of API response. | | No blank line between groups | The reader never dispatches an event | The stream looks alive on the wire and is silent in the browser. | | A named listener stays silent | The server sends no event: field | Bind onmessage, or add the name on the server. | | curl prints nothing for minutes | Output buffering, not a dead server | Add -N and run it again. |

Common mistakes

Sign: The command prints nothing, so the endpoint is reported as broken.Cause: curl buffers its output when stdout is a pipe or a file. In step 4 the first line of a 1.7 second stream appeared at 1.682 seconds, once the server had closed the connection. A production stream never closes, so the output never appears. The -N flag is not an optimisation here, it decides whether there is a reading at all.
Sign: addEventListener for a named event never fires, and nothing is logged.Cause: A group with no event: field is dispatched under the name message. A group with event: price is dispatched as price and is never delivered to onmessage. In the capture above the two handlers saw disjoint sets of ids, 1, 3, 5 against 2. Each is silent about the other, which is why this survives a code review.
Sign: A heartbeat is counted as an event and the count comes out wrong.Cause: A line beginning with a colon is a comment. The ': keep-alive' group in step 2 is on the wire and produced no listener call in step 5, and it does not change lastEventId. Comments exist to hold a connection open through an idle proxy, and a test that counts events will not see them.
Sign: JSON.parse throws on an event that looks like valid JSON on the wire.Cause: Consecutive data: lines are joined with a newline into one event. Id 3 sent two data: lines and the handler received a single string with a newline in the middle. Code that parses each data: line on its own breaks on the first multi-line payload, which is often the first stack trace the server streams.

What to check next

FAQ

How do I test an SSE endpoint without writing a client?

curl -sN <url> is the whole client. It prints the stream as the server writes it, including id:, event: and comment lines that a browser hides. Add | cat -A when you need to see where the blank lines fall.

What content type must an SSE endpoint return?

text/event-stream. A browser EventSource rejects anything else and fires onerror without delivering a single event. curl shows the body either way, which is why the header is worth reading on its own, as in step 1.

Why does my SSE test hang with no output?

Two causes look identical. Either curl is buffering, which -N fixes, or something between you and the server is buffering, which -N does not fix. If -N prints groups as they are written on loopback but not through the gateway, the gateway is the buffer.

Does every event need an id?

No. Events without id: are delivered normally. The id matters only for resuming after a drop, because the browser replays the last one it saw in a Last-Event-ID header on the next connection.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny