フロントエンド 2026.01.20

JavaScript非同期処理のバグを撲滅!async/await実装の落とし穴回避術

約18分で読めます

プロミスチェーンでのエラーハンドリングやメモリリークなど、async/await実装で頻発するバグパターンを実案件の事例とともに解説。安全で保守性の高い非同期処理コードの書き方を身につけましょう。

こんな悩み、ありませんか?

「非同期処理でたまに画面が固まる」 「データが取得できないことがある」 「エラーが発生しても気づかない」

JavaScriptの非同期処理は現代のWeb開発において避けて通れない重要な技術です。しかし、async/awaitを使った実装では、思わぬバグが潜んでいることが多く、本格運用時に問題が顕在化するケースが後を絶ちません。

当社では20年以上にわたりWeb開発を手がけてきましたが、特にここ数年、SPAやAPIを活用したプロジェクトの増加に伴い、非同期処理に起因する問題の相談が急増しています。実際に「開発段階では問題なかったのに、本番環境でユーザーの操作が効かなくなる」といった深刻な事例も数多く経験してきました。

この記事では、実案件で遭遇した問題を踏まえながら、async/await実装における代表的な落とし穴と、それらを回避するための実践的な手法をご紹介します。

非同期処理バグが生まれる根本原因

見た目のシンプルさに隠された複雑性

async/awaitの最大の特徴は、同期処理のような読みやすいコードで非同期処理を記述できることです。しかし、この「見た目のシンプルさ」が逆に問題を生み出しています。

// 一見問題なさそうなコード
async function fetchUserData() {
  const response = await fetch('/api/users');
  const data = await response.json();
  return data;
}

このコードは一見完璧に見えますが、実際には複数の問題を抱えています。ネットワークエラー時の処理、レスポンスのステータス確認、タイムアウト処理など、本番環境で必須の要素が欠けているのです。

実案件での典型的な問題パターン

当社で手がけた某ECサイトの事例では、商品検索機能において以下のような問題が発生しました:

  • ユーザーが連続でキーワードを入力すると、古い検索結果が新しい結果を上書きする
  • ネットワーク不安定時に検索結果が表示されないが、エラーメッセージも出ない
  • 大量の商品データ取得時にブラウザが一時的に応答しなくなる

これらの問題の根底には、エラーハンドリングの不備競合状態の未考慮リソース管理の甘さといった共通の課題がありました。

実践的な問題解決手順

1. 堅牢なエラーハンドリングの実装

最も重要なのは、あらゆる例外状況を想定したエラー処理です。以下のパターンを標準実装として採用することを推奨します:

async function fetchUserDataSafely(userId) {
  try {
    // タイムアウト設定
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 5000);

    const response = await fetch(`/api/users/${userId}`, {
      signal: controller.signal
    });
    
    clearTimeout(timeoutId);

    // HTTPステータスの確認
    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }

    const data = await response.json();
    return { success: true, data };
    
  } catch (error) {
    // エラーの種類に応じた処理
    if (error.name === 'AbortError') {
      return { success: false, error: 'Request timeout' };
    }
    
    if (error instanceof TypeError) {
      return { success: false, error: 'Network error' };
    }
    
    return { success: false, error: error.message };
  }
}

2. 競合状態(Race Condition)の制御

ユーザーの連続操作によるデータの競合を防ぐため、リクエストのキャンセル機能を組み込みます:

class ApiManager {
  constructor() {
    this.pendingRequests = new Map();
  }

  async fetchWithCancellation(url, requestId) {
    // 既存のリクエストをキャンセル
    if (this.pendingRequests.has(requestId)) {
      this.pendingRequests.get(requestId).abort();
    }

    const controller = new AbortController();
    this.pendingRequests.set(requestId, controller);

    try {
      const response = await fetch(url, {
        signal: controller.signal
      });
      
      this.pendingRequests.delete(requestId);
      return await response.json();
      
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Request cancelled:', requestId);
        return null;
      }
      throw error;
    }
  }
}

3. メモリ効率とパフォーマンスの最適化

大量のデータを扱う際は、適切な並列処理とメモリ管理が不可欠です:

// 並列処理数を制限する関数
async function processInBatches(items, processor, batchSize = 3) {
  const results = [];
  
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    const batchResults = await Promise.all(
      batch.map(item => processor(item))
    );
    results.push(...batchResults);
    
    // GCのための小休止
    if (results.length % 50 === 0) {
      await new Promise(resolve => setTimeout(resolve, 10));
    }
  }
  
  return results;
}

4. デバッグと監視の仕組み

本番環境での問題特定のため、適切なロギング機能を実装します:

