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
    end

async/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...ofawaitの組み合わせは直列実行になります。

 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を使う
  • エラーハンドリングを忘れない

これらのパターンを意識することで、パフォーマンスが高く保守しやすい非同期コードを書けるようになります。まずは自分のプロジェクトで改善できる箇所がないか、確認してみてください。

参考リンク