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.

One message, three frames A sender splits a binary message into a first frame and two continuation frames, with a ping and pong interleaved; the receiver reassembles them and delivers one 612-kilobyte message. One message, three frames Sender Receiver opcode=2 FIN=0 (256 KB) opcode=9 ping (allowed between) opcode=10 pong opcode=0 FIN=0 (256 KB) opcode=0 FIN=1 (100 KB) deliver one 612 KB message The application never sees fragments — only the reassembled message, which the receiver must buffer
Fragmentation is a wire detail; the memory cost of reassembly is not.

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.

Memory one connection can force the server to hold With the default hundred mebibyte limit one connection can force up to a hundred mebibytes of reassembly buffer; a one mebibyte limit caps it at one; chunked transfers cap it at a quarter mebibyte per message. Memory one connection can force the server to hold during reassembly of a single message worst single message (MiB) buffer per slow sender (MiB) 0 MiB 25 MiB 50 MiB 75 MiB 100 MiB maxPayload 100 MiB (default) maxPayload 1 MiB chunked 256 KiB
The default limit lets each connection claim 100 MiB; multiply by an attacker's connection count.

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.

Choosing where large data travels Normal messages under 64 kilobytes and snapshots up to a megabyte go as single messages with a suitable maxPayload; bulk data up to 200 megabytes goes as application-level chunks; files go over HTTP with a WebSocket notification. Choosing where large data travels Size Transport Why Normal messages < 64 KB single message no special handling Snapshots 64 KB – 1 MB single message set maxPayload for it Bulk data 1 – 200 MB app-level chunks bounded memory, progress Files any HTTP upload + notify resumable, cacheable The WebSocket is for real-time messages, not a general file transfer channel
Match the transport to the size, and keep the per-message limit small.

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.

Back to Message Framing & Serialization.