Node.jsでasync/awaitを使いこなせていますか。「なんとなく動いている」コードと「正しく最適化された」コードの間には、パフォーマンスにおいて大きな差が生まれます。
本記事では、Node.js環境におけるasync/awaitの実践的なエラー処理パターン、並列実行と直列実行の使い分け、そして多くの開発者が陥りがちなアンチパターンを解説します。ファイル操作やHTTPリクエストなど、Node.js特有のユースケースを中心に、パフォーマンスを意識した非同期処理の書き方を習得しましょう。
実行環境#
| 項目 |
バージョン |
| Node.js |
20.x LTS以上 |
| npm |
10.x以上 |
| OS |
Windows/macOS/Linux |
前提条件#
- JavaScriptの基礎知識(関数、オブジェクト、配列操作)
- Promiseの基本(resolve、reject、then、catch)の理解
- Node.jsの基本操作経験
async/awaitの基本をおさらい#
async関数とawait式#
asyncキーワードを付けた関数は必ずPromiseを返します。await式はPromiseの解決を待ち、その結果を返します。
1
2
3
4
5
6
7
8
9
10
|
import { readFile } from 'node:fs/promises';
async function loadConfig() {
// awaitでPromiseの解決を待つ
const content = await readFile('./config.json', 'utf8');
return JSON.parse(content);
}
// async関数は常にPromiseを返す
loadConfig().then(config => console.log(config));
|
async関数内で例外が発生すると、返されるPromiseは拒否(reject)されます。
1
2
3
4
5
6
7
|
async function failingFunction() {
throw new Error('意図的なエラー');
}
// Promiseが拒否される
failingFunction().catch(err => console.error(err.message));
// 出力: 意図的なエラー
|
awaitの動作と実行フロー#
awaitはasync関数の実行を一時停止し、Promiseが解決されるまで待機します。ただし、イベントループはブロックされず、他の処理は継続されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
async function demonstrateFlow() {
console.log('1. async関数開始');
await new Promise(resolve => setTimeout(resolve, 100));
console.log('2. 100ms後');
await new Promise(resolve => setTimeout(resolve, 100));
console.log('3. さらに100ms後');
return '完了';
}
console.log('A. 呼び出し前');
demonstrateFlow().then(result => console.log(`4. ${result}`));
console.log('B. 呼び出し直後');
// 出力順序:
// A. 呼び出し前
// 1. async関数開始
// B. 呼び出し直後
// 2. 100ms後
// 3. さらに100ms後
// 4. 完了
|
「B. 呼び出し直後」が「1. async関数開始」の後に表示されることに注目してください。最初のawaitに到達した時点で、async関数の実行は一時停止し、呼び出し元に制御が戻ります。
エラー処理のパターン#
try-catchによる基本的なエラー処理#
async/awaitでは、同期処理と同様にtry-catchでエラーを捕捉できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import { readFile } from 'node:fs/promises';
async function loadConfigSafely(filePath) {
try {
const content = await readFile(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
console.error(`ファイルが見つかりません: ${filePath}`);
return null;
}
if (error instanceof SyntaxError) {
console.error(`JSONの解析に失敗しました: ${error.message}`);
return null;
}
// 予期しないエラーは再スロー
throw error;
}
}
|
エラーの種類を判別して適切な対応を行うことで、ユーザーに分かりやすいエラーメッセージを提供できます。
error.causeによるエラーチェーン#
Node.js 16.9以降では、error.causeオプションを使用してエラーをチェーンできます。元のエラー情報を保持しながら、コンテキストを追加したエラーを投げられます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
import { readFile } from 'node:fs/promises';
async function loadUserConfig(userId) {
const configPath = `./users/${userId}/config.json`;
try {
const content = await readFile(configPath, 'utf8');
return JSON.parse(content);
} catch (error) {
// 元のエラーをcauseとして保持
throw new Error(`ユーザー ${userId} の設定読み込みに失敗`, { cause: error });
}
}
async function main() {
try {
await loadUserConfig('user123');
} catch (error) {
console.error('エラー:', error.message);
console.error('原因:', error.cause?.message);
console.error('エラーコード:', error.cause?.code);
}
}
main();
// 出力:
// エラー: ユーザー user123 の設定読み込みに失敗
// 原因: ENOENT: no such file or directory, open './users/user123/config.json'
// エラーコード: ENOENT
|
カスタムエラークラスの活用#
業務ロジックに応じたカスタムエラークラスを定義することで、エラー処理がより明確になります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
// カスタムエラークラスの定義
class ConfigurationError extends Error {
constructor(message, options = {}) {
super(message, options);
this.name = 'ConfigurationError';
this.configPath = options.configPath;
}
}
class ValidationError extends Error {
constructor(message, options = {}) {
super(message, options);
this.name = 'ValidationError';
this.field = options.field;
this.value = options.value;
}
}
// 使用例
async function loadAndValidateConfig(filePath) {
let content;
try {
content = await readFile(filePath, 'utf8');
} catch (error) {
throw new ConfigurationError(
`設定ファイルの読み込みに失敗しました`,
{ cause: error, configPath: filePath }
);
}
let config;
try {
config = JSON.parse(content);
} catch (error) {
throw new ConfigurationError(
`設定ファイルのJSONが不正です`,
{ cause: error, configPath: filePath }
);
}
// バリデーション
if (!config.port || typeof config.port !== 'number') {
throw new ValidationError(
'portは数値で指定してください',
{ field: 'port', value: config.port }
);
}
return config;
}
// エラー種別に応じた処理
async function main() {
try {
const config = await loadAndValidateConfig('./app.json');
console.log('設定:', config);
} catch (error) {
if (error instanceof ConfigurationError) {
console.error(`設定エラー: ${error.message}`);
console.error(`ファイル: ${error.configPath}`);
} else if (error instanceof ValidationError) {
console.error(`検証エラー: ${error.message}`);
console.error(`フィールド: ${error.field}, 値: ${error.value}`);
} else {
throw error; // 予期しないエラーは再スロー
}
process.exit(1);
}
}
|
finallyによるリソースのクリーンアップ#
finallyブロックは、成功・失敗に関わらず必ず実行されます。リソースの解放やクリーンアップ処理に最適です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import { open } from 'node:fs/promises';
async function processLargeFile(filePath) {
let fileHandle;
try {
fileHandle = await open(filePath, 'r');
const content = await fileHandle.readFile('utf8');
// 何らかの処理
return processContent(content);
} catch (error) {
console.error(`ファイル処理中にエラー: ${error.message}`);
throw error;
} finally {
// ファイルハンドルを確実にクローズ
if (fileHandle) {
await fileHandle.close();
console.log('ファイルハンドルをクローズしました');
}
}
}
|
並列実行と直列実行の使い分け#
直列実行(Sequential Execution)#
前の処理結果に依存する場合は、順番にawaitします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import { readFile, writeFile } from 'node:fs/promises';
// 直列実行: 各処理が前の結果に依存する
async function processUserData(userId) {
// 1. ユーザー情報を取得
const userJson = await readFile(`./users/${userId}.json`, 'utf8');
const user = JSON.parse(userJson);
// 2. ユーザーの注文履歴を取得(ユーザーIDが必要)
const ordersJson = await readFile(`./orders/${user.id}.json`, 'utf8');
const orders = JSON.parse(ordersJson);
// 3. 最新の注文に関連する商品情報を取得
const latestOrder = orders[0];
const productJson = await readFile(`./products/${latestOrder.productId}.json`, 'utf8');
const product = JSON.parse(productJson);
return { user, latestOrder, product };
}
|
この場合、各ファイル読み込みが前の結果に依存しているため、直列実行が必須です。
並列実行(Parallel Execution)#
互いに依存しない処理は、Promise.all()で並列実行することでパフォーマンスが向上します。
1
2
3
4
5
6
7
8
9
10
11
12
|
import { readFile } from 'node:fs/promises';
// 並列実行: 各処理が独立している
async function loadAllConfigs() {
const [dbConfig, appConfig, logConfig] = await Promise.all([
readFile('./config/database.json', 'utf8').then(JSON.parse),
readFile('./config/app.json', 'utf8').then(JSON.parse),
readFile('./config/logging.json', 'utf8').then(JSON.parse)
]);
return { dbConfig, appConfig, logConfig };
}
|
パフォーマンス比較#
直列実行と並列実行のパフォーマンス差を計測してみましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
// 模擬的なAPI呼び出し(各100ms)
function fetchData(id) {
return new Promise(resolve => {
setTimeout(() => resolve({ id, data: `Data for ${id}` }), 100);
});
}
// 直列実行
async function sequentialFetch() {
console.time('直列実行');
const result1 = await fetchData(1);
const result2 = await fetchData(2);
const result3 = await fetchData(3);
console.timeEnd('直列実行');
return [result1, result2, result3];
}
// 並列実行
async function parallelFetch() {
console.time('並列実行');
const results = await Promise.all([
fetchData(1),
fetchData(2),
fetchData(3)
]);
console.timeEnd('並列実行');
return results;
}
// 実行結果
await sequentialFetch(); // 直列実行: 約300ms
await parallelFetch(); // 並列実行: 約100ms
|
gantt
title 直列実行 vs 並列実行(各処理100ms)
dateFormat X
axisFormat %Lms
section 直列実行
fetchData(1) :0, 100
fetchData(2) :100, 200
fetchData(3) :200, 300
section 並列実行
fetchData(1) :0, 100
fetchData(2) :0, 100
fetchData(3) :0, 100ハイブリッドパターン#
実際のアプリケーションでは、一部が依存関係を持ち、一部が独立しているケースが多くあります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
async function loadDashboardData(userId) {
// フェーズ1: ユーザー情報を取得(必須)
const user = await fetchUser(userId);
// フェーズ2: ユーザーIDを使って複数のデータを並列取得
const [orders, favorites, notifications] = await Promise.all([
fetchOrders(user.id),
fetchFavorites(user.id),
fetchNotifications(user.id)
]);
// フェーズ3: 注文情報を使って追加データを取得
const orderDetails = await Promise.all(
orders.slice(0, 5).map(order => fetchOrderDetails(order.id))
);
return { user, orders, favorites, notifications, orderDetails };
}
|
flowchart LR
A[fetchUser] --> B[fetchOrders]
A --> C[fetchFavorites]
A --> D[fetchNotifications]
B --> E[fetchOrderDetails x5]
subgraph "フェーズ1(直列)"
A
end
subgraph "フェーズ2(並列)"
B
C
D
end
subgraph "フェーズ3(並列)"
E
endasync/awaitのアンチパターン#
アンチパターン1: 不要なawait#
async関数の戻り値をそのまま返す場合、awaitは不要です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 悪い例: 不要なawait
async function getUser(id) {
return await fetchUser(id); // awaitは不要
}
// 良い例: そのままPromiseを返す
async function getUser(id) {
return fetchUser(id);
}
// さらに良い例: asyncも不要
function getUser(id) {
return fetchUser(id);
}
|
ただし、try-catch内ではawaitが必要です。
1
2
3
4
5
6
7
8
9
|
// この場合はawaitが必要
async function getUserSafely(id) {
try {
return await fetchUser(id); // try-catch内では必要
} catch (error) {
console.error('ユーザー取得エラー:', error);
return null;
}
}
|
アンチパターン2: Promise.allを使わない直列処理#
独立した処理を直列で実行すると、不要な待機時間が発生します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// 悪い例: 独立した処理を直列実行
async function loadAllData() {
const users = await fetchUsers(); // 200ms
const products = await fetchProducts(); // 200ms
const orders = await fetchOrders(); // 200ms
// 合計: 600ms
return { users, products, orders };
}
// 良い例: 並列実行
async function loadAllData() {
const [users, products, orders] = await Promise.all([
fetchUsers(), // 200ms
fetchProducts(), // 200ms
fetchOrders() // 200ms
]);
// 合計: 約200ms
return { users, products, orders };
}
|
アンチパターン3: ループ内での直列await#
配列の各要素に対して非同期処理を行う場合、for...ofとawaitの組み合わせは直列実行になります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// 悪い例: ループ内での直列await
async function processUsersBad(userIds) {
const results = [];
for (const id of userIds) {
const user = await fetchUser(id); // 1つずつ順番に処理
results.push(user);
}
return results;
}
// 良い例: Promise.allで並列処理
async function processUsersGood(userIds) {
const results = await Promise.all(
userIds.map(id => fetchUser(id)) // すべて同時に開始
);
return results;
}
|
ただし、API呼び出しの並列数に制限がある場合は、チャンク処理が必要です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 並列数を制限した処理
async function processUsersWithLimit(userIds, concurrency = 5) {
const results = [];
for (let i = 0; i < userIds.length; i += concurrency) {
const chunk = userIds.slice(i, i + concurrency);
const chunkResults = await Promise.all(
chunk.map(id => fetchUser(id))
);
results.push(...chunkResults);
}
return results;
}
|
アンチパターン4: forEachでのasync/await#
Array.prototype.forEachはコールバックの戻り値を待ちません。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
// 悪い例: forEachはawaitを待たない
async function processItemsBad(items) {
const results = [];
items.forEach(async (item) => {
const result = await processItem(item);
results.push(result); // 順序が保証されない
});
console.log(results); // 空配列が出力される可能性
return results;
}
// 良い例: for...ofを使う(直列)
async function processItemsSequential(items) {
const results = [];
for (const item of items) {
const result = await processItem(item);
results.push(result);
}
return results;
}
// 良い例: Promise.allを使う(並列)
async function processItemsParallel(items) {
const results = await Promise.all(
items.map(item => processItem(item))
);
return results;
}
|
アンチパターン5: エラーハンドリングの忘れ#
async関数の呼び出し時にエラーハンドリングを忘れると、Unhandled Rejectionが発生します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// 悪い例: エラーハンドリングなし
async function main() {
const data = await fetchData(); // エラーが発生すると例外がスローされる
console.log(data);
}
main(); // Unhandled Rejection の可能性
// 良い例: エラーハンドリングあり
async function main() {
try {
const data = await fetchData();
console.log(data);
} catch (error) {
console.error('データ取得エラー:', error.message);
process.exit(1);
}
}
main();
// 別の良い例: catch()を使用
main().catch(error => {
console.error('エラー:', error.message);
process.exit(1);
});
|
アンチパターン6: 不要なasync修飾子#
Promiseを返すだけの関数にasyncは不要です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 悪い例: 不要なasync
async function getConfig() {
return { port: 3000, host: 'localhost' };
}
// 良い例: asyncは不要
function getConfig() {
return Promise.resolve({ port: 3000, host: 'localhost' });
}
// さらに良い例: 同期関数として定義
function getConfig() {
return { port: 3000, host: 'localhost' };
}
|
実践的なエラー処理パターン#
リトライ処理の実装#
ネットワークエラーなど一時的な障害に対応するリトライ処理を実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
async function fetchWithRetry(url, options = {}) {
const { maxRetries = 3, initialDelay = 1000, backoffFactor = 2 } = options;
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
lastError = error;
console.warn(`試行 ${attempt}/${maxRetries} 失敗:`, error.message);
if (attempt < maxRetries) {
const delay = initialDelay * Math.pow(backoffFactor, attempt - 1);
console.log(`${delay}ms 後にリトライします...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error(`${maxRetries}回の試行後も失敗: ${lastError.message}`, {
cause: lastError
});
}
// 使用例
const data = await fetchWithRetry('https://api.example.com/data', {
maxRetries: 5,
initialDelay: 500,
backoffFactor: 2
});
|
タイムアウト処理の実装#
長時間かかる処理にタイムアウトを設定します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
function withTimeout(promise, ms, message = 'タイムアウト') {
const timeout = new Promise((_, reject) => {
const id = setTimeout(() => {
clearTimeout(id);
reject(new Error(`${message} (${ms}ms)`));
}, ms);
});
return Promise.race([promise, timeout]);
}
// 使用例
async function fetchDataWithTimeout(url) {
try {
const response = await withTimeout(
fetch(url),
5000,
'APIリクエストがタイムアウトしました'
);
return await response.json();
} catch (error) {
if (error.message.includes('タイムアウト')) {
console.error('リクエストがタイムアウトしました');
return null;
}
throw error;
}
}
|
AbortControllerによるキャンセル処理#
Node.js 15以降で利用可能なAbortControllerを使用した処理のキャンセルです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
async function fetchWithAbort(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`リクエストがキャンセルされました: ${url}`);
}
throw error;
}
}
// 複数リクエストの一括キャンセル
async function fetchMultipleWithAbort(urls, timeoutMs = 10000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const results = await Promise.all(
urls.map(url => fetch(url, { signal: controller.signal }))
);
clearTimeout(timeoutId);
return await Promise.all(results.map(r => r.json()));
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
|
パフォーマンス最適化のベストプラクティス#
早期リターンパターン#
条件によって早期にリターンすることで、不要な非同期処理を回避します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
async function getUserProfile(userId) {
// キャッシュチェック(同期処理)
const cached = cache.get(userId);
if (cached) {
return cached; // 早期リターン
}
// バリデーション(同期処理)
if (!isValidUserId(userId)) {
throw new ValidationError('無効なユーザーID');
}
// 非同期処理(必要な場合のみ実行)
const profile = await fetchUserProfile(userId);
cache.set(userId, profile);
return profile;
}
|
遅延評価パターン#
必要になるまで非同期処理の開始を遅らせます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
class LazyLoader {
constructor(loader) {
this.loader = loader;
this.promise = null;
}
async get() {
if (!this.promise) {
this.promise = this.loader();
}
return this.promise;
}
clear() {
this.promise = null;
}
}
// 使用例
const configLoader = new LazyLoader(async () => {
console.log('設定を読み込み中...');
return await readFile('./config.json', 'utf8').then(JSON.parse);
});
// 初回呼び出し時のみ読み込みが実行される
const config1 = await configLoader.get(); // "設定を読み込み中..." が出力
const config2 = await configLoader.get(); // キャッシュされた結果が返る
|
Promise.allSettledによる部分的成功の処理#
一部が失敗しても処理を継続したい場合は、Promise.allSettled()を使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
async function fetchAllUserData(userIds) {
const results = await Promise.allSettled(
userIds.map(id => fetchUser(id))
);
const successful = [];
const failed = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successful.push(result.value);
} else {
failed.push({
userId: userIds[index],
error: result.reason.message
});
}
});
console.log(`成功: ${successful.length}件, 失敗: ${failed.length}件`);
if (failed.length > 0) {
console.warn('失敗したユーザー:', failed);
}
return { successful, failed };
}
|
まとめ#
本記事では、Node.jsにおけるasync/awaitの実践的な使い方を解説しました。重要なポイントを振り返ります。
エラー処理
try-catchで適切にエラーを捕捉する
error.causeでエラーチェーンを構築する
- カスタムエラークラスでエラー種別を明確化する
finallyでリソースを確実にクリーンアップする
並列実行と直列実行
- 独立した処理は
Promise.all()で並列実行する
- 依存関係がある場合は順次
awaitする
- ハイブリッドパターンで効率的に処理する
アンチパターン
- 不要な
awaitを避ける
- ループ内では
Promise.all()を検討する
forEachではなくfor...ofまたはmapを使う
- エラーハンドリングを忘れない
これらのパターンを意識することで、パフォーマンスが高く保守しやすい非同期コードを書けるようになります。まずは自分のプロジェクトで改善できる箇所がないか、確認してみてください。
参考リンク#