Fragmented WebSocket frames and max payload #
A customer uploads a large document through your real-time editor and the connection drops with close code 1009 Message Too Big. Another client sends a 50 MB snapshot and the server’s memory spikes by 150 MB while it assembles it. A third, custom client splits messages into frames in a way one server accepts and another rejects. All three are about the same two protocol features: fragmentation, which lets one WebSocket message span many frames, and payload limits, which servers must impose to avoid being handed arbitrarily large messages. Getting them right means choosing a limit on purpose, understanding what happens when it is exceeded, and moving genuinely large data out of single messages.
Root cause #
RFC 6455 separates messages (what the application sends and receives) from frames (what goes on the wire). A message can be sent as a single frame with the FIN bit set, or as a first frame with an opcode (text or binary) and FIN=0, followed by continuation frames (opcode 0), the last of which sets FIN=1. Control frames — ping, pong, close — may be interleaved between fragments, but data frames of different messages may not. The receiver reassembles fragments into one message before the application sees it.
That reassembly is where the danger lies. The browser and most server libraries deliver only complete messages, so the receiver must buffer every fragment until the last one arrives. Frame headers can declare payload lengths up to 2^63 bytes, and a message can have unlimited fragments. Without a limit, a single client can make the server allocate as much memory as it likes. So servers enforce a maximum message size: ws defaults maxPayload to 100 MiB, and when a frame or reassembled message would exceed it, the server closes the connection with 1009.
Resolution #
Set maxPayload from your protocol, not from the library default. The largest legitimate message — usually a snapshot — plus a margin is the right limit; everything above it is either a bug or abuse. Then, for data that legitimately exceeds a sensible per-message size, split it into application-level chunks with their own framing, so neither side has to hold a giant message in memory and progress can be reported and resumed.
import { WebSocketServer, WebSocket } from 'ws';
const MAX_MESSAGE_BYTES = 1 * 1024 * 1024; // largest legit message (snapshot) + margin
const CHUNK_BYTES = 256 * 1024; // application chunk size for large transfers
const MAX_TRANSFER_BYTES = 200 * 1024 * 1024;
// ws closes with 1009 when a frame or reassembled message exceeds maxPayload.
const wss = new WebSocketServer({ port: 8080, maxPayload: MAX_MESSAGE_BYTES });
// --- Sender: split a large binary payload into ordered chunks -----------------
export async function sendLarge(ws: WebSocket, transferId: string, data: Uint8Array) {
const total = Math.ceil(data.byteLength / CHUNK_BYTES);
ws.send(JSON.stringify({ type: 'transfer.begin', transferId, bytes: data.byteLength, chunks: total }));
for (let i = 0; i < total; i++) {
// Respect backpressure: wait while the socket's buffer is large.
while (ws.bufferedAmount > 4 * CHUNK_BYTES) await new Promise((r) => setTimeout(r, 20));
const header = new TextEncoder().encode(`${transferId}:${i}\n`); // tiny text header
const chunk = data.subarray(i * CHUNK_BYTES, (i + 1) * CHUNK_BYTES);
const frame = new Uint8Array(header.length + chunk.length);
frame.set(header); frame.set(chunk, header.length);
ws.send(frame); // each chunk ≤ maxPayload
}
ws.send(JSON.stringify({ type: 'transfer.end', transferId }));
}
// --- Receiver: assemble with a declared, bounded size ------------------------
const transfers = new Map<string, { buf: Uint8Array; received: number; chunks: number }>();
export function onTransferBegin(m: { transferId: string; bytes: number; chunks: number }) {
if (m.bytes > MAX_TRANSFER_BYTES) throw new Error('transfer too large'); // reject before allocating
transfers.set(m.transferId, { buf: new Uint8Array(m.bytes), received: 0, chunks: m.chunks });
}
export function onChunk(frame: Uint8Array) {
const nl = frame.indexOf(10); // '\n' ends the header
const [id, idx] = new TextDecoder().decode(frame.subarray(0, nl)).split(':');
const t = transfers.get(id);
if (!t) return;
t.buf.set(frame.subarray(nl + 1), Number(idx) * CHUNK_BYTES); // write in place; order-independent
t.received += 1;
}
The receiver allocates the declared size once and writes chunks into place, so peak memory is the transfer size rather than a multiple of it, and a transfer whose declared size exceeds the limit is refused before any bytes are buffered. For files, a better answer is often not to use the WebSocket at all: upload over HTTP (with resumable uploads) and send only a notification over the socket.
Clients need to know the limit. Browsers apply their own maximum message sizes (large, but finite), and a 1009 close is not retryable — the same message will fail again. Treat it as a permanent error for that message, as in WebSocket error frames and error codes, rather than reconnecting and resending in a loop.
Edge cases #
Proxies may refragment. Intermediaries are allowed to split or merge frames as long as message boundaries are preserved, so do not design a protocol that depends on frame boundaries — only message boundaries are guaranteed.
Compressed size vs decompressed size. With permessage-deflate, a small compressed frame can inflate into a huge message (a “zip bomb”). ws checks maxPayload against the decompressed size, which is what you want; confirm that any other server library you use does the same.
Fragmented control frames are illegal. Ping, pong and close must fit in a single frame of at most 125 bytes of payload. A peer that sends a fragmented or oversized control frame is violating the protocol, and the connection should be closed with 1002.
Verification #
Test the limit and the close code explicitly:
import WebSocket from 'ws';
const ws = new WebSocket('ws://localhost:8080');
ws.on('open', () => ws.send(Buffer.alloc(2 * 1024 * 1024))); // 2 MiB > 1 MiB limit
ws.on('close', (code, reason) => console.log(code, String(reason))); // expect: 1009
Then watch server memory while a client sends messages just under the limit in a loop, to confirm the worst case is what you expect. In production, count 1009 closes: a steady trickle usually means a client feature produces messages larger than the protocol intended, which should be fixed at the source rather than by raising the limit.
Operational checklist #
FAQ #
What does WebSocket close code 1009 mean? #
1009 Message Too Big: the receiver refused a message larger than it is willing to process. With the ws library, it means a frame or reassembled message exceeded maxPayload.
What is the maximum WebSocket message size? #
The protocol allows messages up to 2^63 bytes, but every implementation imposes a limit. ws defaults to 100 MiB; browsers and proxies have their own limits. Set your server’s limit to what your protocol actually needs.
Can I rely on how my message is split into frames? #
No. Senders, libraries and proxies may fragment or coalesce frames freely as long as message boundaries are kept. Design your protocol around messages, and chunk explicitly at the application level if you need pieces.
Should I raise maxPayload when users hit 1009? #
Only if the rejected messages are legitimate and bounded. Otherwise, fix the feature to send smaller messages or chunk them — raising the limit increases every connection’s worst-case memory footprint.
Related #
- Protocol Buffers over WebSockets — smaller messages mean fewer oversize problems.
- Permessage-Deflate Compression Trade-Offs — decompressed size and memory.
- Handling WebSocket bufferedAmount Backpressure — pacing large sends.
- Inspecting WebSocket Frames in DevTools and Wireshark — seeing fragments on the wire.
Back to Message Framing & Serialization.