Parsing WebSocket messages in a Web Worker #

Your trading view receives 5 MB of market data a minute. The Performance panel shows long tasks every few hundred milliseconds, and most of each task is not rendering at all: it is JSON.parse on large frames, decoding binary order books, and aggregating ticks into candles. Clicks wait behind that work, scroll stutters, and Interaction to Next Paint regresses. The main thread is the only thread that can touch the DOM, so every millisecond it spends decoding is a millisecond it cannot respond to input. Moving the socket and everything that is not rendering into a dedicated Web Worker leaves the main thread with one job — applying ready-to-render batches.

Root cause #

JavaScript on a page runs on one main thread shared with style, layout, paint and input handling. WebSocket message handlers run there by default, and so does whatever they call. Parsing is surprisingly expensive: JSON.parse on a 500 KB snapshot takes several milliseconds on a mid-range phone, binary decoding with a schema library can take longer, and aggregations over thousands of items per second add up. Each long task — anything over 50 ms — blocks input for its whole duration.

Throttling rendering, as in batching WebSocket updates with requestAnimationFrame, caps rendering work but not decoding work: every byte still has to be parsed. The only way to take that cost off the main thread is to do it on another thread.

Splitting the work between threads The worker owns the WebSocket, decodes frames and aggregates data, then posts one batch per frame across the thread boundary to the main thread, which only applies the batch and renders. Splitting the work between threads Worker: WebSocket owns the connection; receives frames off the main thread worker Worker: decode JSON.parse, MessagePack, Protobuf worker Worker: aggregate + coalesce candles, latest-per-key, bounded lists worker postMessage: one batch per frame structured clone or transferable ArrayBuffer boundary Main: apply + render one store update per batch, DOM work only main The boundary crossing has a cost too, so send few, compact batches
Everything above the boundary is work the main thread no longer does.

Resolution #

Open the WebSocket inside the worker, decode and aggregate there, and post the main thread one compact batch per animation frame. The main thread asks for batches with a “ready” message each frame, which doubles as backpressure: if the main thread falls behind, the worker keeps coalescing instead of queuing messages the main thread cannot keep up with.

// feed.worker.ts
type Tick = { s: string; p: number; q: number; t: number };
const CANDLE_MS = 60_000;

let ws: WebSocket | null = null;
const latest = new Map<string, number>(); // last price per symbol
const candles = new Map<string, { o: number; h: number; l: number; c: number; v: number; start: number }>();
let dirty = false;
let mainReady = true; // main thread asked for the next batch

function ingest(t: Tick) {
latest.set(t.s, t.p);
const start = Math.floor(t.t / CANDLE_MS) * CANDLE_MS;
const c = candles.get(t.s);
if (!c || c.start !== start) candles.set(t.s, { o: t.p, h: t.p, l: t.p, c: t.p, v: t.q, start });
else { c.h = Math.max(c.h, t.p); c.l = Math.min(c.l, t.p); c.c = t.p; c.v += t.q; }
dirty = true;
maybePost();
}

function maybePost() {
if (!dirty || !mainReady) return; // coalesce until the main thread is ready
dirty = false;
mainReady = false;
// Compact, render-ready payload: plain arrays clone quickly.
postMessage({ type: 'batch', prices: [...latest], candles: [...candles] });
}

self.onmessage = (e: MessageEvent) => {
const msg = e.data;
if (msg.type === 'connect') {
ws = new WebSocket(msg.url);
ws.binaryType = 'arraybuffer';
ws.onmessage = (ev) => {
// Heavy work happens here, off the main thread.
const ticks: Tick[] = typeof ev.data === 'string' ? JSON.parse(ev.data) : decodeBinary(ev.data);
for (const t of ticks) ingest(t);
};
ws.onclose = (ev) => postMessage({ type: 'status', status: 'closed', code: ev.code });
ws.onopen = () => postMessage({ type: 'status', status: 'open' });
} else if (msg.type === 'ready') {
mainReady = true; // main thread applied the previous batch
maybePost();
} else if (msg.type === 'send') {
ws?.send(msg.data); // outbound messages go through the worker too
} else if (msg.type === 'close') {
ws?.close(1000, 'page closing');
}
};

