Node.jsを用いたアプリケーション開発において、非同期処理の制御は避けて通れない極めて重要なテーマです。
特に、大量のデータに対してAPIリクエストやデータベース操作を行う際の「ループ処理」は、実装方法によってパフォーマンスや信頼性が劇的に変化します。
2026年現在のモダンな開発現場では、単に動作するコードを書くだけでなく、リソース効率を最大化しつつエラーに強い実装が求められています。
本記事では、Node.jsにおける非同期ループの正しい扱い方について、直列実行と並列実行の使い分けから、最新のベストプラクティスまでを詳しく紐解いていきます。
非同期ループで初心者が陥りやすい「forEach」の罠
JavaScriptの配列操作で馴染み深いArray.prototype.forEachですが、非同期処理と組み合わせる際には細心の注意が必要です。
多くの開発者が最初に直面する問題は、forEachの中でawaitを使用しても、ループ自体が待機してくれないという現象です。
これは、forEachが内部的にコールバック関数を順次呼び出すだけで、その戻り値であるPromiseの解決を待つ設計になっていないためです。
具体的にどのような挙動になるのか、以下のコード例で確認してみましょう。
const tasks = [1, 2, 3];
async function runTask(id) {
// 1秒待機する疑似的な非同期処理
await new Promise(resolve => setTimeout(resolve, 1000));
console.log(`タスク ${id} が完了しました`);
}
async function badImplementation() {
console.log('処理を開始します');
tasks.forEach(async (id) => {
await runTask(id);
});
console.log('すべての処理が(?)完了しました');
}
badImplementation();
処理を開始します
すべての処理が(?)完了しました
タスク 1 が完了しました
タスク 2 が完了しました
タスク 3 が完了しました
実行結果を見ると分かる通り、「すべての処理が完了しました」というログが、各タスクの完了よりも前に出力されています。
これは、forEachが非同期関数の終了を待たずに次の反復へ進み、さらにメインの関数もそのまま最後まで実行されてしまうためです。
データの整合性が求められる処理や、ループの終了後に次のステップへ進みたい場合には、この挙動は致命的なバグの原因となります。
なぜforEachは非同期に適さないのか
forEachの本質的な役割は、配列の各要素に対して特定の関数を適用することにあります。
仕様上、コールバック関数がPromiseを返したとしても、forEachはそのPromiseを無視して制御を継続します。
Node.jsのイベントループの仕組み上、コールバック内の非同期処理はマイクロタスクキューに積まれますが、forEach自体は同期的に実行を終えてしまうのです。
したがって、非同期的な反復処理を行う場合は、後述するfor...ofやPromise.allを適切に選択する必要があります。
直列実行:順番を保証する「for…of」ループ
一つひとつの処理を順番に実行し、前の処理が終わるのを待ってから次に進みたい場合は、for…of ループの使用が最も推奨されます。
for...ofは、ループ内でawaitを使用すると、そのPromiseが解決されるまでループの次の反復を一時停止します。
これは、データベースのトランザクション処理や、依存関係のあるAPIリクエストなど、実行順序が重要なシナリオで非常に有効です。
async function sequentialExecution() {
const items = ['A', 'B', 'C'];
console.log('直列処理を開始します');
for (const item of items) {
// 各アイテムの処理を順番に待機する
await new Promise(resolve => {
setTimeout(() => {
console.log(`${item} の処理が終了しました`);
resolve();
}, 1000);
});
}
console.log('すべての直列処理が完了しました');
}
sequentialExecution();
直列処理を開始します
A の処理が終了しました
B の処理が終了しました
C の処理が終了しました
すべての直列処理が完了しました
この方法であれば、開発者の意図通りに一つずつのタスクが順番に実行され、最後にまとめの処理を行うことができます。
コードの可読性も高く、デバッグが容易であるという点も大きなメリットです。
直列実行のメリットとデメリット
直列実行の最大のメリットは、「リソースの消費を最小限に抑えられる」という点です。
一度に一つの処理しか行わないため、メモリ使用量や同時接続数が制限されている環境では安定して動作します。
一方で、デメリットは処理時間が累積されることです。
例えば、1秒かかる処理が100個ある場合、直列実行では合計で100秒以上の時間が必要になります。
タスク間に依存関係がない場合は、この待ち時間は大きなボトルネックとなります。
並列実行:速度を最大化する「Promise.all」
各タスクが互いに独立しており、可能な限り短時間で処理を終えたい場合には、並列実行を選択します。
Node.jsで並列処理を実現する代表的な手法が、Promise.allです。
Promise.allは、複数のPromiseを含む配列を受け取り、それらすべてが解決されるのを待つ新しいPromiseを返します。
async function parallelExecution() {
const ids = [1, 2, 3, 4, 5];
console.log('並列処理を開始します');
// mapを使用してPromiseの配列を作成する
const promises = ids.map(async (id) => {
await new Promise(resolve => setTimeout(resolve, 1000));
console.log(`タスク ${id} が完了`);
return `結果-${id}`;
});
// すべてのPromiseを同時に実行し、完了を待つ
const results = await Promise.all(promises);
console.log('取得した結果:', results);
console.log('すべての並列処理が完了しました');
}
parallelExecution();
並列処理を開始します
タスク 1 が完了
タスク 2 が完了
タスク 3 が完了
タスク 4 が完了
タスク 5 が完了
取得した結果: [ '結果-1', '結果-2', '結果-3', '結果-4', '結果-5' ]
すべての並列処理が完了しました
Promise.allを使用すると、各タスクがほぼ同時に開始されるため、全体の処理時間は「最も重いタスクの実行時間」とほぼ等しくなります。
先ほどの例であれば、タスクが100個あっても、ネットワークやCPUのリソースが許す限り1秒強で終了させることが可能です。
Promise.all の注意点とエラーハンドリング
Promise.allには重要な特性があります。
それは、「渡されたPromiseのうち、一つでも拒否(reject)されると、全体が即座にエラーとして終了する」という点です。
一部のタスクが失敗しても他のタスクの結果を取得したい場合には、Promise.allSettledを使用するのが適切です。
Promise.allSettledは、すべてのPromiseが成功または失敗するまで待機し、個々の実行結果とステータスを返してくれます。
async function safeParallelExecution() {
const tasks = [
Promise.resolve('成功1'),
Promise.reject(new Error('失敗2')),
Promise.resolve('成功3')
];
const results = await Promise.allSettled(tasks);
results.forEach((result) => {
if (result.status === 'fulfilled') {
console.log('成果物:', result.value);
} else {
console.error('エラー理由:', result.reason.message);
}
});
}
パフォーマンスと負荷のバランス:同時実行数の制限
並列実行は強力ですが、無制限にタスクを投げるとシステムに過大な負荷がかかります。
例えば、数万件のデータを一気にPromise.allで処理しようとすると、メモリ不足(Out of Memory)や、ファイル記述子の枯渇、接続先サーバーへのDOS攻撃のような状態を引き起こしかねません。
実務においては、「一定の同時実行数(Concurrency Limit)を保ちながらループを回す」というアプローチが不可欠です。
ライブラリを活用した流量制御
同時実行数を制御するために、Node.jsコミュニティではp-limitのような軽量なライブラリが広く使われています。
これを利用することで、「最大5つずつ並列で実行する」といった細かい調整が可能になります。
import pLimit from 'p-limit';
const limit = pLimit(3); // 同時実行数を3に制限
const input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const tasks = input.map((num) => {
// limit関数でラップすることで、実行タイミングを制御する
return limit(async () => {
console.log(`タスク ${num} を実行中...`);
await new Promise(resolve => setTimeout(resolve, 500));
return num * 2;
});
});
const results = await Promise.all(tasks);
console.log('完了');
このように制限を設けることで、アプリケーションの安定性を保ちつつ、スループットを最大化することができます。
特にクラウド環境やコンテナ環境では、利用可能なリソースが厳密に制限されているため、このような「流量制御」は必須の技術と言えるでしょう。
使い分けの判断基準:表で比較
状況に応じて最適な手法を選択できるよう、各手法の特徴を整理しました。
| 手法 | 実行方式 | 主な用途 | メリット |
|---|---|---|---|
| for…of | 直列 | 順序が重要な処理、依存関係あり | シンプル、低負荷、可読性が高い |
| Promise.all | 並列 | 独立したタスク、速度重視 | 圧倒的に高速 |
| Promise.allSettled | 並列 | 一部失敗しても継続したい処理 | エラーに強い、全結果の取得 |
| p-limit 等 | 制限付き並列 | 大量のデータ処理、外部API連携 | リソース保護と速度の両立 |
基本的には、「迷ったら直列(for…of)、速度が必要なら制限付き並列」という方針で設計するのが最も安全です。
最新の非同期イテレーション:for await…of
Node.jsには、ストリームや非同期ジェネレータを扱うための for await...of という構文も存在します。
これは、非同期的にデータが生成されるソース(例えば、巨大なファイルの読み込みなど)から順番にデータを取り出して処理する場合に最適です。
import { createReadStream } from 'fs';
async function processLogFile() {
const stream = createReadStream('large-log.txt', { encoding: 'utf8' });
// ストリームからチャンクが届くたびに非同期ループが回る
for await (const chunk of stream) {
console.log('データを処理中...');
await heavyAnalysis(chunk); // 各チャンクの解析を待機
}
}
この構文を使用することで、メモリに一度にすべてのデータを載せることなく、巨大なデータセットを効率的に処理することができます。
これも一種の直列実行ですが、「バックプレッシャー(処理能力を超えたデータの流入を抑える仕組み)」を自然に扱えるという点で非常に強力です。
エラーハンドリングのベストプラクティス
非同期ループにおけるエラーハンドリングは、同期処理以上に複雑になりがちです。
特に並列処理中に例外が発生した場合、どのタスクが失敗し、どのタスクがまだ実行中なのかを把握するのは困難です。
モダンな実装では、AbortController を活用して、一つのタスクが失敗した際に関連する他の非同期処理をすべてキャンセルするという手法が一般的になっています。
async function fetchWithTimeout(urls) {
const controller = new AbortController();
const { signal } = controller;
try {
const fetchPromises = urls.map(url =>
fetch(url, { signal }).then(res => res.json())
);
return await Promise.all(fetchPromises);
} catch (error) {
// 一つでも失敗したら他のリクエストも中断する
controller.abort();
throw error;
}
}
このように、単にループを回すだけでなく、「異常時にリソースを速やかに解放する」設計を心がけることで、プロフェッショナルなコードへと近づきます。
まとめ
Node.jsにおける非同期ループの扱いは、アプリケーションの性能と安定性を左右する極めて重要な要素です。
従来の forEach は非同期処理との相性が悪く、意図しない挙動を引き起こす可能性があるため、基本的には使用を避けるべきです。
順序を守る必要がある場合は for...of による直列実行を、速度を優先する場合は Promise.all や Promise.allSettled による並列実行を選択してください。
また、大規模なシステムにおいては、同時実行数の制限や AbortController によるキャンセル処理を組み合わせることが、2026年現在のスタンダードな手法となっています。
それぞれの特性を深く理解し、要件に合わせて適切な道具を選び抜くことが、高品質なNode.jsアプリケーションを構築するための第一歩となるでしょう。
