Node.js ストリームにおける「Error: write after end」の解決方法

intermediate💚 Node.js2026-04-03| Node.js (writableEnded の利用には v12.9.0 以上を推奨)。OS 環境を問わず、ストリーム処理や HTTP 操作中に発生します。

Error Message

Error: write after end
#Node.js#ストリーム#Express.js#バックエンド#エラーハンドリング

30秒でわかる解決策

このエラーは、すでに閉じられたストリームに対してデータを送信しようとしていることを Node.js が知らせるものです。これは、郵便局が夜間の営業を終了してドアを閉めた後に、手紙を投函しようとするようなものです。通常、このエラーは .end() が途中で呼び出されたか、複数回呼び出されたことによって発生します。即座にクラッシュを止めるには、書き込み前にストリームの状態を確認してください。

if (!myStream.writableEnded) {
  myStream.write(data);
}

もし .pipe() を使用している場合は、出力先で手動で .end() を呼び出すのを止めてください。パイプの仕組みがその遷移を自動的に処理します。

なぜこのエラーが発生するのか

Node.js のすべての Writableストリーム は、厳格なライフサイクルに従います。.end() を呼び出してこれ以上データが送られないことを合図すると、ストリームは完了状態に入ります。この時点以降に .write() を使用したり、再度 .end() を呼び出そうとすると、例外がスローされます。1秒間に1,000リクエストを処理するような高並行性の環境では、これらのエラーはしばしば非同期コードのロジックの不備を示しています。

問題が発生しやすいケース:

  • Express での二重レスポンス: res.json() を実行した後に、同じ関数内の後続の処理で別の res.send() が実行されてしまう場合。
  • 非同期のレースコンディション: クライアントがすでにタイムアウトしたかリクエストが閉じられた後に、データベースクエリや外部 API コールが結果を返してきた場合。
  • 手動によるパイプへの干渉: fs.createReadStream().pipe(dest) がまだデータを転送しようとしている最中に、手動でファイルストリームを閉じてしまった場合。
  • エラーハンドラーのループ: エラーが発生し、ハンドラーがストリームを閉じたにもかかわらず、元のロジックが現在の書き込み操作を完了させようとする場合。

実践的な修正方法

1. 書き込みを保護する

writableEnded プロパティを確認することが、最初の防御策となります。これは、挙動の予測しにくいサードパーティ製のストリームや複雑なイベントエミッターを扱う際に特に有効です。

function safelyWrite(stream, data) {
  // writableEnded は Node v12.9.0 で導入されました
  if (stream.writable && !stream.writableEnded) {
    stream.write(data);
  } else {
    console.warn("Prevented a write attempt to a closed stream");
  }
}

2. pipeline() への移行

従来の source.pipe(dest) は、チェーン内の1つのストリームが失敗しても残りのストリームが破棄されないため、メモリリークの原因になりがちです。stream.pipeline ユーティリティは、より安全な代替手段です。クリーンアップを自動で管理し、出力先が閉じられた場合には即座にソースを停止させます。

const { pipeline } = require('stream');
const fs = require('fs');
const zlib = require('zlib');

// エラーを適切に処理し、孤立した書き込みを防止します
pipeline(
  fs.createReadStream('large-archive.tar'),
  zlib.createGzip(),
  fs.createWriteStream('large-archive.tar.gz'),
  (err) => {
    if (err) {
      console.error('Pipeline failed:', err);
    } else {
      console.log('Compression complete');
    }
  }
);

3. Express の「return」ロジックを修正する

Express では、res.send()res.json() は自動的に res.end() を呼び出します。return キーワードを使用しないと、関数の残りの部分が実行され続け、2回目の書き込み試行につながります。

// 間違った方法
app.get('/user/:id', (req, res) => {
  if (!req.params.id) {
    res.status(400).send('ID required');
  }
  res.send('User Data'); // クラッシュ: IDがない場合に write after end が発生する
});

// 正しい方法
app.get('/user/:id', (req, res) => {
  if (!req.params.id) {
    return res.status(400).send('ID required'); // 「return」によって関数を終了させる
  }
  res.send('User Data');
});

4. 非同期コールバックの保護

データベースの結果を扱うときは、常にクライアントがまだ接続を待機しているか確認してください。クエリに5秒かかり、ユーザーが2秒後にブラウザをリロードした場合、データが到着したときにはストリームはすでに閉じられています。

db.users.find({ id }, (err, user) => {
  if (res.writableEnded) return; // ユーザーが離脱したため、データの送信を試行しない

  if (err) return res.status(500).send(err);
  res.json(user);
});

修正を確認する方法

修正したと思い込まず、以下のシナリオをテストしてください。

  • クライアントの中断をシミュレートする: curl などのツールを使用し、リクエストの途中で Ctrl+C を押して、サーバーが未処理の例外をログに出力しないか確認します。
  • 負荷テスト: autocannon -c 100 -d 10 http://localhost:3000/data を実行します。高負荷環境では、ローカルの開発環境では現れないレースコンディションがしばしば露呈します。
  • ストリームの状態を確認する: console.log('Finished:', stream.writableFinished) を使用して、ストリームのライフサイクルがどこで終了しているかを正確に追跡します。

リソース

Related Error Notes