QA How-To
Test WebSocket Message Ordering and Loss (2026)
Learn to test websocket message ordering and loss with Node.js checks for sequence gaps, duplicates, reconnect replay, and asynchronous processing races.
18 min read | 2,756 words
TL;DR
Capture each WebSocket message, audit a server-assigned sequence field, and compare the observed IDs with an explicit expected range. Then force gaps, reversals, duplicates, disconnects, and delayed handlers so the suite proves both transport-level observation and application-level processing behavior.
Key Takeaways
- Put a monotonic sequence number and stream identity in every ordered application message.
- Assert exact arrival sequences on one connection instead of relying on timestamps or visual order.
- Declare the expected sequence boundary so trailing loss is detectable.
- Test gaps, duplicates, regressions, disconnects, and replay as separate failure modes.
- Separate transport arrival order from asynchronous handler completion order.
- Use deterministic scripted servers and bounded timeouts to keep CI failures reproducible.
To test websocket message ordering and loss, record a monotonic server-assigned sequence number for every message, assert the exact arrival order, and compare the received set with a known expected range. WebSocket preserves frame order on one live connection, but that guarantee does not make an application durable across disconnects, multi-node publishing, dropped subscriptions, replay, or concurrent message handlers.
This tutorial builds a runnable Node.js integration suite around a scripted WebSocket server. You will make correct delivery pass, inject one missing sequence, reverse two application messages, replay an event after reconnect, and expose an async processing race. For a broader protocol and tool overview, read the WebSocket testing guide.
The important design choice is to test an observable application contract, not an assumption such as "messages looked sorted in the UI." Sequence IDs provide a deterministic oracle. Session IDs, expected boundaries, and reconnect metadata explain which delivery guarantee failed.
TL;DR
| Signal | Example arrival | What the test proves | Typical cause |
|---|---|---|---|
| Clean sequence | 41, 42, 43 |
No gap, duplicate, or regression in the declared range | Expected path |
| Gap | 41, 43, 44 |
Sequence 42 was not observed | Disconnect, queue policy, filter, or publisher omission |
| Duplicate | 41, 42, 42, 43 |
One logical event arrived more than once | Retry or reconnect replay |
| Regression | 41, 43, 42, 44 |
Arrival order differs from sequence order | Multiple producers, merge logic, or async forwarding |
| Truncated tail | 41, 42 with expected end 44 |
Final messages never arrived | Early close or an insufficient wait condition |
Use server sequence numbers rather than client timestamps. Clocks can disagree, and millisecond timestamps can collide. Keep the expected end explicit because observing 1, 2, 3 cannot reveal that 4 was supposed to exist.
What You Will Build
You will create a small test project containing:
- A local WebSocket server that emits a declared script on an operating-system-assigned port.
- A client collector with a bounded timeout and an option to resolve on server closure.
- A sequence auditor that reports missing IDs, duplicates, and arrival regressions.
- Integration tests for correct order, a gap, deliberate reordering, and reconnect replay.
- A processing test showing how concurrent async handlers can scramble otherwise ordered arrivals.
- A deterministic 100-message stress case suitable for continuous integration.
The test server is intentionally narrow. It does not pretend to validate a production broker, load balancer, or gateway. It gives you exact control over inputs so each failed assertion names one protocol defect. Add a small deployed smoke path after these checks are stable.
Prerequisites
Use Node.js 24.18.0 LTS, npm 11, and ws 8.21.1. The examples use ECMAScript modules and the stable node:test and node:assert/strict APIs, so no separate test framework is required.
Confirm your runtime:
node --version
npm --version
The first command should print v24.18.0. A later Node.js 24 patch is also acceptable, but pin one patch in CI so local and pipeline behavior match. You need an unused loopback port, permission to install an npm package, and basic familiarity with promises and JSON.
This tutorial tests text messages whose payloads are JSON objects. If your service sends Protocol Buffers, MessagePack, or another binary format, keep the same sequence assertions after decoding with the real schema. If you first need browser-side frame visibility, follow inspect Playwright WebSocket frames step by step.
Step 1: test websocket message ordering and loss project setup
Create a clean project and save this package.json at its root:
{
"name": "websocket-order-loss-tests",
"private": true,
"type": "module",
"scripts": {
"test": "node --test",
"test:order": "node --test tests/order.test.mjs"
},
"dependencies": {
"ws": "8.21.1"
}
}
Install the locked dependency and create the source folders:
npm install
mkdir -p src tests
npm ls ws
Pinning ws removes a moving variable from the tutorial. Commit package-lock.json in a real test repository and use npm ci in CI. The Node client package is appropriate here because ws implements both client and server APIs on Node.js. A browser application should continue to use the browser's native WebSocket class.
Verify the step: npm ls ws must show ws@8.21.1 under websocket-order-loss-tests. Run npm test; zero discovered tests is acceptable now, but a module resolution or package error is not.
Step 2: Build a deterministic scripted WebSocket server
Create src/ws-fixture.mjs. It starts an HTTP server on port zero, which asks the operating system for a free port, and attaches a WebSocketServer. Each incoming connection receives one session script.
import { createServer } from 'node:http';
import { WebSocket, WebSocketServer } from 'ws';
export async function startScriptedServer(sessions, { intervalMs = 5 } = {}) {
const server = createServer();
const wss = new WebSocketServer({ server });
let connectionCount = 0;
wss.on('connection', socket => {
const index = Math.min(connectionCount, sessions.length - 1);
const session = sessions[index];
connectionCount += 1;
let sent = 0;
const timer = setInterval(() => {
if (socket.readyState !== WebSocket.OPEN) {
clearInterval(timer);
return;
}
const frame = session.frames[sent];
if (!frame) {
clearInterval(timer);
return;
}
socket.send(JSON.stringify(frame));
sent += 1;
if (session.closeAfter === sent) {
clearInterval(timer);
socket.close(1011, 'scripted disconnect');
} else if (sent === session.frames.length) {
clearInterval(timer);
}
}, intervalMs);
socket.on('close', () => clearInterval(timer));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('No TCP address');
return {
url: `ws://127.0.0.1:${address.port}`,
getConnectionCount: () => connectionCount,
async stop() {
for (const client of wss.clients) client.terminate();
await new Promise(resolve => wss.close(resolve));
await new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
});
}
};
}
The fixture sends frames sequentially from one timer. Therefore, a script containing sequence IDs 1, 3, 2 deliberately models application-level reordering before the data reaches the client. The helper also supports multiple connection scripts and an intentional close after a chosen message, which you will use for recovery testing.
Never hardcode a popular port such as 8080 in parallel test workers. Random loopback ports prevent unrelated suites from competing for one listener. Terminating remaining clients in stop() also prevents a hanging Node process after a failed assertion.
Verify the step: run node --check src/ws-fixture.mjs. No output and exit code zero mean Node parsed the module successfully.
Step 3: Capture a bounded WebSocket session
Create src/ws-client.mjs. The collector resolves after an expected message count or, when requested, after the server closes the connection. Every path clears its timeout.
import WebSocket from 'ws';
export function captureSession(url, {
expectedCount,
resolveOnClose = false,
timeoutMs = 1_000
} = {}) {
return new Promise((resolve, reject) => {
const events = [];
const socket = new WebSocket(url);
let settled = false;
const timer = setTimeout(() => {
finish(new Error(`Timed out after ${timeoutMs} ms with ${events.length} messages`));
}, timeoutMs);
function finish(error, close = { code: null, reason: null }) {
if (settled) return;
settled = true;
clearTimeout(timer);
if (socket.readyState === WebSocket.OPEN) socket.close(1000, 'capture complete');
if (error) reject(error);
else resolve({ events, close });
}
socket.on('message', data => {
events.push(JSON.parse(data.toString()));
if (expectedCount !== undefined && events.length === expectedCount) finish();
});
socket.on('close', (code, reason) => {
if (resolveOnClose) finish(undefined, { code, reason: reason.toString() });
else if (!settled) finish(new Error(`Socket closed early with code ${code}`));
});
socket.on('error', error => finish(error));
});
}
A bounded capture is stronger than sleeping for 500 ms and inspecting whatever happened to arrive. For a normal flow, expectedCount is the completion condition. For a disconnect case, resolveOnClose preserves the close code and all messages seen before the break. JSON parsing intentionally fails fast because malformed payloads violate this sample protocol.
Production collectors should limit payload size and redact diagnostic fields. They should also handle binary messages explicitly instead of calling toString() on unknown bytes.
Verify the step: run node --check src/ws-client.mjs. Then run node -e "import('./src/ws-client.mjs').then(m => console.log(typeof m.captureSession))"; it should print function.
Step 4: test websocket message ordering and loss with a sequence audit
Create src/sequence-audit.mjs. This pure function validates integer sequence IDs, discovers duplicates, finds absent values inside a declared range, and records any arrival that moves backward.
export function auditSequence(events, { expectedFrom = 1, expectedTo } = {}) {
const sequences = events.map(event => event.sequence);
if (sequences.some(sequence => !Number.isInteger(sequence))) {
throw new TypeError('Every event.sequence must be an integer');
}
const counts = new Map();
for (const sequence of sequences) {
counts.set(sequence, (counts.get(sequence) ?? 0) + 1);
}
const duplicates = [...counts.entries()]
.filter(([, count]) => count > 1)
.map(([sequence]) => sequence);
const finalSequence = expectedTo ?? Math.max(expectedFrom - 1, ...sequences);
const missing = [];
for (let sequence = expectedFrom; sequence <= finalSequence; sequence += 1) {
if (!counts.has(sequence)) missing.push(sequence);
}
const regressions = [];
for (let index = 1; index < sequences.length; index += 1) {
if (sequences[index] < sequences[index - 1]) {
regressions.push({
index,
previous: sequences[index - 1],
current: sequences[index]
});
}
}
return {
sequences,
missing,
duplicates,
regressions,
ordered: missing.length === 0 && duplicates.length === 0 && regressions.length === 0
};
}
A set alone can find gaps but cannot detect order. A sorted array can hide the very regression under test. This auditor keeps original arrival positions and uses a separate frequency map. expectedTo is essential for trailing loss: without it, [1, 2] appears complete because the observer has no evidence that sequence 3 was planned.
Scope each sequence to a logical stream, such as account-17:orders, and reset or resume it according to the published contract. Do not compare unrelated topics that happen to share one socket.
Verify the step: run node -e "import('./src/sequence-audit.mjs').then(({auditSequence}) => console.log(auditSequence([{sequence:1},{sequence:3}], {expectedTo:3})))". The output must include missing: [ 2 ] and ordered: false.
Step 5: Prove correct order and detect an omitted message
Create tests/order.test.mjs. The first test protects the normal contract. The second sends sequence 1, 2, 4, and 5 while declaring that 1 through 5 should exist.
import test from 'node:test';
import assert from 'node:assert/strict';
import { startScriptedServer } from '../src/ws-fixture.mjs';
import { captureSession } from '../src/ws-client.mjs';
import { auditSequence } from '../src/sequence-audit.mjs';
test('delivers one connection in server sequence order', async t => {
const frames = [1, 2, 3].map(sequence => ({
stream: 'orders', sequence, type: 'order.updated'
}));
const server = await startScriptedServer([{ frames }]);
t.after(() => server.stop());
const { events } = await captureSession(server.url, { expectedCount: 3 });
const audit = auditSequence(events, { expectedFrom: 1, expectedTo: 3 });
assert.deepEqual(audit.sequences, [1, 2, 3]);
assert.equal(audit.ordered, true);
});
test('reports a missing WebSocket application message', async t => {
const frames = [1, 2, 4, 5].map(sequence => ({
stream: 'orders', sequence, type: 'order.updated'
}));
const server = await startScriptedServer([{ frames }]);
t.after(() => server.stop());
const { events } = await captureSession(server.url, { expectedCount: 4 });
const audit = auditSequence(events, { expectedFrom: 1, expectedTo: 5 });
assert.deepEqual(audit.missing, [3]);
assert.deepEqual(audit.duplicates, []);
assert.equal(audit.ordered, false);
});
These tests do not claim that TCP randomly discarded sequence 3 while keeping the connection healthy. The fixture models what a consumer sees when an upstream publisher omits an event, a resume cursor skips a record, or a queue policy drops data. Your observable requirement remains the same: every expected logical event must reach the consumer.
Keep the happy path because it catches fixture and auditor regressions. Keep the injected gap separately because a test that merely asserts received count can pass with the wrong IDs. For UI-oriented assertions driven by messages, see the Playwright WebSocket testing complete guide.
Verify the step: run npm run test:order. Node should report two passing tests. Change the missing fixture from 4 to 3 and the second test should fail at missing, demonstrating that the oracle reacts to the payload rather than elapsed time.
Step 6: Detect reordering and reconnect replay
Add two tests to tests/order.test.mjs. One sends all expected IDs but swaps 2 and 3. The other closes the first connection after sequence 2, then begins a second connection by replaying sequence 2 before continuing.
test('reports an arrival regression even when no ID is missing', async t => {
const frames = [1, 3, 2, 4].map(sequence => ({
stream: 'orders', sequence, type: 'order.updated'
}));
const server = await startScriptedServer([{ frames }]);
t.after(() => server.stop());
const { events } = await captureSession(server.url, { expectedCount: 4 });
const audit = auditSequence(events, { expectedFrom: 1, expectedTo: 4 });
assert.deepEqual(audit.missing, []);
assert.deepEqual(audit.regressions, [{ index: 2, previous: 3, current: 2 }]);
assert.equal(audit.ordered, false);
});
test('deduplicates replay after a scripted disconnect', async t => {
const event = sequence => ({ stream: 'orders', sequence, type: 'order.updated' });
const server = await startScriptedServer([
{ frames: [event(1), event(2)], closeAfter: 2 },
{ frames: [event(2), event(3), event(4)] }
]);
t.after(() => server.stop());
const first = await captureSession(server.url, { resolveOnClose: true });
assert.equal(first.close.code, 1011);
const second = await captureSession(server.url, { expectedCount: 3 });
assert.equal(server.getConnectionCount(), 2);
const combined = [...first.events, ...second.events];
const beforeDeduplication = auditSequence(combined, { expectedTo: 4 });
assert.deepEqual(beforeDeduplication.duplicates, [2]);
const unique = [...new Map(combined.map(item => [item.sequence, item])).values()];
const afterDeduplication = auditSequence(unique, { expectedTo: 4 });
assert.equal(afterDeduplication.ordered, true);
});
A reversed pair is not loss because all IDs are present. It is an ordering violation. The replay case is not corruption if your delivery contract is at least once and the consumer is idempotent. Name these outcomes precisely so a failure routes to the correct owner.
The example deduplicates by sequence for one stream. Real events often use an immutable eventId for idempotency and a separate sequence for order. Persist the last committed cursor only after business processing succeeds. For a browser recovery workflow, use the Playwright WebSocket reconnection tutorial.
Verify the step: rerun npm run test:order. Four tests should pass. Delete the Map deduplication and audit combined a second time; the final ordered assertion should fail because sequence 2 remains duplicated.
Step 7: Expose asynchronous application processing races
Wire arrival order and state update order are different observables. A message listener that launches async work without serialization can finish later messages first. Add this unit test to tests/order.test.mjs:
test('serial processing preserves ordered WebSocket arrivals', async () => {
const arrivals = [1, 2, 3].map(sequence => ({ sequence }));
const delay = new Map([[1, 30], [2, 10], [3, 0]]);
const completedConcurrently = [];
await Promise.all(arrivals.map(async event => {
await new Promise(resolve => setTimeout(resolve, delay.get(event.sequence)));
completedConcurrently.push(event);
}));
const concurrentAudit = auditSequence(completedConcurrently, { expectedTo: 3 });
assert.deepEqual(concurrentAudit.sequences, [3, 2, 1]);
assert.equal(concurrentAudit.ordered, false);
const completedSerially = [];
for (const event of arrivals) {
await new Promise(resolve => setTimeout(resolve, delay.get(event.sequence)));
completedSerially.push(event);
}
const serialAudit = auditSequence(completedSerially, { expectedTo: 3 });
assert.deepEqual(serialAudit.sequences, [1, 2, 3]);
assert.equal(serialAudit.ordered, true);
});
WebSocket delivered 1, 2, 3; Promise.all completed 3, 2, 1. A UI could now show an older order status over a newer one even though the frame trace is perfect. Serialize state-changing handlers, queue them by entity key, or make the reducer reject stale sequence numbers. Do not serialize unrelated streams globally unless the contract requires it, because that creates avoidable head-of-line blocking.
In a browser test, collect both receivedSequence at the message event and appliedSequence after the store commits. Comparing those timelines separates a server ordering defect from a client processing race.
Verify the step: run node --test --test-name-pattern="serial processing". The single selected test should pass and prove both the intentionally scrambled concurrent result and the corrected serial result.
Step 8: Run a deterministic burst in CI
Small examples explain failures; a larger scripted burst catches indexing and reporting errors. Add one final test. It removes 37, duplicates 52 beside itself, and swaps 69 with 70.
test('audits a deterministic 100-message fault pattern', async t => {
const frames = Array.from({ length: 100 }, (_, index) => ({
stream: 'orders', sequence: index + 1, type: 'order.updated'
})).filter(event => event.sequence !== 37);
const index52 = frames.findIndex(event => event.sequence === 52);
frames.splice(index52 + 1, 0, { ...frames[index52] });
const index69 = frames.findIndex(event => event.sequence === 69);
const index70 = frames.findIndex(event => event.sequence === 70);
[frames[index69], frames[index70]] = [frames[index70], frames[index69]];
const server = await startScriptedServer([{ frames }], { intervalMs: 1 });
t.after(() => server.stop());
const { events } = await captureSession(server.url, {
expectedCount: 100,
timeoutMs: 2_000
});
const audit = auditSequence(events, { expectedFrom: 1, expectedTo: 100 });
assert.deepEqual(audit.missing, [37]);
assert.deepEqual(audit.duplicates, [52]);
assert.ok(audit.regressions.some(item => item.previous === 70 && item.current === 69));
assert.equal(audit.ordered, false);
t.diagnostic(JSON.stringify(audit));
});
This is a deterministic fault injection test, not a throughput benchmark. Increasing the rate until the current laptop fails produces a machine-specific number with little diagnostic value. For capacity and backpressure work, measure published rate, received rate, queue depth, reconnect count, and latency percentiles under a declared environment. The k6 WebSocket load testing tutorial covers that separate goal.
Emit the audit object as a CI diagnostic only when useful, and cap evidence for long sessions. Include stream ID, connection number, close code, first and last expected sequence, and the first few anomalies. Do not log access tokens or customer message bodies.
Verify the step: run npm test. Six tests should pass, including the fault-pattern test because it asserts that the injected anomalies are found. If it times out on a heavily loaded runner, raise the bounded timeout modestly; do not replace the completion condition with an arbitrary sleep.
Troubleshooting
Problem: the client times out with zero messages -> Confirm the server URL uses the port returned by server.address(), await startScriptedServer(), and check that a corporate security tool is not blocking loopback sockets. Do not add delay before connecting because the awaited listen callback already establishes readiness.
Problem: Socket closed early with code 1006 appears -> Code 1006 means the close handshake was not observed. Look for process termination, proxy interference, or terminate() being called before collection completes. Reserve abrupt termination for a test that explicitly targets abnormal closure.
Problem: a gap test cannot detect missing final events -> Pass expectedTo from a fixture manifest, snapshot boundary, terminal summary, or protocol message. Without a known upper boundary, the observer cannot distinguish a complete short stream from a truncated one.
Problem: every reconnect reports a duplicate -> Decide whether the service promises exactly once or at least once. If replay is expected, deduplicate with stable event IDs and verify that different payloads never reuse the same ID. If replay is forbidden, keep the duplicate assertion as a server defect.
Problem: frame order is correct but the UI regresses -> Instrument completion order around asynchronous parsing, storage, and rendering. Queue state changes by entity or ignore updates whose sequence is not newer than the stored version.
Problem: the suite passes locally but flakes in parallel CI -> Use port zero, close all clients in teardown, retain bounded diagnostics, and avoid global shared counters. Increase timeout only after evidence shows a slow but valid run rather than a missing completion signal.
Common Mistakes and Best Practices
- Do define order per stream or entity. Do not compare independent topics simply because they share a TCP connection.
- Do use a monotonic integer plus a stable event ID. Do not infer causality from client receipt timestamps.
- Do test missing IDs, duplicates, regressions, and early close independently. Do not collapse them into one vague "socket failed" assertion.
- Do preserve original arrival order in evidence. Do not sort before auditing and erase regressions.
- Do state whether replay is legal after reconnect. Do not call every duplicate a transport bug.
- Do bound waits with a semantic completion condition. Do not sleep for a guessed interval and count a partial buffer.
- Do validate the decoded payload before reading sequence fields. Do not let
undefinedsilently enter the audit. - Do pair deterministic fixture tests with a few real deployment checks. Do not assume a local server proves TLS, authentication, gateway, or broker behavior.
Where To Go Next
Adapt the fixture to your owned envelope first. Add streamId, eventId, sequence, schemaVersion, and a server-issued snapshot boundary. Then test each delivery promise stated by the protocol: ordered within a stream, replay allowed after a cursor, idempotent updates, and an explicit response to unrecoverable gaps.
Use mock WebSocket server responses with Playwright when a browser UI must react to these same scripts. Use the GraphQL subscriptions WebSocket tutorial when ordering is carried inside subscription payloads rather than a custom message envelope. Keep load behavior in the k6 guide and correctness behavior in this deterministic suite so each failure remains explainable.
Interview Questions and Answers
Q: Does WebSocket guarantee message ordering?
For frames sent sequentially over one established connection, WebSocket runs over an ordered byte stream and preserves their order. That does not guarantee durable delivery after a disconnect or ordering across separate connections, publishers, topics, or concurrent application handlers. I define the exact scope before choosing assertions.
Q: How can a test prove that a WebSocket message was lost?
The protocol needs an oracle such as contiguous sequence IDs and a known expected boundary. The test records arrivals and reports absent IDs in that range. A received count alone cannot identify which logical event disappeared.
Q: Why are timestamps weaker than sequence numbers for this test?
Publisher and consumer clocks may differ, timestamp precision can cause ties, and retries can retain or regenerate time fields. A server-controlled monotonic sequence communicates the intended order directly. Timestamps remain useful for latency after order is established.
Q: How should reconnect replay be tested?
Close a connection after a committed event, reconnect with the last cursor, and script an overlap plus a new event. Verify that the overlap has no second business effect and the new event is still applied. Also record the close code and restored subscription state.
Q: What causes ordered frames to update the UI out of order?
Independent asynchronous handlers can finish in a different order because parsing, database work, or rendering takes unequal time. Capture both arrival and commit sequences to isolate the client race. Fix it with per-key serialization or stale-update rejection.
Q: Would you use a load test to validate message correctness?
I use deterministic integration cases as the primary correctness oracle because their faults are reproducible. A load test adds rate, queue, and latency evidence, then samples or aggregates sequence anomalies. Mixing both goals into one assertion makes failures difficult to diagnose.
Conclusion
A reliable way to test websocket message ordering and loss is to make the application protocol observable. Assign sequences within a named stream, declare the expected range, preserve arrival order, and classify gaps, duplicates, and regressions separately. Then cross the connection boundary and the async processing boundary, where real products commonly weaken WebSocket's in-connection ordering guarantee.
Start with the six runnable tests in this guide. Replace the sample envelope with your production schema, define the allowed replay behavior, and keep the scripted suite in pull-request CI. Add a small deployed smoke test and a separate load scenario only after the deterministic contract is clear.
Interview Questions and Answers
How would you design a WebSocket ordering and loss test?
I would add a monotonic sequence and stable event ID to each logical stream, collect arrivals without sorting, and compare them with a known range. Separate assertions would identify gaps, duplicate IDs, and backward movement. I would then repeat the check across an intentional disconnect and reconnect.
What delivery guarantee does WebSocket provide?
Within one live connection, WebSocket frames are delivered in order through TCP. The protocol does not by itself persist messages for a disconnected consumer or coordinate ordering among separate producers and connections. Application-level acknowledgments, cursors, and replay rules cover those concerns.
Why must a loss test know the expected end of a stream?
A gap between two observed IDs is visible, but a missing tail is not. If the test receives 10 through 14, it needs evidence that 15 was planned before declaring truncation. I get that boundary from controlled fixture data, a snapshot response, or a terminal control message.
How do you distinguish replay from an invalid duplicate?
I start with the documented delivery semantics and reconnect cursor. For allowed replay, I expect overlap at the boundary and assert idempotent business state. A repeated event outside that window, an ID reused for different content, or a second side effect is a defect.
What evidence would you attach to a failed WebSocket sequence test?
I would attach a bounded transcript containing stream ID, connection index, close code, expected range, received sequences, and the first anomalies. Payload bodies and credentials would be redacted. That evidence shows whether the break occurred during publishing, transport recovery, deduplication, or state application.
How do asynchronous handlers affect WebSocket ordering?
The message callbacks can begin in order while their awaited work completes out of order. I instrument receipt and commit separately, then test slower earlier events against faster later ones. Per-entity queues or rejecting stale sequence values usually preserves correct state without blocking unrelated streams.
Where should WebSocket sequence tests run in CI?
Deterministic local fixture tests belong in pull-request CI because they are fast and isolate protocol behavior. A small environment smoke suite should cover authentication, TLS, proxies, and the real broker. Sustained rate and backpressure checks run separately with controlled infrastructure and trendable metrics.
Frequently Asked Questions
Does WebSocket lose messages?
A healthy WebSocket connection provides ordered, reliable frame transport over TCP, but an application can still miss logical messages around disconnects, resume errors, queue limits, filtering, or publisher defects. Detect those failures with server sequence IDs and an explicit expected range.
How do I test WebSocket message order?
Capture messages in their original arrival order and compare a monotonic sequence field without sorting. Report every position where the current sequence is lower than the previous one, while checking gaps and duplicates independently.
How can I detect the last missing WebSocket message?
Supply an expected final sequence from a snapshot boundary, test fixture, terminal summary, or queryable server cursor. Observed IDs alone cannot reveal that an unseen higher ID should have arrived.
Are duplicate messages always a WebSocket bug?
No. At-least-once systems may replay an acknowledged boundary after reconnection, making duplicates part of the delivery contract. Consumers should use immutable event IDs for idempotency while tests verify that replay causes no repeated business effect.
Should WebSocket ordering tests use fixed sleeps?
Avoid them. Resolve collection from an expected count, a close event, or a protocol completion marker, and retain a bounded timeout only as a failure guard. A sleep can inspect a partial buffer on a slow runner.
Can multiple WebSocket topics share one sequence?
Only if the protocol intentionally defines one global order. Most systems should scope sequences by stream, room, tenant, or entity so independent publishers do not create false regressions in the test.
How many messages should an ordering test send?
Use small scripts for specific gap, duplicate, and reversal cases, then add a deterministic burst large enough to exercise collection and diagnostics. Treat high-volume capacity as a separate load-testing problem with a declared environment.