declare function decodeBinary(buf: ArrayBuffer): Tick[];
// main.ts — the main thread only renders
const worker = new Worker(new URL('./feed.worker.ts', import.meta.url), { type: 'module' });
worker.postMessage({ type: 'connect', url: 'wss://md.example.com/ticks' });

worker.onmessage = (e) => {
if (e.data.type !== 'batch') return;
requestAnimationFrame(() => {
renderPrices(e.data.prices); // one DOM/store update per batch
renderCandles(e.data.candles);
worker.postMessage({ type: 'ready' }); // pull the next batch only when this one is done
});
};
addEventListener('pagehide', () => worker.postMessage({ type: 'close' }));

declare function renderPrices(p: [string, number][]): void;
declare function renderCandles(c: [string, unknown][]): void;

The ready/batch handshake is the important design choice. A worker that simply posts every decoded message moves the queue from the socket to the worker’s message channel, and the main thread still drowns — now in structured-clone deserialization. Pulling one batch per frame keeps the main thread’s work bounded by its own pace, and the worker’s coalescing absorbs the difference. For very large binary payloads, transfer the ArrayBuffer (postMessage(msg, [buf])) instead of cloning it; for small structured batches, cloning plain arrays is fast enough.

To share one such connection between several tabs, the same code runs in a SharedWorker instead, which is the approach in sharing one WebSocket across browser tabs.

Where one 100 ms batch of ticks is processed Before, the main thread spends nine milliseconds decoding, seven aggregating and four rendering per batch; with a worker, the main thread spends only the four milliseconds of rendering while the worker does the decoding and aggregation. Where one 100 ms batch of ticks is processed illustrative per-batch cost, ms decode aggregate render 9 ms 7 ms 4 ms main thread, before 20 ms 4 ms main thread, worker 4 ms 9 ms 7 ms worker thread 16 ms
Total work is unchanged; what moves is which thread the user's input has to wait for.

Edge cases #

Worker support for WebSocket. WebSocket is available in dedicated and shared workers in all modern browsers. Cookies are sent according to the worker’s origin, so cookie-authenticated sockets behave as they do on the page; token-based URLs work unchanged.

Debuggability. Frames sent from a worker still appear in DevTools’ Network panel (under the worker’s context), and worker consoles are visible in the Sources panel’s thread list. Add a status channel back to the main thread so the UI knows about connects, closes and errors.

Fallback. Keep the decode and aggregate functions in plain modules that can also run on the main thread, and fall back to them if the worker fails to start (CSP restrictions, old embedded browsers).

Verification #

Record a Performance profile during peak traffic before and after. The main thread’s long tasks attributable to onmessage, JSON.parse and aggregation should disappear, replaced by short onmessage tasks that apply a batch. Use the Web Vitals library or the Performance panel’s Interactions track to confirm INP improves during bursts. Then check backpressure: throttle the CPU 6× in DevTools and confirm batches arrive less often but the page stays responsive, with no growing memory in the worker.

Pull-based batches keep the main thread in charge The worker decodes and folds ticks and sends a batch; while the main thread renders it, further ticks are folded but not sent; once the main thread signals ready, the worker sends the latest state. Pull-based batches keep the main thread in charge Server Worker Main thread ticks × 400 (decoded, folded) batch (prices + candles) ticks × 380 (folded, not sent) rAF: render batch ready next batch (latest state) A slow main thread receives fewer, fresher batches — never a backlog
The main thread sets the pace; the worker absorbs the excess.

Operational checklist #

FAQ #

Can a Web Worker open a WebSocket? #

Yes. WebSocket is available in dedicated workers, shared workers and service workers in modern browsers. A dedicated or shared worker is the right place for a long-lived connection; service workers are terminated when idle and are unsuitable.

Is postMessage to the main thread expensive? #

Structured cloning costs roughly in proportion to the data size, so posting every raw message can cost as much as parsing it. Post compact, coalesced batches — ideally one per frame — and transfer large buffers instead of cloning them.

When is a worker not worth it? #

When messages are small and infrequent, the extra complexity buys nothing; per-frame batching on the main thread is enough. Profile first: if onmessage work is not a visible share of long tasks, keep it simple.

Does this help on mobile? #

Especially on mobile, where CPUs are slower and a few milliseconds of parsing per message quickly becomes a long task. Workers run on another core, so the page stays responsive even when decoding is heavy.

Back to Real-Time Rendering Performance.