エラー
Error [ERR_STREAM_DESTROYED]: Cannot call write after a stream was destroyed
すでに閉じられたストリームに書き込もうとしました。クライアントがレスポンスの途中で切断したか、非同期のデータベース呼び出しがres.end()の実行後200ms遅れて返ってきた可能性があります。Node.jsは内部のdestroyedフラグを設定し、それ以降の書き込みをすべて拒否して、未処理の例外またはストリームのerrorイベントとしてこのエラーをスローします。
根本原因
Node.jsのストリームには厳密なライフサイクルがあります。stream.destroy()が呼び出されると——コードで明示的に、またはHTTPレスポンスの終了やソケットの切断時に自動的に——destroyedフラグがtrueに切り替わります。これは永続的な状態です。それ以降の.write()呼び出しはすべてこのエラーをスローします。
よくある原因:
res.end()の呼び出し後、またはクライアントの切断後にHTTPレスポンスへ書き込む- 非同期コールバック(データベースクエリ、外部API呼び出し)がストリームのクローズ後に解決される
- パイプされた読み取りストリームが、書き込み先の破棄後にデータをプッシュする
- 破棄済みのストリームオブジェクトを再利用する
修正方法1 — 書き込み前にdestroyedを確認する
最もシンプルな修正方法:書き込む前にフラグを確認します。
function safeWrite(stream, chunk) {
if (stream.destroyed) {
return; // silently skip — stream is gone
}
stream.write(chunk);
}
HTTPレスポンスの場合、確認すべきフラグが2つあります:
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はres.end()が呼び出された瞬間にtrueになります。res.destroyedは基盤となるソケットが失われた時点でtrueになります。どちらの場合も致命的ですので、両方を確認してください。
修正方法2 — closeイベントで非同期処理をキャンセルする
データベースから行をストリーミングするルートがありますか?5,000行のうち50行を受信した後にクライアントが切断すると、ループが書き込みを続けることでこのエラーが発生します。closeイベントでキャンセルフラグを設定し、早期に処理を中断します。
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);
});
});
修正方法3 — パイプラインでAbortControllerを使用する
ストリームをパイプしていますか?stream.pipeline()にAbortSignalを渡すことで、切断時にチェーン全体をクリーンに中断できます——不要な書き込みの試みが発生しません。
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()はエラーまたは中断時にチェーン内のすべてのストリームを破棄します。手動のクリーンアップは不要です。
修正方法4 — ストリームのerrorイベントをキャッチする
すべての書き込みパスを保護できない場合もあります。最低限、エラーハンドラをアタッチして、エラーがプロセス全体をクラッシュさせないようにします。
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);
});
これはセーフティネットです。エラーの発生を防ぐわけではなく、サーバーをダウンさせないようにするだけです。
修正方法5 — 書き込み中のストリームを破棄しない
ストリームのクローズタイミングを制御できる場合は、先にドレインさせてください。
// 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
クリーンなシャットダウンにはstream.end()を使用します。stream.destroy()は、大容量アップロードの失敗時など、フラッシュせずに強制クローズが必要なエラーパスのために残しておいてください。
確認方法
修正後、問題が解消されたことを確認するために失敗を再現します:
- サーバーを起動してストリーミングエンドポイントにアクセスします。レスポンスの途中で切断します——ブラウザでfetchをキャンセルするか、
curlを実行してストリーミング中にCtrl+Cを押します - ログを確認します。
ERR_STREAM_DESTROYEDが表示されないはずです - CIの場合、ストリームをパイプラインの途中で破棄し、未処理エラーが発生しないことを検証するテストを追加します:
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();
});
予防策
- 手動の書き込みループより
stream.pipeline()または.pipe()を優先する——後処理が自動化される - HTTPレスポンスの
closeイベントをリッスンして、クライアントの切断を早期に検知する - 非同期書き込みの前に必ず
stream.destroyedまたはres.writableEndedを確認する - まず
stream.end()を使用し、フラッシュなしの強制クローズが必要な場合のみstream.destroy()を使用する - Node.js 16以降では、キャンセル可能なパイプラインのために
AbortControllerをstream.pipeline()に接続する

