How to check websocket message order

WebSocket delivers frames over one connection in the order they were sent, and 500 numbered frames came back with zero inversions. Reordering that you observe is your own code: an async handler returned five messages reversed, while the server log shows it received them in order.

Why check this

Run this the first time a bug report says messages arrive out of sequence, and again whenever a client starts opening a second socket or a handler gains an await. Both changes break an assumption the team never wrote down.

The failure it prevents is a week spent on the wrong layer. A chat that shows replies above questions, a board that applies a move before the move that set it up, a feed that jumps backwards: all three get filed against the transport, and the transport is the one part that is guaranteed here. RFC 6455 requires message fragments to be delivered in the order the sender sent them, and a WebSocket runs over a single TCP connection. The check below separates that guarantee from the code around it, so the search starts where the defect is.

Prerequisites

Save this as echo-server.mjs. It is the control: a handler with nothing asynchronous in it.

// node echo-server.mjs   listens on ws://127.0.0.1:49317
import { WebSocketServer } from 'ws';

const PORT = 49317;
const MAX_PAYLOAD = 8192;

const wss = new WebSocketServer({ port: PORT, maxPayload: MAX_PAYLOAD });
let open = 0;

wss.on('connection', (ws) => {
  open += 1;
  ws.on('message', (data, isBinary) => ws.send(data, { binary: isBinary }));
  ws.on('error', (err) => console.log(`server socket error: ${err.message}`));
  ws.on('close', (code) => {
    open -= 1;
    console.log(`closed ${code}, open ${open}`);
  });
});

wss.on('listening', () =>
  console.log(`echo listening on ${PORT}, maxPayload ${MAX_PAYLOAD} bytes`));

Save this as order.mjs. It numbers every frame and counts pairs that arrive smaller than their predecessor.

// node order.mjs <count>
import WebSocket from 'ws';

const N = Number(process.argv[2] ?? 5);
const ws = new WebSocket('ws://127.0.0.1:49317');
const got = [];

ws.on('open', () => {
  for (let i = 1; i <= N; i += 1) ws.send(`m${i}`);
});
ws.on('message', (d) => {
  got.push(d.toString());
  if (got.length < N) return;
  const seq = got.map((s) => Number(s.slice(1)));
  const inversions = seq.filter((v, i) => i > 0 && v < seq[i - 1]).length;
  if (N <= 10) console.log(`received: ${got.join(' ')}`);
  console.log(`frames ${seq.length}, first ${got[0]}, last ${got[got.length - 1]}, out-of-order pairs ${inversions}`);
  ws.close(1000);
});

Save this as async-server.mjs. Its handler does what a real handler does, which is wait for something.

// node async-server.mjs   listens on ws://127.0.0.1:49319
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 49319 });

wss.on('connection', (ws) => {
  ws.on('message', async (data) => {
    const text = data.toString();
    console.log(`received ${text}`);
    const n = Number(text.slice(1));
    await new Promise((r) => setTimeout(r, (6 - n) * 20)); // a lookup that varies per message
    ws.send(text);
  });
  ws.on('error', (e) => console.log(`server socket error: ${e.message}`));
});
wss.on('listening', () => console.log('async handler echo on 49319'));

