The Web Streams memory leak nobody warned you about

My staging cluster was screaming last Thursday. Three nodes, all OOM-killing their containers every twenty minutes. The memory graph looked like a hockey stick, and I spent four hours chasing ghost leaks in my database queries before realizing the culprit was a single, standard JavaScript method.

Actually, I should clarify — I was just trying to build a proxy API. The requirements were basic: take an incoming file upload, calculate a SHA-256 hash to validate the contents, and forward the raw bytes to an S3 bucket. You don’t want to buffer a 2GB video file in memory, so you stream it.

And since we’re living in a post-compatibility world where Node, Bun, Deno, and Cloudflare Workers all support the standard Web Streams API, I used what the MDN docs suggest. I grabbed the incoming ReadableStream and called tee().

That was a massive mistake.

The locking problem

If you’ve spent any time shipping APIs recently, you already know Web Streams enforce a strict lock. The moment you attach a reader to a stream, it’s locked. Nothing else can touch it.

This makes sense for preventing race conditions, but it probably sucks for real-world API development where you frequently need to do two things with one data source. You want to log it and parse it. Or hash it and store it.

The official spec gives us tee() to solve this. It splits the original stream into two identical branches. Here is the exact code that nuked my production environment:

async function handleUpload(req) {
  // Don't do this in production
  const [hashStream, uploadStream] = req.body.tee();
  
  // Start both operations concurrently
  const hashPromise = calculateHash(hashStream);
  const uploadPromise = uploadToS3(uploadStream);
  
  const [hash, uploadResult] = await Promise.all([
    hashPromise, 
    uploadPromise
  ]);
  
  return { hash, id: uploadResult.id };
}

Looks clean. It’s completely broken.

Why tee() blows up your RAM

And here is the dirty secret about tee() that the tutorials skip. When you split a stream, the JavaScript runtime has to keep both branches perfectly in sync. If one branch reads data faster than the other, the runtime has no choice but to buffer the unread chunks in memory until the slower branch catches up.

In my API, calculating a SHA-256 hash is incredibly fast. Uploading chunks to S3 over the network is slow. The hashing branch was tearing through the file at 500MB/s, while the S3 branch was chugging along at 30MB/s.

The result? Node buffered the difference. I watched my container’s memory usage jump from a stable 140MB right up to 4.1GB in about twelve seconds before crashing with Error: ENOMEM.

The workaround: Pass-through observers

I needed a way to spy on the chunks as they flew by, without taking ownership of the stream and without forcing the runtime to buffer anything. If the network upload slows down, the whole pipeline needs to exert backpressure and slow down the incoming request.

Instead of splitting the stream side-by-side, you have to chain it linearly. You create a custom TransformStream that does your side-effect (like hashing) and immediately passes the unmodified chunk to the next step.

Here is what actually works:

function createHashObserver() {
  const hash = crypto.createHash('sha256');
  
  return new TransformStream({
    transform(chunk, controller) {
      // 1. Do our side-effect (update the hash)
      hash.update(chunk);
      
      // 2. Pass the exact same chunk forward immediately
      controller.enqueue(chunk);
    },
    flush() {
      // Store the final hash somewhere accessible when done
      this.finalHash = hash.digest('hex');
    }
  });
}

// Usage in the API handler:
async function fixedUploadHandler(req) {
  const observer = createHashObserver();
  
  // Pipe the request THROUGH the observer, then to S3
  const uploadStream = req.body.pipeThrough(observer);
  
  // Now we only have ONE destination reading the stream
  const uploadResult = await uploadToS3(uploadStream);
  
  return { 
    hash: observer.finalHash, 
    id: uploadResult.id 
  };
}

I deployed this fix on Friday. Memory usage flatlined at 160MB regardless of whether someone uploaded a 5MB image or a 4GB database dump. The backpressure works perfectly. If S3 slows down, the TransformStream stops pulling chunks from the incoming request, which slows down the client’s upload speed. No buffering required.

But we need a better standard. Writing custom transform streams just to passively observe data is annoying boilerplate. The fact that the most obvious method in the API (tee) is fundamentally dangerous for large payloads is a massive footgun for API developers.

Just build the pipeline linearly. It’s uglier, but it won’t wake you up at 2 AM.

More From Author

Die autoren man sagt, sie seien arrogant darauf, Jedem das unvergleichliches Moglich-Spiel-Erlebnis im angebot

Efectos del Citrato de Enclomifeno: Una Guía Completa

Leave a Reply

Your email address will not be published. Required fields are marked *

Zeen Social