数ヶ月以上JavaScriptを書いてきたなら、深くネストしたコールバックや絡まった.then()チェーンの辛さを経験したことがあるでしょう。ES2017で導入されたasync/awaitはそれを全て解決しました。しかし開発者はいまだに同じ3〜4つの落とし穴にはまり続けています。全体をきちんと解説します:仕組み、適切なエラーハンドリング、そしてパフォーマンスに実際に影響する並列実行パターンについて。

基礎 — async関数とawait

async関数は常にPromiseを返します。内部では、awaitはawaitされたPromiseが解決されるまで実行を一時停止します。これがすべての基本概念です:

js
async function fetchUserProfile(userId) {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const profile = await response.json();
  return profile; // wrapped in a Promise automatically
}

// Calling an async function gives you a Promise
const profilePromise = fetchUserProfile(42);
profilePromise.then(profile => console.log(profile.name));

// Or use await at the call site
const profile = await fetchUserProfile(42);
console.log(profile.name);

awaitキーワードはasync関数の内部でのみ使用できます。またはESモジュールのトップレベルでも使用できます(後ほど説明します)。それ以外の場所で使用すると構文エラーになります。

エラーハンドリング — 正しい方法と間違った方法

ほとんどのチュートリアルがここで脱線します。すべてをtry/catchで囲んで終わりにするのが本能的な対応です。それは動きますが、空のcatchブロックは本物のバグを隠すコードの悪臭です:

js
// ❌ Don't do this — silent failure, impossible to debug
async function loadConfig() {
  try {
    const res = await fetch('/api/config');
    return await res.json();
  } catch (err) {
    // swallowed — you'll never know what broke
  }
}

// ✅ Do this — handle errors explicitly, return something meaningful
async function loadConfig() {
  try {
    const res = await fetch('/api/config');
    if (!res.ok) {
      throw new Error(`Config fetch failed: ${res.status} ${res.statusText}`);
    }
    return await res.json();
  } catch (err) {
    console.error('loadConfig error:', err.message);
    return null; // caller can check for null
  }
}

私はasync関数が失敗時にnullを返すか、意図的なエラーをスローするパターンを好みます。避けるべきは、エラーをキャッチしてログに記録し、呼び出し元にリクエストが成功したと思わせる値を返すことです。

プロのヒント:一つのtryブロックに複数のawaitがある場合、一つのcatchがすべてを処理します。しかしどれが失敗したかのコンテキストは失われます。複雑なフローでは、別々のtry/catchブロックか、[error, data]タプルを返すawait to(promise)のようなヘルパーを使うことを検討してください。

逐次 vs 並列 — パフォーマンスの落とし穴

本番コードで最もよく見かけるミスです。各呼び出しを次々とawaitすると、それらが完全に独立していても逐次実行になります:

js
// ❌ Sequential — takes ~900ms total (300 + 300 + 300)
async function loadDashboard(userId) {
  const user     = await fetchUser(userId);       // 300ms
  const orders   = await fetchOrders(userId);     // 300ms
  const settings = await fetchSettings(userId);  // 300ms
  return { user, orders, settings };
}

// ✅ Parallel with Promise.all — takes ~300ms total
async function loadDashboard(userId) {
  const [user, orders, settings] = await Promise.all([
    fetchUser(userId),
    fetchOrders(userId),
    fetchSettings(userId)
  ]);
  return { user, orders, settings };
}

Promise.all()は3つのリクエストを同時に送り、すべてが完了するまで待ちます。どれか一つが拒否されると全体が拒否されます。すべてのデータが必要なダッシュボード形式の読み込みには通常これが望ましい動作です。

Promise.allSettled — 部分的な失敗が許容される場合

複数のリクエストを送り、いくつかが失敗しても成功したものを使いたい場合があります。Promise.allSettled()はまさにそのために作られています:

js
async function loadWidgets(widgetIds) {
  const results = await Promise.allSettled(
    widgetIds.map(id => fetchWidget(id))
  );

  const widgets = [];
  const errors  = [];

  for (const result of results) {
    if (result.status === 'fulfilled') {
      widgets.push(result.value);
    } else {
      errors.push(result.reason.message);
    }
  }

  if (errors.length > 0) {
    console.warn('Some widgets failed to load:', errors);
  }

  return widgets; // return whatever succeeded
}

