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
- Node 22. Save the server below as
sse-server.mjsand start it withnode sse-server.mjs. It listens on127.0.0.1:8731, serves an empty HTML page at/so the browser step has an origin, and the stream at/sse. - curl with the
-Nflag. The curl manual entry for -N calls it "disable buffering of the output stream". - Chrome or another Chromium browser. Node 22 has a global
WebSocketclient but noEventSource, so the browser half of this check cannot be done from the command line. - Every timing below is loopback on one machine. There is no network in these numbers, so read the gaps as the server's own pacing.
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
- Step 1.
Read the response headers without reading the body.
curl -sN -D - -o /dev/null http://127.0.0.1:8731/sseHTTP/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: chunkedtext/event-streamis what makes a browser parse the response as a stream.Transfer-Encoding: chunkedmeans there is noContent-Length, so the response has no declared end. - Step 2.
Read the stream as bytes, with
cat -Amarking every line end.curl -sN http://127.0.0.1:8731/sse | cat -Aretry: 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: aretrygroup with no data, an unnamed group, one namedprice, one carrying twodata:lines, and a comment group whose line starts with a colon. - 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 | ts0.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.721Groups 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
awkcall in the loop, not the server. - Step 4.
Run the same pipeline in the same shell with
-Nremoved.curl -s http://127.0.0.1:8731/sse | ts1.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.303Nothing 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.
- 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=0The first line is the browser asking for
/favicon.ico, which this server does not serve. Read the rest against step 2.onmessagereceived ids 1, 3 and 5. Thepricelistener received id 2, andonmessagedid not. Theupdatelistener 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
What to check next
- Eventsource auto reconnect: what the browser does when this stream drops, and the
id:field that decides whether events are lost. - Long polling test: the other way to push from a plain HTTP endpoint, measured the same way.
- How to test websocket connection: what to use when the traffic has to go both ways.
- How to check content-type of API response: the header that decides whether any of this is parsed as a stream.
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.
Related on this site
intermediate8 minpublished updated Maks Verny