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.
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.
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.
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.
Related #
- Batching WebSocket Updates with requestAnimationFrame — per-frame application on the main thread.
- Sharing One WebSocket Across Browser Tabs — the SharedWorker variant.
- Protobuf over WebSockets — the binary decoding the worker can take on.
- Coalescing High-Frequency WebSocket Updates — reducing the load before it reaches the browser.
Back to Real-Time Rendering Performance.