How to test Server-Sent Events endpoints: curl, Postman, and spec-driven SSE testing
Server-Sent Events are the simplest way for a server to push updates to a client — one long-lived HTTP response with Content-Type: text/event-stream, framed text events, automatic browser reconnection, and no WebSocket handshake complexity. That simplicity disappears the moment you test one. A normal HTTP client sends the request and waits for the response to finish; an SSE response never finishes, so the client hangs, times out, or buffers everything until disconnect. Teams end up testing streams by eye in a browser, which is not a strategy for CI. Here is how to test SSE properly, from a raw curl command to repeatable spec-driven scenarios.
What you are actually testing
An SSE stream is a sequence of frames:
event: project.updated
id: 471
data: {"projectId":"p_123","status":"ready"}
: heartbeat
event: project.updated
id: 472
data: {"projectId":"p_123","status":"published"}A meaningful test covers more than "the connection opens":
- The response has
Content-Type: text/event-stream,Cache-Control: no-cache, and (through proxies) compression or buffering disabled. - Events arrive with the expected
eventnames and payloads matching a schema. idvalues are present and monotonic, becauseLast-Event-IDdrives reconnection.- Heartbeats (comments starting with
:) arrive within the proxy timeout window. - Authentication works on the initial request — token refresh during a long stream is a separate design question.
- Reconnect with
Last-Event-IDresumes without duplicating or skipping events.
Testing by hand with curl
curl streams a response live instead of buffering it, which makes it the fastest smoke test:
curl -N -H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream" \
https://api.example.com/v1/projects/p_123/events-N (--no-buffer) is the flag that matters; without it curl holds output until the connection closes and you see nothing. Trigger an action in another tab (update the project) and watch the frame arrive.
For reconnect behavior, kill the stream after noting the last id, then resume:
curl -N -H "Authorization: Bearer $TOKEN" \
-H "Last-Event-ID: 471" \
https://api.example.com/v1/projects/p_123/eventsA correct server replays events after 471 from a backlog or instructs the client to refetch state. curl cannot assert anything for you, but it establishes ground truth and produces a recording you can turn into a fixture.
One proxy gotcha deserves mention: nginx buffers proxied responses by default (proxy_buffering on), which silently turns a live stream into a delayed one. If curl works locally but events batch up in staging, the proxy is buffering; the fix is proxy_buffering off for the stream path and explicit X-Accel-Buffering: no.
GUI clients: what works
Postman added SSE support as a separate request type (not the ordinary HTTP request), and it renders named events and a timeline; it is fine for exploratory checks. The same limitations that apply to all collection-based tooling apply here: the event contract lives in the collection, not in an OpenAPI document, and assertions over sequences of events are awkward to automate. Insomnia and Hoppscotch have similar stream views with varying depth. Browser DevTools (Network tab, the streaming request, EventStream tab) remains the most honest view of what the client actually receives, including reconnection behavior.
Making the stream part of the contract
The durable fix is describing the stream in OpenAPI so tooling knows the event vocabulary. The media type alone says a stream exists; an extension describes the events on it. In the Powerduck convention this is x-protocol alongside text/event-stream, carrying the event names, the item schema per event, heartbeat interval, and close semantics:
paths:
/v1/projects/{projectId}/events:
get:
summary: Project change events
parameters:
- name: projectId
in: path
required: true
schema: { type: string }
responses:
'200':
description: SSE stream of project lifecycle events.
content:
text/event-stream:
schema:
type: object
properties:
event:
type: string
enum: [project.updated, project.deleted]
data:
$ref: '#/components/schemas/ProjectEvent'
x-protocol:
transport: sse
heartbeatSeconds: 25
events:
- name: project.updated
schema:
$ref: '#/components/schemas/ProjectEvent'
- name: project.deleted
schema:
$ref: '#/components/schemas/ProjectDeletedEvent'With the contract in the document, three things stop being manual: documentation shows consumers the event catalog; a mock server can emit a realistic stream (correct names, cadence, payloads) before the streaming endpoint exists; and tests can assert against the schema instead of hard-coded JSON.
Repeatable scenario tests
A streaming scenario has a different shape from a request-response one: open the stream, trigger the producing action, collect events for a bounded window, assert, close. In pseudo-YAML the flow reads:
name: Project update emits one event
steps:
- openStream:
path: /v1/projects/{{projectId}}/events
collect:
events: [project.updated]
timeoutSeconds: 10
- request:
method: PATCH
path: /v1/projects/{{projectId}}
body: { name: "Renamed project" }
- expectStream:
count: 1
events:
- event: project.updated
match:
data.projectId: "{{projectId}}"
data.name: "Renamed project"
- closeStream: {}The assertions that catch real bugs: exactly one event for one update (duplicates are common when reconnect logic double-subscribes); ordering across two rapid updates; reconnect-with-resume delivering no gaps or repeats; and heartbeat cadence keeping a proxy-idle connection alive (assert at least one frame within the documented interval). These run against the mock in development and against staging in CI, from the same scenario file.
The failure cases worth a permanent test
- Buffering proxy: no frame for 60 seconds despite server activity — the heartbeat assertion catches it.
- Reconnect storm: server closes every 30 seconds and the client reconnects with no backoff; assert reconnect count over a window.
- Schema drift: event payload missing a documented field; validate each
dataframe against the event schema. - Auth expiry: a stream lasting longer than token lifetime — decide whether it stays open, closes with a documented event, or accepts refreshed credentials, and test the chosen behavior.
Powerduck documents SSE with the x-protocol extension, generates streaming mocks, and runs the open-trigger-collect scenarios against mock and staging from the same OpenAPI 3.2 document used for everything else — the demo includes a streaming operation.
What to read next: how to test WebSocket APIs covers bidirectional connections, and debug every protocol in one workspace compares HTTP, SSE, WebSocket, gRPC, and GraphQL tooling.