Node.jsを用いたサーバーサイド開発において、避けて通れないのが非同期処理の制御です。
JavaScriptはシングルスレッドで動作するため、時間のかかる処理を非同期で行うことで、アプリケーション全体のパフォーマンスを維持しています。
しかし、複数の非同期処理を「特定の順番」で実行したい場合、適切な実装パターンを知らなければ、プログラムは意図しない挙動を示してしまいます。
2026年現在、Node.jsのランタイムは進化を続けていますが、非同期制御の基本原則であるPromiseとasync/awaitの理解は依然として不可欠です。
本記事では、初心者から中級者のエンジニアに向けて、Node.jsで非同期処理を意図した順番で確実に実行するためのテクニックを、最新のベストプラクティスを交えて解説します。
Node.jsにおける非同期処理の仕組みと実行順序
Node.jsがなぜ非同期処理を多用するのか、その理由はメインスレッドをブロックしない「ノンブロッキングI/O」という設計思想にあります。
ファイル操作やネットワークリクエストなどの処理は、CPUの処理速度に比べて非常に時間がかかるため、結果を待たずに次の処理へ進むようになっています。
この仕組みにより、大量のリクエストを効率的に処理できる一方で、プログラマーが明示的に制御しなければ、処理がバラバラなタイミングで完了してしまうという課題が生じます。
例えば、データベースからユーザー情報を取得した後に、そのIDを使って注文履歴を取得したい場合、必ず「ユーザー情報の取得」が完了していなければなりません。
このような「依存関係のある処理」を順番通りに実行するためには、JavaScriptが提供する非同期制御の仕組みを正しく使い分ける必要があります。
かつては「コールバック関数」を入れ子にする手法が一般的でしたが、現在ではPromiseとasync/awaitが標準的な選択肢となっています。
イベントループとタスクキューの役割
非同期処理の実行順序を理解するためには、Node.jsの心臓部であるイベントループの挙動を把握しておくことが重要です。
非同期タスクが呼び出されると、それはバックグラウンドで実行され、完了したタイミングで「タスクキュー」に登録されます。
イベントループは、メインスレッドが空いているかどうかを常に監視し、空いたタイミングでキューにあるタスクを順番に取り出します。
このとき、Promiseの解決(Resolve)待ちは「マイクロタスクキュー」という優先度の高いキューに送られるため、通常のタイマー処理よりも早く実行される傾向があります。
このように、コードの記述順序と実際の実行順序が一致しないことが、Node.js開発における複雑さの要因となっています。
Promiseによる逐次実行の制御
Promiseは、非同期処理の「未来の結果」を表現するオブジェクトであり、コールバック地獄を解消するために導入されました。
複数の非同期処理を順番に実行したい場合、.then()メソッドを繋げる「Promiseチェーン」を利用します。
前の処理が完了した後に次のPromiseを返すことで、非同期処理を同期処理のように上から下へと流すことが可能になります。
/**
* 擬似的な非同期APIリクエスト
* @param {string} name - 処理名
* @param {number} delay - 遅延時間(ms)
*/
function asyncTask(name, delay) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`${name} が完了しました`);
resolve(`Result of ${name}`);
}, delay);
});
}
// Promiseチェーンによる逐次実行
asyncTask("タスク1", 1000)
.then((res1) => {
console.log(res1);
return asyncTask("タスク2", 500);
})
.then((res2) => {
console.log(res2);
return asyncTask("タスク3", 800);
})
.then((res3) => {
console.log(res3);
console.log("すべてのタスクが順番に完了しました");
})
.catch((error) => {
console.error("エラーが発生しました:", error);
});
タスク1 が完了しました
Result of タスク1
タスク2 が完了しました
Result of タスク2
タスク3 が完了しました
Result of タスク3
すべてのタスクが順番に完了しました
この手法のメリットは、各処理の結果を次のステップに確実に渡せることと、最後に.catch()を一つ置くだけでチェーン内のどこでエラーが起きても捕捉できる点にあります。
ただし、チェーンが長くなりすぎると、コードの可読性が低下し、デバッグが困難になる場合もあります。
async/awaitによる直感的な順序制御
2026年の現代において、Node.jsで最も推奨される非同期制御の方法は、async/await構文の使用です。
これは内部的にはPromiseを利用していますが、見た目を同期処理のように記述できるため、開発者の直感に非常に近いコードを書くことができます。
asyncキーワードを付与した関数内では、awaitキーワードを使うことで、そのPromiseが解決されるまで関数の実行を一時停止させることができます。
/**
* 逐次実行を制御するメイン関数
*/
async function executeSequentially() {
try {
console.log("実行開始");
// awaitにより、前のタスクが終わるまで次へ進まない
const result1 = await asyncTask("フェーズA", 1000);
console.log(result1);
const result2 = await asyncTask("フェーズB", 500);
console.log(result2);
const result3 = await asyncTask("フェーズC", 300);
console.log(result3);
console.log("実行終了");
} catch (error) {
console.error("処理中にエラーが発生しました:", error);
}
}
executeSequentially();
実行開始
フェーズA が完了しました
Result of フェーズA
フェーズB が完了しました
Result of フェーズB
フェーズC が完了しました
Result of フェーズC
実行終了
async/awaitの最大の利点は、try…catch構文による標準的なエラーハンドリングが可能な点です。
これにより、同期処理と非同期処理が混在する複雑なロジックでも、エラーの発生箇所を特定しやすくなります。
また、最近のNode.jsでは「Top-level await」がサポートされているため、モジュールの直下でもawaitを記述することが可能です。
ループ処理における非同期制御の落とし穴
配列の各要素に対して非同期処理を順番に実行したい場合、多くの開発者が最初に思いつくのはArray.prototype.forEach()かもしれません。
しかし、forEach内でawaitを使用しても、ループは止まらずにすべての要素の処理が並行して開始されてしまいます。
これは、forEachが内部で渡されたコールバック関数の完了を待機するように設計されていないためです。
誤った例:forEachによる非同期処理
const tasks = ["タスクA", "タスクB", "タスクC"];
async function badExample() {
console.log("開始");
tasks.forEach(async (task) => {
await asyncTask(task, Math.random() * 1000);
});
console.log("終了"); // ここが先に実行されてしまう
}
badExample();
上記のコードを実行すると、各タスクの完了を待たずに「終了」というログが表示され、さらにタスクの完了順序もバラバラになります。
意図した順番で1つずつ実行するには、for...ofループまたは従来のforループを使用する必要があります。
正しい例:for…ofによる逐次実行
async function goodExample() {
console.log("逐次実行開始");
const tasks = ["タスク1", "タスク2", "タスク3"];
for (const task of tasks) {
// 各ループの完了を待機する
const result = await asyncTask(task, 500);
console.log(`処理済み: ${result}`);
}
console.log("すべてのループ処理が完了しました");
}
goodExample();
逐次実行開始
タスク1 が完了しました
処理済み: Result of タスク1
タスク2 が完了しました
処理済み: Result of タスク2
タスク3 が完了しました
処理済み: Result of タスク3
すべてのループ処理が完了しました
このように、for...ofを使用することで、配列内の非同期タスクを確実に1つずつ順番に処理させることができます。
データの不整合を防ぎたい場面や、APIへのレートリミット(リクエスト頻度制限)がある場合には、この手法が必須となります。
並列実行と順序制御の両立
すべての処理を順番に待機していると、アプリケーション全体のレスポンスが低下することがあります。
依存関係のない複数の非同期処理がある場合、それらを並列で実行しつつ、すべての完了を待ってから次の処理へ進むのが効率的です。
Node.jsでは、これらを実現するためにPromise.allやPromise.allSettledを活用します。
Promise.allによる一括待機
Promise.allは、渡されたすべてのPromiseが完了した時点で、その結果を配列として返します。
各タスク自体は並列で動き出すため、全体の処理時間は「最も時間のかかるタスク」と同じ程度にまで短縮されます。
async function runInParallel() {
const start = Date.now();
// 3つのタスクを同時に開始
const promises = [
asyncTask("データ取得A", 1000),
asyncTask("データ取得B", 1000),
asyncTask("データ取得C", 1000)
];
// すべてが終わるまで待機
const results = await Promise.all(promises);
console.log("取得結果:", results);
console.log(`経過時間: ${Date.now() - start}ms`);
}
runInParallel();
データ取得A が完了しました
データ取得B が完了しました
データ取得C が完了しました
取得結果: [ 'Result of データ取得A', 'Result of データ取得B', 'Result of データ取得C' ]
経過時間: 1005ms
この手法は非常に強力ですが、注意点があります。
どれか1つでもタスクが失敗(Reject)すると、Promise.all全体がエラーとして即座に終了してしまいます。
「一部が失敗しても他の結果は取得したい」という場合には、Promise.allSettledを使用するのが2026年現在のスタンダードです。
Promise.allSettledによる安全な制御
Promise.allSettledは、すべての処理の成否を問わず、それぞれの結果の状態(status)を返してくれます。
これにより、「成功したデータだけを取り出し、失敗したものはログに残す」といった柔軟な制御が容易になります。
async function safeParallelRun() {
const tasks = [
asyncTask("成功タスク", 500),
Promise.reject("致命的なエラー"),
asyncTask("後続タスク", 300)
];
const summary = await Promise.allSettled(tasks);
summary.forEach((item, index) => {
if (item.status === 'fulfilled') {
console.log(`Task ${index} 成功:`, item.value);
} else {
console.log(`Task ${index} 失敗:`, item.reason);
}
});
}
safeParallelRun();
高度な制御:実行件数の制限(Concurrency Control)
非同期処理を「順番に実行(1件ずつ)」するのと「並列に実行(全件一斉に)」するのには、それぞれメリットとデメリットがあります。
しかし、数千件、数万件のデータを扱う場合、一斉に実行するとメモリ不足や接続制限でエラーになる恐れがあります。
そこで、「最大で同時3件まで実行し、空きが出たら次のタスクを投入する」といった並行数の制限が必要になります。
モダンなNode.js開発では、外部ライブラリ(p-limitなど)を使うか、以下のような簡易的な制御ロジックを実装することが推奨されます。
/**
* 指定した並行数でタスクを実行する
* @param {Array} items - 処理対象のデータ配列
* @param {number} limit - 最大並行数
* @param {Function} taskFn - 非同期処理関数
*/
async function mapWithLimit(items, limit, taskFn) {
const results = [];
const executing = new Set();
for (const item of items) {
const promise = taskFn(item).then(result => {
executing.delete(promise);
return result;
});
results.push(promise);
executing.add(promise);
// 上限に達していたら、どれか1つが終わるのを待つ
if (executing.size >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
// 実行例
const items = [1, 2, 3, 4, 5];
mapWithLimit(items, 2, (id) => asyncTask(`ID:${id}`, 1000))
.then(() => console.log("限定的な並列処理が完了"));
このように、単純な逐次実行(awaitのループ)と全件並列(Promise.all)の中間を取る手法を知っておくと、スケーラビリティの高いアプリケーションを構築できます。
Node.js 非同期制御パターンの比較表
これまでに解説した制御手法の使いどころを、以下の表にまとめました。
| 制御手法 | 実行順序 | 主な用途 | エラー時の挙動 |
|---|---|---|---|
| Promiseチェーン | 逐次 | 少数の依存関係がある処理 | .catch()へ飛ばされる |
| async/await (forループ) | 逐次 | 最も推奨される標準的な逐次処理 | try/catchで柔軟に処理可能 |
| Promise.all | 並列 | 高速化が必要な独立した処理 | 1つでも失敗すると全体が失敗 |
| Promise.allSettled | 並列 | 各処理の結果を個別に扱いたい場合 | 失敗しても他の結果を保持する |
プロジェクトの要件に応じて、適切なパターンを選択することが、バグの少ない堅牢なコードへの第一歩となります。
まとめ
Node.jsにおける非同期処理の制御は、一見複雑に思えるかもしれませんが、基本的なパターンを理解すれば自由自在に操ることができます。
単純な順序制御であれば、async/awaitとfor…ofループの組み合わせが最も安全で読みやすいコードになります。
パフォーマンスを優先する場面では、Promise.allやPromise.allSettledによる並列処理を検討し、リソースの制約がある場合は実行件数を制限する工夫を行いましょう。
「なぜその順序で動かす必要があるのか」を常に意識し、意図した通りのデータフローを設計することが、Node.jsエンジニアにとって最も重要なスキルの1つです。
本記事で解説した制御パターンを活用し、より効率的でメンテナンス性の高いアプリケーション開発に取り組んでみてください。