このパターンは複数の独立したセクションを持つサイドバーのような非重要なUI要素に最適です。一つが失敗しても、ページ全体を空にする代わりに残りを表示できます。

ループ内でのasync — forEachの落とし穴

これは誰もが少なくとも一度は経験する問題です。Array.forEach()はasyncコールバックをawaitしません。それらを起動してすぐに次に進みます。ループはasyncの作業が完了する前に終了します:

js
const orderIds = [101, 102, 103, 104];

// ❌ forEach ignores async — all requests fire in parallel uncontrolled,
// and code after the forEach runs before any complete
orderIds.forEach(async (id) => {
  await processOrder(id); // NOT awaited by forEach
});
console.log('done?'); // prints before any order is processed

// ✅ for...of — sequential, fully awaited
for (const id of orderIds) {
  await processOrder(id);
}
console.log('done'); // prints after all orders are processed

// ✅ Parallel but controlled — all fire at once, await all completions
await Promise.all(orderIds.map(id => processOrder(id)));
console.log('done'); // prints after all orders are processed

順序が重要な場合やリクエストを制限する必要がある場合(一度に一つ処理する)はfor...ofを使います。最大の並列性が必要で順序の保証が不要な場合はPromise.all(map(...))を使います。

ESモジュールのトップレベルawait

ES2022以降、ESモジュールのトップレベルでawaitを使用できます。ラッパー関数は不要です。これはasyncデータに依存するモジュールの初期化に大きな意味を持ちます:

js
// config.js (ES module)
const response = await fetch('/api/runtime-config');
const config   = await response.json();

export const API_BASE_URL  = config.apiBaseUrl;
export const FEATURE_FLAGS = config.featureFlags;
js
// main.js — imports wait for config.js to fully resolve
import { API_BASE_URL, FEATURE_FLAGS } from './config.js';

console.log(API_BASE_URL); // guaranteed to be loaded

トップレベルawaitはpackage.jsonに"type": "module"があるNode.js 14.8+と、ネイティブESモジュールを通じた全ての最新ブラウザで動作します。インポートするモジュールの実行はawaitされたモジュールが完全に解決されるまで一時停止します。これがまさに必要な保証です。

実際のパイプライン — 取得、解析、変換

すべてを組み合わせたリアルなasyncパイプラインです:APIからの取得、HTTPエラーの処理、データの変換、失敗時の適切なフォールバック:

js
async function getProductCatalog(categoryId) {
  // Step 1: fetch raw data
  const response = await fetch(
    `https://api.shop.example.com/categories/${categoryId}/products`,
    { headers: { Authorization: `Bearer ${getAuthToken()}` } }
  );

  if (!response.ok) {
    throw new Error(`Catalog fetch failed: ${response.status}`);
  }

  // Step 2: parse JSON
  const raw = await response.json();

  // Step 3: transform into the shape your UI needs
  const products = raw.items.map(item => ({
    id:       item.product_id,
    name:     item.display_name,
    price:    (item.price_cents / 100).toFixed(2),
    inStock:  item.inventory_count > 0,
    imageUrl: item.media?.[0]?.url ?? '/images/placeholder.png'
  }));

  // Step 4: filter out anything that's been discontinued
  return products.filter(p => !p.discontinued);
}

// Usage
try {
  const catalog = await getProductCatalog('electronics');
  renderProductGrid(catalog);
} catch (err) {
  showErrorBanner(`Could not load products: ${err.message}`);
}

便利なツール

JSONペイロードを処理するasyncコードをデバッグする際、これらのツールが役立ちます:JSONフォーマッターでAPIレスポンスを確認し、JSON バリデーターでコードに到達する前に不正なペイロードを検出し、JSフォーマッターでasync関数のコードを整形できます。async/awaitの完全な仕様については、MDN asyncガイドが最も詳細なリファレンスで、TC39仕様には正確なセマンティクスが記載されています。

まとめ

async/awaitは非同期JavaScriptを読みやすくします。しかし落とし穴は実在します。解析前に必ずresponse.okを確認し、catchブロックがエラーを無言で飲み込まないようにし、独立した並列呼び出しにはPromise.all()を使い、asyncコールバックでforEachを使うのを避けましょう。これらの習慣を定着させれば、asyncコードは高速かつデバッグ可能になります。