The Error
Error [ERR_STREAM_DESTROYED]: Cannot call write after a stream was destroyed
You tried to write to a stream that's already gone. Maybe a client disconnected mid-response. Maybe an async database call came back 200ms too late โ after res.end() had already fired. Node.js sets an internal destroyed flag and refuses any further writes, throwing this as an unhandled exception or an error event on the stream.
Root Cause
Streams in Node.js have a strict lifecycle. Once stream.destroy() is called โ explicitly by your code, or automatically when an HTTP response ends or a socket drops โ the destroyed flag flips to true. That's permanent. Any .write() after that point throws this error.
The usual suspects:
- Writing to an HTTP response after
res.end()or a client disconnect - An async callback (database query, external API call) resolving after the stream already closed
- A piped readable stream pushing data after the writable destination was destroyed
- Reusing a stream object after it was destroyed
Fix 1 โ Check destroyed Before Writing
The bluntest fix: check the flag before you write.
function safeWrite(stream, chunk) {
if (stream.destroyed) {
return; // silently skip โ stream is gone
}
stream.write(chunk);
}
For HTTP responses, two flags matter:
app.get('/data', async (req, res) => {
const data = await fetchSomething();
if (res.destroyed || res.writableEnded) {
return; // client disconnected or res.end() already called
}
res.json(data);
});
res.writableEnded becomes true the moment res.end() is called. res.destroyed becomes true once the underlying socket is gone. Both cases are fatal โ check both.
Fix 2 โ Handle the close Event to Cancel Async Work
Got a route that streams rows from a database? A client that disconnects after receiving 50 of 5,000 rows will trigger this error if your loop keeps writing. Set a cancellation flag on the close event and bail out early.
app.get('/stream-data', (req, res) => {
let cancelled = false;
res.on('close', () => {
cancelled = true; // client disconnected
});
async function streamRows() {
for await (const row of getDatabaseRows()) {
if (cancelled) break;
res.write(JSON.stringify(row) + '\n');
}
if (!cancelled) res.end();
}
streamRows().catch(err => {
if (!cancelled) res.destroy(err);
});
});
Fix 3 โ Use AbortController with Pipelines
Piping streams? Pass an AbortSignal to stream.pipeline() so a disconnect aborts the whole chain cleanly โ no dangling write attempts.
const { pipeline } = require('stream/promises');
// AbortController is built-in from Node.js 16+
const ac = new AbortController();
req.on('close', () => ac.abort()); // client disconnected โ abort
try {
await pipeline(sourceStream, transformStream, res, { signal: ac.signal });
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Pipeline failed:', err);
}
}
stream.pipeline() destroys every stream in the chain on error or abort. No manual cleanup needed.
Fix 4 โ Catch the Error Event on the Stream
Sometimes you can't guard every write path. At minimum, attach an error handler so the error doesn't crash your entire process.
const writable = getWritableStream();
writable.on('error', (err) => {
if (err.code === 'ERR_STREAM_DESTROYED') {
// expected โ stream closed before all data was written
return;
}
console.error('Unexpected stream error:', err);
});
This is a safety net. It won't stop the error from happening โ it just stops it from taking down your server.
Fix 5 โ Don't Destroy a Stream You're Still Writing To
If you control when the stream closes, let it drain first.
// Wrong โ destroys immediately, pending writes fail
stream.write(largeChunk);
stream.destroy();
// Correct โ end() flushes pending writes then closes
stream.write(largeChunk);
stream.end(); // waits for write to complete, then closes gracefully
Use stream.end() for a clean shutdown. Save stream.destroy() for error paths where you need to force-close without flushing โ like aborting a large upload gone wrong.
Verification
After your fix, simulate the failure to confirm it's gone:
- Start your server and hit a streaming endpoint. Disconnect mid-response โ cancel a fetch in the browser, or run
curland press Ctrl+C while it's streaming - Check your logs.
ERR_STREAM_DESTROYEDshould be absent - For CI, add a test that destroys the stream mid-pipeline and asserts no unhandled errors:
it('does not throw when stream is destroyed mid-write', (done) => {
const writable = new PassThrough();
writable.on('error', done); // fail test on unexpected error
writable.write('first chunk');
writable.destroy();
// safeWrite should swallow ERR_STREAM_DESTROYED silently
expect(() => safeWrite(writable, 'second chunk')).not.toThrow();
done();
});
Prevention
- Prefer
stream.pipeline()or.pipe()over manual write loops โ teardown is automatic - Listen for the
closeevent on HTTP responses to catch client disconnects early - Always check
stream.destroyedorres.writableEndedbefore any async write - Reach for
stream.end()first; only usestream.destroy()when you need a forced, unflushed close - On Node.js 16+, wire up
AbortControllertostream.pipeline()for cancellable pipelines