Steps

  1. Step 1.

    Start the control server.

    node echo-server.mjs > server.log 2>&1 &
    
    echo listening on 49317, maxPayload 8192 bytes
  2. Step 2.

    Send five numbered frames back to back on one connection.

    node order.mjs 5
    
    received: m1 m2 m3 m4 m5
    frames 5, first m1, last m5, out-of-order pairs 0
  3. Step 3.

    Repeat at a size where a coincidence is no longer credible.

    node order.mjs 500
    
    frames 500, first m1, last m500, out-of-order pairs 0

    Five hundred frames, no inverted pair. This is the baseline the rest of the check is measured against: the transport did not reorder anything.

  4. Step 4.

    Start the server whose handler awaits, and send the same five frames to it.

    // node order-async.mjs
    import WebSocket from 'ws';
    
    const ws = new WebSocket('ws://127.0.0.1:49319');
    const got = [];
    
    ws.on('open', () => {
      for (let i = 1; i <= 5; i += 1) ws.send(`m${i}`);
    });
    ws.on('message', (d) => {
      got.push(d.toString());
      if (got.length < 5) return;
      console.log(`client received: ${got.join(' ')}`);
      ws.close(1000);
    });
    
    node async-server.mjs > async.log 2>&1 & sleep 3; node order-async.mjs && sleep 1 && cat async.log
    
    client received: m5 m4 m3 m2 m1
    async handler echo on 49319
    received m1
    received m2
    received m3
    received m4
    received m5

    Read the two halves against each other. The server received m1 through m5 in order, exactly as in step 2. The client got them back reversed. Nothing on the wire changed: the handler started five overlapping pieces of work and each one replied when it finished.

  5. Step 5.

    Send one message on each of two connections and watch what the ordering rule covers.

    // node two-conn.mjs
    import WebSocket from 'ws';
    
    const a = new WebSocket('ws://127.0.0.1:49319');
    const b = new WebSocket('ws://127.0.0.1:49319');
    const order = [];
    let ready = 0;
    
    function go() {
      ready += 1;
      if (ready < 2) return;
      a.send('m1'); // sent first, handled slowly
      b.send('m5'); // sent second, handled quickly
    }
    a.on('open', go);
    b.on('open', go);
    
    function note(label) {
      return (d) => {
        order.push(`${label}:${d.toString()}`);
        if (order.length < 2) return;
        console.log(`sent order:     A:m1 B:m5`);
        console.log(`received order: ${order.join(' ')}`);
        a.close(1000);
        b.close(1000);
      };
    }
    a.on('message', note('A'));
    b.on('message', note('B'));
    
    sent order:     A:m1 B:m5
    received order: B:m5 A:m1

    The guarantee is per connection. Two sockets are two orderings, and the application has no sequence across them unless it puts one in the messages.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | out-of-order pairs 0 over 500 frames | The transport preserved order | Stop looking at the socket. The defect is above it. | | Server log in order, client output reversed | The handler is asynchronous and unserialised | Await the previous reply, or carry a sequence number and sort on the client. | | Out-of-order pairs on one connection with a synchronous handler | Something between you is not a plain WebSocket | Look for a proxy, a message broker or a fan-out layer that merges publishers. | | Reordering that disappears when the client opens one socket | The second connection was the cause | Reuse one connection, or make ordering explicit in the payload. | | Order correct under a slow run, wrong under load | Concurrency in the handler, exposed by timing | Reproduce with a deliberate delay, as in step 4, rather than with load. |

Common mistakes

Sign: Out-of-order messages are filed as a WebSocket defect.Cause: RFC 6455 section 5.4 requires fragments to be delivered in the order they were sent, and the connection is a single TCP stream. Five hundred numbered frames came back with zero inversions here. A reorder that survives that baseline is in the application, the proxy or the broker.
Sign: The server logs arrivals in order and the client still sees them shuffled.Cause: An async message handler starts work per frame and replies when that work finishes. In step 4 a per-message delay reversed five replies with no change to the wire. Ordering is a property of the reply path, not only of the receive path.
Sign: Ordering is correct in development and wrong in production.Cause: A handler whose awaited work takes the same time for every message keeps its input order by accident. Vary the delay per message, as async-server.mjs does, and the defect appears on the first run instead of once a cache misses in production.
Sign: A reconnect or a second tab breaks the sequence.Cause: The order guarantee ends with the connection. A new socket has its own ordering, and messages queued during a reconnect can arrive after messages produced later. Put a sequence number in the message when order matters across connections.

What to check next

FAQ

Does WebSocket guarantee message order?

Within one connection, yes. RFC 6455 section 5.4 requires fragments to be delivered in the order sent, and the frames travel over one TCP stream. In the run above 500 numbered frames returned with zero inverted pairs.

Why do my messages arrive out of order then?

Most often an asynchronous handler. Step 4 reproduces it: the server received m1 through m5 in order and the client received m5 through m1, because each reply was sent when its own work finished.

Does the guarantee hold across two connections?

No. Two sockets are two independent orderings. In step 5 the message sent first on connection A arrived after the message sent second on connection B. Carry a sequence number if you need order across sockets.

How many messages should I test with?

Enough that an accidental pass is unlikely. Five frames prove little, 500 with zero inversions is a baseline, and the interesting run is the one with a varying delay in the handler.

Can a proxy reorder WebSocket frames?

A conforming proxy relays the stream and cannot reorder it. A message broker or a fan-out service in the middle can, because it is re-emitting messages rather than forwarding bytes. Test through the same path production uses.

Verified

Verified by Maks VernyNode 22.23.2ws 8.21.3

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.

intermediate9 minpublished updated Maks Verny