loke.dev
Header image for Why Do Traditional WebSockets Still Trigger Memory Exhaustion Under High Load?

Why Do Traditional WebSockets Still Trigger Memory Exhaustion Under High Load?

A technical deep dive into why event-based WebSockets lack backpressure and how the new WebSocketStream API prevents browser tabs from crashing during high-frequency data bursts.

· 9 min read

You’ve probably been told that WebSockets are the most efficient way to stream data to a browser. We treat them as the gold standard for real-time performance, assuming that because they are "persistent" and "bidirectional," they are inherently scalable. This is a half-truth that leads directly to memory exhaustion and browser tab crashes the moment you move from a "Hello World" chat app to a high-frequency data feed.

The reality is that the traditional WebSocket API, as defined in the early 2010s, is architecturally incapable of handling backpressure. It is a "firehose" protocol wrapped in an event-driven interface that lacks any built-in mechanism to tell the sender to slow down. If your server sends 5,000 messages per second but your client-side JavaScript can only process 2,000, your browser doesn't just "drop" the extra messages. It buffers them in RAM until the tab process hits its limit and the "Aw, Snap!" screen appears.

The Event Loop is Your Bottleneck

To understand why WebSockets fail under load, we have to look at how the browser handles the onmessage event. In a standard implementation, you do something like this:

const socket = new WebSocket('wss://stream.example.com/market-data');

socket.onmessage = (event) => {
    // Imagine this takes 5ms to process (UI updates, calculations, etc.)
    processHighFrequencyData(event.data);
};

On the surface, this looks fine. But onmessage is an asynchronous event callback managed by the browser’s event loop. When a packet arrives over the wire, the browser’s networking layer picks it up, parses the WebSocket frame, and pushes a new task onto the event loop.

