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.