class AsyncLogger {
  static async wrapAsync(fn, context = '') {
    const startTime = performance.now();
    
    try {
      const result = await fn();
      const duration = performance.now() - startTime;
      
      console.log(`[SUCCESS] ${context}: ${duration.toFixed(2)}ms`);
      return result;
      
    } catch (error) {
      const duration = performance.now() - startTime;
      
      console.error(`[ERROR] ${context}: ${error.message} (${duration.toFixed(2)}ms)`);
      
      // エラー報告サービスへの送信
      this.reportError(error, context, duration);
      
      throw error;
    }
  }
  
  static reportError(error, context, duration) {
    // 実際のプロジェクトでは外部サービスに送信
    fetch('/api/errors', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        message: error.message,
        stack: error.stack,
        context,
        duration,
        timestamp: new Date().toISOString()
      })
    }).catch(() => {}); // エラー報告の失敗は無視
  }
}

よくある失敗パターンと対処法

失敗パターン1: Promise.all()でのエラーの連鎖

// ❌ 一つでも失敗すると全体が失敗
async function fetchAllUsersBad(userIds) {
  const users = await Promise.all(
    userIds.map(id => fetchUser(id))
  );
  return users;
}

// ✅ 個別の成功・失敗を管理
async function fetchAllUsersGood(userIds) {
  const results = await Promise.allSettled(
    userIds.map(async (id) => {
      try {
        return await fetchUser(id);
      } catch (error) {
        return { error: error.message, id };
      }
    })
  );
  
  return results.map(result => result.value);
}

失敗パターン2: 無限ループでのawait

// ❌ メモリリークの原因
async function pollStatusBad(taskId) {
  while (true) {
    const status = await checkTaskStatus(taskId);
    if (status === 'completed') break;
    await new Promise(resolve => setTimeout(resolve, 1000));
  }
}

// ✅ タイムアウトと最大試行回数を設定
async function pollStatusGood(taskId, maxAttempts = 30) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const status = await checkTaskStatus(taskId);
      if (status === 'completed') return status;
      
      await new Promise(resolve => setTimeout(resolve, 1000));
    } catch (error) {
      if (attempt === maxAttempts - 1) throw error;
      // 一時的なエラーは継続
    }
  }
  
  throw new Error('Polling timeout');
}

失敗パターン3: 不適切な並列処理

多くの開発者が陥りがちなのが、すべてのAPIリクエストを同時実行してしまうことです。これはサーバーへの負荷だけでなく、ブラウザのコネクション制限にも抵触します。

当社の実測データでは、適切なバッチ処理を行うことで、大量データ処理時の成功率が大幅に向上することが分かっています。

実装後の効果と成果

パフォーマンス改善の実例

前述のECサイト案件では、適切なasync/await実装により以下の改善が実現されました:

  • 検索エラー率: 12% → 0.3%に減少
  • 平均レスポンス時間: 3.2秒 → 1.1秒に短縮
  • ユーザー満足度: 68% → 89%に向上

特に注目すべきは、エラーハンドリングの強化により、ユーザーに適切なフィードバックが表示されるようになったことです。「何も起こらない」という最悪のUXから、「処理中です」「エラーが発生しました。もう一度お試しください」といった明確なメッセージが表示されるようになり、ユーザーの操作継続率が大幅に改善されました。

保守性の向上

適切なエラーハンドリングとロギングの実装により、本番環境でのトラブル解決時間も短縮されています:

  • 平均障害対応時間: 4時間 → 45分
  • 原因特定までの時間: 2時間 → 15分
  • 再発防止率: 向上(具体的な数値は守秘義務により非公開)

無料相談受付中

お気軽にご相談ください(初回無料)

詳しく見る

まとめと次のステップ

async/awaitを安全に活用するためには、単純な成功ケースだけでなく、あらゆる異常ケースを想定した設計が不可欠です。特に以下の要素は、どのプロジェクトでも必須の実装項目として考えてください:

  1. 包括的なエラーハンドリング - ネットワークエラー、タイムアウト、HTTPステータスエラーすべてに対応
  2. 競合状態の制御 - 連続リクエストのキャンセル機能
  3. 適切な並列処理制御 - サーバー負荷とブラウザ制限の考慮
  4. デバッグ・監視機能 - 本番環境での問題特定支援

適切な実装により、ユーザー体験の向上だけでなく、開発・運用コストの削減も実現できます。しかし、これらの実装には相応の知識と経験が必要です。

技術的な課題でお困りの際は

Fivenine Designでは、20年以上にわたるWeb開発の経験を活かし、JavaScriptの非同期処理実装からパフォーマンス最適化まで、幅広い技術課題の解決をサポートしています。特に「動作はするが不安定」「エラーの原因が分からない」といったお悩みについて、実践的な改善提案をご提供できます。

技術的な課題でお困りの際は、お気軽にご相談ください。現状の診断から改善策の提案まで、丁寧にサポートいたします。

この記事をシェア

この記事の内容でお困りですか?

無料でご相談いただけます

Webサイトの改善、システム開発、AI導入など、 お気軽にご相談ください。初回相談は無料です。

無料相談してみる
AIに無料相談