If the network layer receives data faster than the event loop can execute the processHighFrequencyData function, the task queue grows. Because the browser is committed to delivering every single message to your callback (it's a TCP-based protocol, after all), it must store those messages in memory while they wait for their turn on the execution thread.

There is no way for the onmessage handler to say, "Hey, stop sending for a second, I'm busy." The socket just keeps pumping data into the buffer.

The Illusion of bufferedAmount

Many developers point to the socket.bufferedAmount property as the solution. It tells you how many bytes of data have been queued using send(), but haven't yet been transmitted to the network.

// This only helps with OUTGOING data
if (socket.bufferedAmount === 0) {
    socket.send(hugePayload);
}

The fatal flaw? bufferedAmount is almost entirely useless for incoming data. There is no equivalent incomingBufferedAmount that lets you inspect how much data is sitting in the browser's internal receive buffer waiting to be dispatched to your onmessage handler. By the time your code knows there’s a problem, the memory is already allocated, and the event loop is already a thousand tasks behind.

Why "Throttling" Doesn't Save You

A common "fix" is to throttle the processing logic:

let lastProcessed = Date.now();
socket.onmessage = (event) => {
    const now = Date.now();
    if (now - lastProcessed > 100) { // Only process every 100ms
        renderUI(event.data);
        lastProcessed = now;
    }
    // The data is still received and the event is still fired!
};

This is a cosmetic fix. While renderUI might run less frequently, the onmessage event still fires for every single packet. The browser still has to allocate a MessageEvent object and a String or ArrayBuffer for the data. If the server is sending 10,000 messages a second, you’re still creating 10,000 objects a second. The garbage collector will eventually struggle to keep up, leading to "GC pressure" that stutters your UI before the memory even fills up.

The Backpressure Problem

In the world of streams, "backpressure" is the signal sent from a consumer back to a producer saying, "Stop sending data, I'm full."

In a standard TCP connection, backpressure is handled at the transport layer using a "receive window." If a process doesn't read data from the socket, the OS buffer fills up, the TCP window size drops to zero, and the sender's OS stops transmitting packets.

Traditional WebSockets break this chain. The browser *always* reads from the OS socket as fast as possible to turn those bytes into onmessage events. It effectively disconnects the JavaScript execution speed from the TCP flow control.

Enter the WebSocketStream API

To solve this, the W3C and the Chrome team introduced the WebSocketStream API. It treats a WebSocket not as a series of disconnected events, but as a standard WHATWG Stream.

The core difference is that a stream is "pull-based" or at least "pressure-aware." If you don't read from the stream, the browser stops reading from the underlying TCP socket. This allows the built-in TCP flow control to do its job: the server will actually be throttled by the network layer because the client isn't consuming the data.

Here is how the WebSocketStream API looks in practice:

async function connectToStream(url) {
    const wss = new WebSocketStream(url);

    try {
        const { readable, writable, extensions, protocol } = await wss.opened;
        const reader = readable.getReader();
        const writer = writable.getWriter();

        while (true) {
            const { value, done } = await reader.read();
            if (done) break;

            // This is the magic part: 
            // The loop waits for processData to finish before 
            // calling reader.read() again.
            await processData(value); 
            
            // While we are awaiting processData, the browser 
            // stops pulling data from the network. 
            // Backpressure is achieved!
        }
    } catch (err) {
        console.error("Connection failed", err);
    }
}

Why This Changes Everything

In the WebSocketStream example above, the await reader.read() call is the key. If processData(value) takes two seconds to complete (maybe it's doing heavy WebGL rendering or complex calculations), the loop pauses.

Because the loop is paused, reader.read() isn't called. Because reader.read() isn't called, the ReadableStream internal buffer reaches its "high water mark." Once that mark is reached, the browser stops reading bytes from the underlying OS-level TCP socket. The sender (the server) then notices the TCP window is full and stops sending packets.

No memory exhaustion. No task queue bloat. Just a natural slowdown of the data rate.

Handling Mixed Loads: The WritableStream

The API also gives us a writable side, which solves the bufferedAmount problem for outgoing data. Instead of manually checking bytes, you can pipe a stream directly to the socket.

const responseStream = new ReadableStream({ /* ... your data source ... */ });

// This automatically handles backpressure.
// If the network is slow, responseStream will pause.
await responseStream.pipeTo(wss.writable);

If you’ve ever tried to upload a massive file over a raw WebSocket while simultaneously keeping the UI responsive, you know how hard it is to coordinate. pipeTo handles all that internal state management for you.

Real-World Scenario: The Order Book Crash

I once worked on a crypto-trading platform where we used a traditional WebSocket to stream the "Order Book" (the list of all buy and sell orders). During normal market hours, it worked perfectly. But during a "flash crash," the volume of updates increased by 100x in seconds.

The browsers of our users started freezing. We looked at the heap snapshots and saw hundreds of megabytes of strings waiting in the event loop queue. We tried "batching" the updates on the server, but that just meant larger messages, not fewer.

If we had WebSocketStream back then, the browser would have simply slowed down the rate at which it accepted packets. The user might have seen slightly delayed data, but the tab wouldn't have crashed, and the UI would have remained interactive, allowing them to actually hit the "Sell" button.

Is It Ready for Production?

Now for the catch: Browser Support.

The WebSocketStream API is currently a specialized API. It has been available in Chrome (behind flags or in origin trials) for a while, but it hasn't seen universal adoption across Safari and Firefox yet.

However, the concept is so vital that you should be architecting your high-load applications with "stream-like" behavior in mind.

The Polyfill Strategy

You can't perfectly polyfill backpressure into the old WebSocket API because the onmessage event is fundamentally push-based. However, you can use a "pacing" mechanism where the client sends an "ACK" (Acknowledgement) message every X frames.

// A "Manual Backpressure" Pattern
let processedCount = 0;
const BATCH_SIZE = 100;

socket.onmessage = async (event) => {
    await processData(event.data);
    processedCount++;

    if (processedCount >= BATCH_SIZE) {
        socket.send(JSON.stringify({ type: 'ACK', count: BATCH_SIZE }));
        processedCount = 0;
    }
};

On the server side, you would need to pause sending if the number of un-ACKed messages exceeds a certain threshold. This is essentially reinventing TCP's sliding window at the application level. It's ugly, but it's the only way to prevent memory exhaustion in environments that don't support WebSocketStream.

Integrating with TransformStream

One of the coolest things about the move to a stream-based WebSocket is that it plays nicely with other Web Stream APIs. For example, you can decrypt or decompress data on the fly using a TransformStream.

const { readable, writable } = await wss.opened;

const decryptor = new TransformStream({
    transform(chunk, controller) {
        const decrypted = decrypt(chunk);
        controller.enqueue(decrypted);
    }
});

// Chain the socket into the decryptor, then read from the decryptor
const reader = readable.pipeThrough(decryptor).getReader();

This code is incredibly clean. It separates the networking (WebSocket), the logic (Decryption), and the consumption (the Reader) into distinct, composable pieces.

The Performance Cost of Convenience

It is worth noting that WebSocketStream isn't "faster" in terms of raw throughput. In fact, if your goal is to ingest data as fast as humanly possible without caring about UI responsiveness, the traditional onmessage might even seem faster because it never stops.

But performance isn't just about throughput; it's about predictability. A system that runs at 100mph and then crashes is less useful than a system that runs at 80mph consistently and stays up for a week.

The traditional WebSocket API gives you no control over the "buffer bloat" occurring in the browser's memory space. It’s a design that assumes the client will always be faster than the server—a dangerous assumption in the world of mobile devices and low-powered hardware.

Summary of the Differences

| Feature | Traditional WebSocket | WebSocketStream |
| :--- | :--- | :--- |
| Data Flow | Push-based (Events) | Pull-based (Streams) |
| Backpressure | None (Manual/Incomplete) | Built-in via TCP window |
| Interface | Event Listeners (onmessage) | ReadableStream / WritableStream |
| Memory Safety | Poor (Unbounded buffering) | Excellent (High water mark) |
| Composition | Difficult | Easy (using pipeTo, pipeThrough) |

Final Thoughts

We are moving away from the era of "just get it working" into an era of "make it robust." As web applications become more complex—handling real-time collaborative editing, high-frequency financial data, or multiplayer gaming—the limitations of 1st-generation web APIs become glaringly obvious.

Memory exhaustion via WebSockets is a silent killer because it often doesn't show up in your local development environment with a single connection and a local server. It shows up in production, under heavy load, on a user's 3-year-old Android phone.

If you are building a system that expects high-frequency data bursts, stop relying on the onmessage firehose. Investigate the WebSocketStream API, and if you can't use it yet, implement an application-level ACK protocol. Your users' RAM will thank you.