エラーメッセージ
ターミナルには、おそらく次のようなスタックトレースが表示されているはずです。
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
at new NodeError (node:internal/errors:371:5)
at ServerResponse.setHeader (node:_http_outgoing:576:11)
at ServerResponse.header (/project/node_modules/express/lib/response.js:771:10)
at ServerResponse.send (/project/node_modules/express/lib/response.js:170:12)
根本的な原因:1つのリクエストに1つのレスポンス
HTTPリクエストは、使い捨てのチケットのようなものだと考えてください。一度レスポンスを「購入」するために使用すると、それは消えてしまいます。HTTP/1.1プロトコルでは、リクエストとレスポンスの関係は厳密に1対1です。res.send()、res.json()、またはres.end()を呼び出した瞬間、Expressはヘッダーをロックし、ブラウザに送信します。
重要なのは、レスポンスメソッドを呼び出しても関数が終了するわけではないということです。JavaScriptは、res.send()の後でも、returnに到達するかブロックの最後に達するまで、すべてのコード行を問題なく実行し続けます。残りのコードが別のレスポンスを送信しようとすると、Node.jsはプロトコル違反を防ぐためにこのエラーをスローします。
よくあるシナリオと解決策
1. 「returnの書き忘れ」という罠
これは間違いなく最も一般的なミスです。開発者は、res.send()がreturn文ではないことを忘れがちです。バリデーションロジックが失敗した場合、コードはエラーを送信しても、そのまま成功時のロジックへと進んでしまうことがあります。
バグのあるコード:
app.post('/login', (req, res) => {
const { username } = req.body;
if (!username) {
res.status(400).json({ error: 'Missing username' });
// 関数は実行され続けます!
}
// ヘッダーは既に上記で送信されているため、ここでクラッシュが発生します
res.status(200).json({ message: 'Welcome!' });
});
解決策:
レスポンスの呼び出しには常にreturnを付けてください。これは、デバッグの時間を大幅に節約できる簡単な習慣です。
app.post('/login', (req, res) => {
const { username } = req.body;
if (!username) {
return res.status(400).json({ error: 'Username is required' });
}
return res.status(200).json({ message: 'Success' });
});
2. ループ内に隠れたレスポンス
リストを反復処理し、ループ内でレスポンスを送信するのは、災難の元です。最初のアイテムは正常に送信されますが、2番目のアイテムでプロセスが即座にクラッシュします。
バグのあるコード:
app.get('/search', (req, res) => {
const items = [1, 2, 3];
items.forEach(item => {
if (item === 2) {
res.send('Found it!'); // アイテム2では動作しますが、アイテム3はどうなるでしょうか?
}
});
});
解決策:
resオブジェクトを操作する前に、ロジックを完全に処理してください。まずデータを見つけ、それから一度だけ送信します。
app.get('/search', (req, res) => {
const items = [1, 2, 3];
const match = items.find(i => i === 2);
if (match) {
return res.send('Found it!');
}
return res.status(404).send('Not Found');
});
3. 非同期コールの重複
データベースクエリやAPI呼び出しでは、コールバックがよく使われます。エラーハンドリングで実行を停止しないと、誤ってエラーレスポンスと成功レスポンスの両方をトリガーしてしまう可能性があります。
バグのあるコード:
app.get('/profile', (req, res) => {
User.findById(id, (err, user) => {
if (err) {
res.status(500).send('Database failure');
// ここにreturnがないため、コードは進み続けます...
}
res.json(user); // errが存在した場合、ここでクラッシュします!
});
});
解決策:
async/awaitを使用してコードを最新化しましょう。これにより、実行パスが読みやすくなり、try/catchブロックでの制御が非常に容易になります。
app.get('/profile', async (req, res) => {
try {
const user = await User.findById(id);
return res.json(user);
} catch (err) {
return res.status(500).send('Database failure');
}
});
ステップバイステップのデバッグ戦略
- 二重呼び出しを特定する: スタックトレースを確認してください。通常、成功した最初の呼び出しではなく、失敗した2番目の
res.send()呼び出しを指し示しています。 - returnを監査する: ファイル内で
res.を検索し、ロジックのすべての分岐がreturnまたはelseで終わっていることを確認してください。 - ミドルウェアを確認する: カスタムミドルウェアがレスポンスを送信した後に
next()を呼び出していないか確認してください。これは「ゴースト」クラッシュの一般的な原因です。 - ログを監視する:
morganのようなツールを使用して、クラッシュが発生する前にどのレスポンスが送信されているかを正確に確認してください。
クリーンなコードのためのプロのヒント
- 「Return res」パターンを採用する:
return res.json(...)をデフォルトの構文にしましょう。その方が安全で明確です。 - Use ESLint:
consistent-returnルールを有効にしてください。returnパスを書き忘れた可能性がある関数をハイライトしてくれます。 - ガードレール: 非常に複雑なロジックでは、
if (res.headersSent) return;をチェックできます。ただし、これは最終手段と考えてください。通常、これは関数が多くのことをやりすぎており、リファクタリングが必要な兆候です。

