QA How-To
Test SSE Reconnect Last Event ID (2026)
Learn how to test sse reconnect last event id behavior with a Node.js server, forced disconnects, replay assertions, browser checks, and full CI coverage.
22 min read | 2,523 words
TL;DR
Start an SSE server with a fixed event log, terminate the connection after a known event, and reconnect using the last dispatched event ID. Pass only when the server receives the expected Last-Event-ID values and the client observes every event once, in order, through completion.
Key Takeaways
- Force deterministic disconnects after known event IDs instead of waiting for random network failures.
- Assert both sides of recovery: the client sends Last-Event-ID and the server resumes after that exact ID.
- Track processed IDs, payload order, reconnect attempts, and retry delays separately from HTTP status.
- Define the application's behavior for expired or unknown IDs because the SSE protocol does not define replay storage.
- Run a native EventSource check because browsers manage Last-Event-ID internally and do not expose it as an application-set header.
- Keep the replay fixture immutable so a failed test identifies protocol behavior rather than changing test data.
To test sse reconnect last event id behavior, break a stream immediately after a known event, reconnect with the last processed id, and assert that delivery resumes at the next replayable record. A 200 response is not enough. The test must prove the request header, replay boundary, final ordered ID list, and behavior when the requested ID is no longer retained.
This tutorial builds that proof with Node.js built-ins. You will create a deterministic SSE endpoint, a small protocol-aware client, an automated node:test suite, and a native browser check. If you need load rather than functional recovery, pair this guide with k6 Server-Sent Events stream testing.
SSE reconnection has two responsibilities. The browser or client remembers the last dispatched event's id field and sends it back as the Last-Event-ID request header. The application maps that value to retained data and chooses what comes next. The HTML event-stream format supplies the cursor, but it does not create a database, guarantee exactly-once processing, or define how long history remains available.
What You Will Build
You will finish with a compact integration project that proves five observable facts:
- The first subscription starts without a
Last-Event-IDheader. - A forced disconnect after
evt-003causes the next request to sendLast-Event-ID: evt-003. - A second break after
evt-006produces a third request withLast-Event-ID: evt-006. - The consumer receives
evt-001throughevt-008in order, with no missing or duplicate update. - An unknown cursor returns an explicit recovery response instead of silently restarting at an arbitrary position.
The fixture uses eight immutable events and disconnects every three delivered updates. That gives the happy path two reconnects, enough to expose clients that only remember the first cursor or servers that apply an off-by-one replay index. The server also sends retry: 50, allowing the test to verify that the client uses the advertised 50 millisecond delay.
Keep this focused contract separate inside a broader JavaScript API automation framework, where generic request helpers cannot hide SSE replay rules.
Prerequisites
Use Node.js 22.18.0 and npm 10.9.3 for the commands below. The code relies on stable built-in APIs: node:http, global fetch, Web Streams, TextDecoder, node:test, and node:assert/strict. No third-party package is required.
Confirm the exact runtime and create an isolated project:
node --version
npm --version
mkdir sse-reconnect-test
cd sse-reconnect-test
npm init -y
Expect v22.18.0 and 10.9.3. The .mjs extension enables modules without changing the generated package. Pin the runtime in CI for consistent stream diagnostics.
You need a terminal for the server, another terminal for curl, and a current browser for the final native EventSource check. Run all file creation commands inside sse-reconnect-test.
Verification: Run node -e "console.log(typeof fetch, typeof TextDecoder)". The expected output is function function. If either value is missing, the runtime is older than the tutorial's prerequisite.
Step 1: Define the test sse reconnect last event id Contract
Write the contract before the server. An event counts as recoverable only after the client has parsed a complete SSE frame and dispatched it to the application callback. The cursor is the most recent valid id field, not a JSON sequence guessed from data, a TCP chunk number, or the ID of a frame that was only partially received.
Use this oracle for the forced-disconnect scenario:
| Attempt | Header sent by client | Events returned by server | Connection ending |
|---|---|---|---|
| 1 | Header absent | evt-001 to evt-003 |
Forced break |
| 2 | evt-003 |
evt-004 to evt-006 |
Forced break |
| 3 | evt-006 |
evt-007, evt-008, complete |
Clean close |
This application uses an exclusive cursor, so replay starts after the supplied ID. An at-least-once product may repeat that event and require business-key deduplication, but its test must assert that different contract explicitly.
This tutorial returns HTTP 409 plus the retained range for an expired cursor. A documented reset event, snapshot, or 410 can also work; silently choosing a new position can hide gaps or duplicates.
Verification: Review the table against the acceptance criterion. Exactly eight update IDs must reach the application, and the server must record the header sequence [null, "evt-003", "evt-006"]. Those values become exact assertions in Step 5.
Step 2: Build a Deterministic SSE Replay Server
Create server.mjs with a fixed in-memory log. The dropAfter query parameter is a test control that destroys an incomplete response after a requested number of updates. Do not expose such a switch on an unrestricted production endpoint.
import http from 'node:http';
import { pathToFileURL } from 'node:url';
const eventLog = Array.from({ length: 8 }, (_, index) => ({
id: `evt-${String(index + 1).padStart(3, '0')}`,
data: { sequence: index + 1, status: 'ready' },
}));
export function createSseServer() {
const subscriptions = [];
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/health') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
if (url.pathname !== '/events') {
res.writeHead(404);
res.end();
return;
}
const lastEventId = req.headers['last-event-id'] || null;
const previousIndex = lastEventId
? eventLog.findIndex((event) => event.id === lastEventId)
: -1;
subscriptions.push({ lastEventId });
console.log(JSON.stringify({ subscription: subscriptions.length, lastEventId }));
if (lastEventId && previousIndex === -1) {
res.writeHead(409, { 'content-type': 'application/json' });
res.end(JSON.stringify({
error: 'replay_cursor_unavailable',
availableFrom: eventLog[0].id,
availableTo: eventLog.at(-1).id,
}));
return;
}
const requestedDrop = Number.parseInt(url.searchParams.get('dropAfter') || '0', 10);
const dropAfter = Number.isInteger(requestedDrop) && requestedDrop > 0
? requestedDrop
: 0;
let cursor = previousIndex + 1;
let sentThisConnection = 0;
let timer;
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
'access-control-allow-origin': '*',
'x-accel-buffering': 'no',
});
res.flushHeaders();
res.write('retry: 50\n\n');
const sendNext = () => {
if (cursor >= eventLog.length) {
res.write('event: complete\n');
res.write(`data: ${JSON.stringify({ delivered: eventLog.length })}\n\n`);
res.end();
return;
}
const event = eventLog[cursor];
res.write(`id: ${event.id}\n`);
res.write('event: update\n');
res.write(`data: ${JSON.stringify(event.data)}\n\n`);
cursor += 1;
sentThisConnection += 1;
if (dropAfter && sentThisConnection === dropAfter && cursor < eventLog.length) {
timer = setTimeout(() => res.destroy(), 5);
return;
}
timer = setTimeout(sendNext, 20);
};
timer = setTimeout(sendNext, 20);
res.on('close', () => clearTimeout(timer));
});
return { server, subscriptions };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const { server } = createSseServer();
server.listen(3000, '127.0.0.1', () => {
console.log('SSE fixture listening on http://127.0.0.1:3000');
});
}
The fixture flushes a complete frame before breaking the socket, resumes at previousIndex + 1, and records received cursors independently of the client.
Verification: Run node server.mjs, then execute curl -s http://127.0.0.1:3000/health in the second terminal. Expect {"status":"ok"}. Keep the server running for Step 3.
Step 3: Verify Baseline and Resume Requests with curl
First inspect an uninterrupted stream. The -N option disables curl's output buffering, and --max-time prevents a broken fixture from occupying the terminal indefinitely.
curl -N --max-time 3 'http://127.0.0.1:3000/events'
Expect retry: 50, eight update frames, and one complete frame. The blank line dispatches each frame; HTTP chunk boundaries have no SSE meaning.
Now issue a resume request directly:
curl -N --max-time 3 \
-H 'Accept: text/event-stream' \
-H 'Last-Event-ID: evt-003' \
'http://127.0.0.1:3000/events'
The first update must be evt-004, not evt-003 or evt-001. The server terminal should log {"lastEventId":"evt-003"} as part of the subscription record. This manual probe isolates server replay logic before a custom client adds parsing and retry behavior.
Finally, inspect the declared stale-cursor response:
curl -i -H 'Last-Event-ID: evt-999' \
'http://127.0.0.1:3000/events'
Verification: Confirm the response is HTTP/1.1 409 Conflict and its JSON range is evt-001 through evt-008. If the resume request starts at evt-003, change the server from previousIndex to previousIndex + 1; replaying the cursor itself would violate this tutorial's chosen contract.
Step 4: Implement a Reconnecting SSE Test Client
Create sse-client.mjs. This client is deliberately small but handles chunked reads, multiline data, event names, IDs, the retry field, clean early closes, and body-read failures. It sets Last-Event-ID only after a complete frame reaches the callback.
import { setTimeout as delay } from 'node:timers/promises';
function parseFrame(frame) {
const parsed = { type: 'message', dataLines: [] };
for (const line of frame.split('\n')) {
if (!line || line.startsWith(':')) continue;
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'event') parsed.type = value;
if (field === 'data') parsed.dataLines.push(value);
if (field === 'id' && !value.includes('\0')) parsed.id = value;
if (field === 'retry' && /^\d+$/.test(value)) parsed.retry = Number(value);
}
return {
type: parsed.type,
data: parsed.dataLines.join('\n'),
id: parsed.id,
retry: parsed.retry,
};
}
async function readEventStream(body, onFrame) {
if (!body) throw new Error('SSE response has no body');
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
let boundary = buffer.indexOf('\n\n');
while (boundary !== -1) {
const rawFrame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
if (rawFrame) onFrame(parseFrame(rawFrame));
boundary = buffer.indexOf('\n\n');
}
if (done) return;
}
}
export async function collectWithReconnect({ url, expectedCount, maxReconnects = 5 }) {
const updates = [];
const attempts = [];
const retryDelays = [];
const readErrors = [];
let lastEventId = '';
let retryMs = 1000;
let completed = false;
while (!completed) {
const sentLastEventId = lastEventId || null;
const headers = { Accept: 'text/event-stream' };
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
attempts.push({ sentLastEventId });
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`SSE subscription failed with ${response.status}`);
}
try {
await readEventStream(response.body, (frame) => {
if (frame.retry !== undefined) retryMs = frame.retry;
if (frame.id !== undefined) lastEventId = frame.id;
if (frame.type === 'update') {
updates.push({ id: frame.id, payload: JSON.parse(frame.data) });
}
if (frame.type === 'complete') completed = true;
});
} catch (error) {
readErrors.push(error.message);
}
if (completed) break;
if (updates.length >= expectedCount) {
throw new Error('Expected completion event after final update');
}
if (attempts.length - 1 >= maxReconnects) {
throw new Error(`Reconnect limit reached after ${attempts.length} attempts`);
}
retryDelays.push(retryMs);
await delay(retryMs);
}
return { updates, attempts, retryDelays, readErrors, lastEventId, completed };
}
Unlike browser EventSource, this controllable harness exposes outgoing headers, retry decisions, and body-read failures to the test runner.
Verification: Run node --check sse-client.mjs. No output and exit code 0 mean Node parsed the module. A syntax error includes a line and column, which is faster to diagnose here than inside the full integration test.
Step 5: Automate Disconnect, Header, Replay, and Order Assertions
Stop the manually started server so the test can choose a free port. Create reconnect.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSseServer } from './server.mjs';
import { collectWithReconnect } from './sse-client.mjs';
async function startFixture(t) {
const fixture = createSseServer();
await new Promise((resolve) => fixture.server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve, reject) => {
fixture.server.close((error) => error ? reject(error) : resolve());
}));
const address = fixture.server.address();
return { ...fixture, baseUrl: `http://127.0.0.1:${address.port}` };
}
test('resumes after the last dispatched event without gaps', async (t) => {
const { baseUrl, subscriptions } = await startFixture(t);
const result = await collectWithReconnect({
url: `${baseUrl}/events?dropAfter=3`,
expectedCount: 8,
});
const expectedIds = Array.from({ length: 8 }, (_, index) =>
`evt-${String(index + 1).padStart(3, '0')}`
);
assert.deepEqual(result.updates.map((event) => event.id), expectedIds);
assert.deepEqual(result.updates.map((event) => event.payload.sequence),
[1, 2, 3, 4, 5, 6, 7, 8]);
assert.deepEqual(result.attempts.map((attempt) => attempt.sentLastEventId),
[null, 'evt-003', 'evt-006']);
assert.deepEqual(subscriptions.map((request) => request.lastEventId),
[null, 'evt-003', 'evt-006']);
assert.deepEqual(result.retryDelays, [50, 50]);
assert.equal(result.lastEventId, 'evt-008');
assert.equal(result.completed, true);
assert.equal(result.readErrors.length, 2);
});
The ID list detects gaps, duplicates, and a wrong replay boundary. Payload sequences catch mismatched data, while client attempts and server subscriptions prove both ends of the header exchange. Two read errors match the planned socket breaks.
Verification: Run node --test reconnect.test.mjs. Expect one passing test, three logged subscriptions, and a summary with pass 1 and fail 0. The test binds port 0, so parallel CI jobs do not compete for port 3000.
Step 6: Test Expired IDs and Clean Completion
Add these two tests below the existing test in reconnect.test.mjs:
test('returns a recovery range for an unavailable replay cursor', async (t) => {
const { baseUrl } = await startFixture(t);
const response = await fetch(`${baseUrl}/events`, {
headers: {
Accept: 'text/event-stream',
'Last-Event-ID': 'evt-999',
},
});
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), {
error: 'replay_cursor_unavailable',
availableFrom: 'evt-001',
availableTo: 'evt-008',
});
});
test('does not reconnect after a normal complete event', async (t) => {
const { baseUrl, subscriptions } = await startFixture(t);
const result = await collectWithReconnect({
url: `${baseUrl}/events`,
expectedCount: 8,
});
assert.equal(result.attempts.length, 1);
assert.equal(subscriptions.length, 1);
assert.deepEqual(result.retryDelays, []);
assert.equal(result.readErrors.length, 0);
assert.equal(result.completed, true);
});
The stale-cursor test prevents retention changes from becoming silent data loss. The clean-close test prevents the opposite defect: a client that reconnects forever after the application has sent its explicit completion signal. Native EventSource reconnects after an ordinary HTTP close unless application code calls close(), so a named completion event is a useful product-level convention for finite jobs.
Verification: Run node --test reconnect.test.mjs again. The summary should report tests 3, pass 3, and fail 0. A hanging process usually means a response remained open or the fixture was not registered with t.after.
Step 7: Extend test sse reconnect last event id Coverage to Native EventSource
A browser owns the EventSource reconnect algorithm and does not let application JavaScript set arbitrary constructor headers. Test this path because wrappers can recreate or close the object during recovery.
Restart node server.mjs, then create browser-check.html:
<!doctype html>
<html lang='en'>
<meta charset='utf-8'>
<title>SSE reconnect check</title>
<pre id='output'>connecting...
</pre>
<script>
const output = document.querySelector('#output');
const received = [];
const source = new EventSource(
'http://127.0.0.1:3000/events?dropAfter=3'
);
source.addEventListener('update', (message) => {
received.push(message.lastEventId);
output.textContent = received.join('\n');
});
source.addEventListener('complete', () => {
source.close();
const expected = Array.from({ length: 8 }, (_, index) =>
`evt-${String(index + 1).padStart(3, '0')}`
);
output.textContent += received.join('|') === expected.join('|')
? '\nPASS: ordered replay completed'
: '\nFAIL: gap or duplicate detected';
});
source.onerror = () => {
output.textContent += '\nconnection interrupted, waiting to retry';
};
</script>
</html>
Open the file in a current browser. The CORS header permits the local subscription. Expected error callbacks mark deliberate breaks; the final IDs and received request headers decide the result.
For an automated browser suite, observe the final PASS text and retain server-side subscription logs. The patterns in Playwright realtime notification assertions help connect transport recovery to UI state, while GraphQL subscription testing covers the WebSocket alternative.
Verification: Expect the page to end with PASS: ordered replay completed. The server terminal must show three subscriptions with cursors null, evt-003, and evt-006. Browser DevTools may label the interrupted requests as failed, which is correct for this controlled fault.
Step 8: Add the SSE Recovery Test to CI
Register one stable command rather than copying runner flags across pipelines:
npm pkg set 'scripts.test:sse=node --test reconnect.test.mjs'
npm run test:sse
Keep this eight-record functional suite separate from concurrency, long retention, proxy timeout, and failover jobs. OpenAPI contract testing validates ordinary response shapes, but it cannot prove a stream's temporal order.
Pin Node.js 22.18.0, preserve runner output, and fail on any nonzero exit code. Do not auto-retry the job, which can mask a repeatable cursor defect. Capture IDs, headers, delays, and read errors before changing timeouts.
After the local case passes, compare curl -N at the application and public edge to locate proxy buffering or idle-timeout interference.
Verification: Run npm run test:sse from a clean shell. The command should execute all three tests without a separately started server and return exit code 0. Then run it a second time to confirm the ephemeral port and teardown leave no process behind.
Troubleshooting
Problem: The reconnect request has no Last-Event-ID header -> Confirm every replayable message includes a valid id: field and a terminating blank line. A partial frame is not dispatched, comments do not update the cursor, and an id containing a null character must be ignored. In browser code, reuse the same EventSource instance and let its reconnect algorithm run.
Problem: The resumed stream repeats evt-003 -> Check whether the server starts at previousIndex instead of previousIndex + 1. If at-least-once replay is intentional, change the expected list and add consumer deduplication. Do not call an accidental duplicate a harmless transport detail when payload handling has side effects.
Problem: Events arrive only when the response closes -> Disable compression and proxy buffering for text/event-stream. Keep Cache-Control: no-cache, no-transform, flush headers, and use curl -N at each network hop. X-Accel-Buffering: no helps with Nginx when that header is honored, but the proxy configuration remains the authoritative control.
Problem: The test reconnects forever -> Require an explicit completion condition and enforce maxReconnects. A normal EOF without a product-level completion event is ambiguous, so bound retries and report the last processed ID. For finite streams in browser code, handle a named complete event and call source.close().
Problem: The unknown-ID test returns a fresh stream -> The service has an implicit reset policy or lost the request header. Decide whether 409, 410, a snapshot, or a reset event fits the product, then assert that exact response. Starting fresh without notifying the consumer can conceal a retention gap.
Problem: Local tests pass but the deployed service loses IDs -> Inspect load balancer routing, shared replay storage, cache retention, and deployment overlap. A reconnect may land on an instance that does not own the previous instance's memory. Use shared durable history, sticky routing with a documented failure mode, or a cursor that identifies its partition.
Where To Go Next
Add production rules one at a time: auth expiry, retention boundaries, empty id: resets, multiline data, heartbeats, and slow consumers. Combine temporal checks with Playwright TypeScript OpenAPI contract tests for payload compatibility.
Then assert UI consequences such as duplicate toasts or reset progress. Use ordered IDs as transport evidence and test visible state separately. Sharpen that reasoning with JavaScript promises interview questions or the QAJobFit practice area.
After correctness is deterministic, measure reconnect storms, retry jitter, replay amplification, and catch-up time without changing the functional oracle.
Interview Questions and Answers
Q: What causes a browser to send Last-Event-ID?
A dispatched message with an id updates the EventSource cursor. The browser includes it on retry, while application code reads MessageEvent.lastEventId rather than setting the header.
Q: Does Last-Event-ID guarantee exactly-once delivery?
No. Server replay boundaries and consumer persistence determine delivery semantics. A crash around side effects can duplicate work, while expired history can create a gap, so important handlers need idempotency or deduplication.
Q: Why assert the server's received headers as well as the client's event list?
An event list proves the outcome, not the mechanism. Server request records confirm the interoperable cursor crossed the network instead of hidden session state reconstructing the same output.
Q: How should a service handle an expired replay cursor?
Return an explicit 409, 410, reset event, or snapshot based on whether clients can rebuild state. Never continue silently from an unrelated cursor.
Q: What does the SSE retry field control?
A valid nonnegative integer retry field changes the reconnection delay in milliseconds for the event stream client. It is not a maximum attempt count, an HTTP retry policy, or proof of exponential backoff. Tests should separate the advertised base delay from any product-specific jitter or cap.
Q: How do you detect an off-by-one replay bug?
Force a disconnect after a known ID and assert the first ID on the next connection. Under an exclusive cursor contract, evt-003 must lead to evt-004. Also compare the complete ordered sequence so a later compensating skip cannot hide the initial duplication.
Common Mistakes and Best Practices
Record the processed cursor, first replayed ID, complete sequence, reconnect count, and terminal event. Status and connection counts cannot explain a replay defect.
Treat complete dispatched frames as the unit, not received chunks. Support multiline data and both line endings, and ignore comments for business delivery.
Use deterministic boundaries such as dropAfter=3. Add random fault injection only after the exact replay contract passes.
Reauthorize every replay and reject cross-tenant cursors. Include token expiry without exposing another account's retained range.
Keep protocol fixtures immutable. Separately test the handoff from historical catch-up to live publishing.
Conclusion: Test SSE Reconnect Last Event ID as a Replay Contract
A reliable SSE recovery test creates a known event log, interrupts delivery at exact IDs, and checks the complete chain: dispatched cursor, reconnect header, server lookup, resumed boundary, ordered payloads, retry timing, and clean completion. That chain catches the failures hidden by a successful initial connection.
Run the three-test Node suite first, then confirm the same header sequence with native EventSource. Once both pass, extend the contract to retention, authentication, proxies, UI state, and reconnect load without weakening the eight-event oracle.
Interview Questions and Answers
How would you test SSE reconnection and Last-Event-ID end to end?
I would seed an immutable event log, force the response to break after a known event, and capture the next subscription request. I would assert its Last-Event-ID value, the first replayed ID, the full ordered payload list, reconnect timing, and final completion. I would also cover an expired cursor and the native browser path.
Who is responsible for storing events used by Last-Event-ID replay?
The application and its infrastructure own replay storage. SSE defines text framing and cursor propagation, not a durable event log. I would test retention duration, partition lookup, deployment behavior, and the response when the requested record is unavailable.
Why is exactly-once delivery not guaranteed by Server-Sent Events?
A cursor cannot atomically cover network receipt, business processing, and persistence of processing state. Failure between those operations may cause a duplicate or a gap depending on the design. Reliable consumers therefore use idempotent changes, deduplication, or transactional cursor storage when required.
Which assertions distinguish a strong SSE reconnect test from a status check?
I check every received ID and payload sequence, the cursor on each new request, the first event after recovery, attempt count, retry delay, read failures, and the terminal event. Server-side subscription evidence confirms the header actually arrived. HTTP 200 only proves that a connection began.
How would you test SSE recovery behind multiple server instances?
I would establish a stream on one instance, interrupt it, and route the retry to another instance. The resumed sequence must remain correct using shared storage or a partition-aware cursor. I would repeat the test during a rolling deployment and inspect whether sticky routing hides a missing durability guarantee.
What security checks belong in a Last-Event-ID test suite?
I would revalidate authorization on every subscription, reject a cursor from another tenant, and test token expiry during reconnection. Replay must obey current permissions rather than the permissions from the original stream. Error bodies and logs must not disclose another tenant's IDs or payload range.
How do proxy settings affect SSE reconnect testing?
Buffering can delay complete frames, while idle timeouts can create synchronized reconnects that never appear locally. I verify incremental output at the app and public edge, align timeout and heartbeat policies, and record disconnect timing. A functional cursor test should run through the same ingress path used by clients.
Frequently Asked Questions
What is the Last-Event-ID header in SSE?
Last-Event-ID carries the most recent SSE event ID remembered by the reconnecting client. The server can use it as a replay cursor, but the application must define retention and whether replay starts at or after that ID.
How do I force an SSE reconnection in a test?
Make a test-only server fixture close the response after a fixed number of complete events. Deterministic closure after a known ID gives you an exact expected header and avoids relying on random network timing.
Can JavaScript set Last-Event-ID on browser EventSource?
The native EventSource constructor does not accept arbitrary request headers. The browser maintains the cursor from dispatched `id` fields and adds Last-Event-ID during its own reconnect; use server logs or network inspection to verify it.
Should an SSE server replay the event named by Last-Event-ID?
That is a product delivery decision. This tutorial uses an exclusive cursor, so the server starts with the following event, while an at-least-once design may repeat the named event and require consumer deduplication.
What should happen when an SSE event ID has expired?
Return an explicit recovery outcome such as 409, 410, a reset event, or a complete snapshot. Include enough metadata for the client to rebuild safely instead of silently continuing with an unknown gap.
Why does my SSE stream reconnect without Last-Event-ID?
The prior stream may not have dispatched any valid `id` field, or application code may have replaced the EventSource instance. Check frame termination, ID validity, empty ID resets, and whether the disconnect occurred before the complete frame reached the parser.
How can I test duplicate SSE events after reconnect?
Collect every event ID across all attempts and compare the final array with an exact expected sequence. If duplicates are allowed by the service contract, assert that the consumer deduplicates them by a stable key before applying side effects.