テクノロジー

【脱・非同期処理アレルギー】JavaScriptのasync/awaitの使い方とメリット|昔のコードとの比較から実務での使い道まで解説

【脱・非同期処理アレルギー】JavaScriptのasync/awaitの使い方とメリット|昔のコードとの比較から実務での使い道まで解説

非同期処理と聞くだけで、少し身構えてしまう人は少なくありません。「コールバック地獄」という言葉だけが先行して、.then() を数珠つなぎにしたコードを見ると、どこで失敗したのか追えなくなる。実務ではこの感覚が、レビュー時間とバグの温床になります。

この記事では、フリーランスのWebディレクター兼エンジニアとして、API連携や管理画面、ヘッドレスCMS(microCMS)連携を実装してきた立場から、async/await の書き方だけでなく、なぜこの書き方になったのかまで整理します。昔のコールバック、Promise、そして現在の async/await を同じ処理で並べて比較し、Fetch / データベース / Promise.all による並列取得まで、現場でそのまま使える型を持ち帰れるようにします。

読み終えるころには、「なんとなく await を付けている」状態から、「直列と並列を意図して使い分ける」状態に変わっているはずです。

async/awaitの歴史:昔はどう記述していたのか?

JavaScript の非同期処理は、言語の欠陥というより ブラウザとサーバーの待ち時間を、画面を止めずに扱うための進化 です。歴史を知ると、今の書き方が「文法の流行」ではなく、現場の痛みを解消した結果だと分かります。

コールバック時代:処理は動くが、読む側が壊れる

古くは「終わったらこの関数を呼んで」というコールバックが主流でした。1段なら問題ありません。問題は、ユーザー取得 → 投稿取得 → コメント取得のように、次の処理が前の結果に依存するときです。

function getUserPosts(userId, callback) {
  fetchUser(userId, (userErr, user) => {
    if (userErr) {
      callback(userErr);
      return;
    }

    fetchPosts(user.id, (postsErr, posts) => {
      if (postsErr) {
        callback(postsErr);
        return;
      }

      fetchComments(posts[0].id, (commentsErr, comments) => {
        if (commentsErr) {
          callback(commentsErr);
          return;
        }

        callback(null, { user, posts, comments });
      });
    });
  });
}

これがいわゆるコールバック地獄(Callback Hell)です。現場で起きる実害は次の3つです。

  • インデントが右へ進み、変更箇所の見通しが消える
  • エラー処理が各段に散らばり、漏れた段だけ本番で落ちる
  • 「成功時の引数順」が関数ごとに違い、レビューで毎回確認が必要になる

動くコードと、運用できるコードは違います。コールバックは前者になりやすい書き方でした。

Promise時代:改善されたが、階層は残った

ES2015(ES6)で Promise が標準になると、成功と失敗を .then() / .catch() に分けられるようになりました。コールバックよりは明らかに読みやすいです。

function getUserPosts(userId) {
  return fetchUser(userId)
    .then((user) => {
      return fetchPosts(user.id).then((posts) => ({ user, posts }));
    })
    .then(({ user, posts }) => {
      return fetchComments(posts[0].id).then((comments) => ({
        user,
        posts,
        comments,
      }));
    })
    .catch((error) => {
      console.error(error);
      throw error;
    });
}

改善点は明確です。エラーを末尾の .catch() に寄せられる。返す値が Promise なので、呼び出し側もつなぎやすい。それでも、前の結果を次へ渡すたびにオブジェクトで持ち回る必要があり、処理が増えるほど .then() の入れ子が復活します。

実務では「Promise は使っているのに、レビューで追い切れない」状態がよく残っていました。非同期そのものより、データの受け渡し設計がボトルネックだったのです。

async/await登場:同期コードと同じ読み方になった

ES2017(ES8)で async / await が入り、非同期処理を「上から下へ」書けるようになりました。内部では Promise のままです。新しい別物ではなく、Promise を人間が読める形にするための糖衣構文です。

async function getUserPosts(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    return { user, posts, comments };
  } catch (error) {
    console.error(error);
    throw error;
  }
}

同じ3ステップでも、読み方は同期処理に近づきます。「待つ場所」が await 一語で見える。エラーは try...catch に集約できる。ここが、現場で async/await が標準になった理由です。

対比を一言でまとめると、次の通りです。

  • コールバック:動作はする。読む・直すコストが高い
  • Promise:失敗処理は整理された。データの受け渡しがまだ重い
  • async/await:待ちと失敗を、普段の JavaScript と同じ骨格で書ける

async/awaitの基本機能と使うメリット

文法は短いです。大事なのは、何が返り、何を待っているのかを取り違えないことです。

asyncawait の役割

async を付けた関数は、必ず Promise を返します。return した値は Promise.resolve 相当、投げた例外は Promise.reject 相当です。

await は、Promise が解決されるまでその行で待ち、解決値を取り出します。付けられるのは async 関数の中(または ES2022 以降のトップレベル await が使える環境)です。

async function fetchArticle(id) {
  const response = await fetch(`/api/blogs/${id}`);
  if (!response.ok) {
    throw new Error(`記事の取得に失敗しました: ${response.status}`);
  }
  return response.json();
}

const articlePromise = fetchArticle("abc"); // この時点では Promise
const article = await fetchArticle("abc"); // 中身が取り出せる

「async を付ければ同期になる」わけではありません。呼び出し側が await しなければ、裏で動いている Promise のままです。この取り違えが、後述する Promise { <pending> } 事故につながります。

メリット1: 可読性の爆発的向上

ネストが消える、という説明は正しいのですが、現場で効くのはもう一段先です。「何を待って、何を次に使うか」が変数名で残ることです。

.then((data) => ...) が続くと、data が何なのかが段ごとに変わります。async/await なら user / posts / comments と名前を固定できる。レビューする側は、差分ではなく物語を読めます。

可読性は見た目の問題ではありません。変更時に「どこまでが依存関係か」を誤認しないための、品質の話です。

メリット2: エラーハンドリングの統一

同期処理で使っている try...catch が、非同期でも同じ形で使えます。Fetch の HTTP エラー、JSON パース、業務ルールの例外を、一つの catch に集められます。

async function loadDashboard(userId) {
  try {
    const user = await fetchUser(userId);
    const summary = await fetchSummary(user.id);
    return { user, summary };
  } catch (error) {
    // 通信失敗も、JSON破損も、throw した業務エラーもここに来る
    console.error("ダッシュボード取得に失敗", error);
    throw error;
  }
}

.catch() を途中に挟む書き方でも動きます。ただし、成功ルートと失敗ルートが別レイヤになると、ログの粒度がバラけます。実務では 「待つ処理は try の中、呼び出し側に返す失敗は throw」 に揃えると、チームの実装が安定します。

注意点として、fetch は 404 や 500 でも例外になりません。response.ok を見て自分で throw しないと、catch に入りません。ここを忘れると「エラーハンドリングしたつもり」になります。

メリット3: デバッグのしやすさ

.then() チェーンは、ブレークポイントを置いてもコールスタックが飛びやすく、ウォッチしたい値がクロージャの奥に隠れます。async/await は通常の関数と同じ行に止められるため、userposts をその場で確認できます。

障害対応では、再現手順より先に「今どの待ちで止まっているか」が重要です。await の行が見えるコードは、その確認が速い。結果として、修正時間だけでなく、説明コストも下がります。

実務ではどういう場面で使われているか?

文法より、使いどころです。Web制作・フロント実装の現場で、async/await が主役になるのはほぼ次の3つです。

ケース①: Fetch / axios による外部API取得

ブログ、制作実績、在庫、予約枠。いまのサイトは、画面の裏側で JSON を取ってくる前提です。Next.js と microCMS の組み合わせでも、取得関数は async で切るのが基本です。

async function getBlogs() {
  const endpoint = `https://${serviceDomain}.microcms.io/api/v1/blogs`;
  const response = await fetch(endpoint, {
    headers: { "X-MICROCMS-API-KEY": apiKey },
  });

  if (!response.ok) {
    throw new Error("ブログ一覧の取得に失敗しました");
  }

  const data = await response.json();
  return data.contents;
}

axios でも型は同じです。const { data } = await axios.get(url) と書けるので、レスポンス整形まで含めて関数を薄く保てます。

ここでの実務ポイントは、画面コンポーネントに fetch を直接書かないことです。取得・失敗・整形を関数に閉じ、ページ側は await getBlogs() だけにする。テストもモックもしやすくなります。

ケース②: データベースや BaaS への非同期アクセス

ORM や Firebase も、ほぼ必ず Promise を返します。見た目は「データを取る」でも、実体はネットワーク待ちです。

async function getPublishedArticles(db) {
  try {
    const rows = await db
      .selectFrom("articles")
      .where("published_at", "is not", null)
      .orderBy("published_at", "desc")
      .execute();
    return rows;
  } catch (error) {
    console.error("公開記事の取得に失敗", error);
    throw error;
  }
}

フロントだけ書いていても、お問い合わせAPIや下書き保存では同じ型が出てきます。「I/O は待つ。計算は待たない」 を基準にすると、どこに async を付けるか迷いにくいです。

ケース③: Promise.all による並列取得(実務の必須テクニック)

async/await を覚えると、全部を直列に書いてしまいがちです。前の結果を使わない待ちを順番に積むと、表示が遅くなります。

// NG: 互いに依存しないのに、合計待ち時間が足し算になる
const profile = await fetchProfile();
const blogs = await fetchBlogs();
const results = await fetchResults();

依存が無いなら、同時に投げてまとめて待ちます。

const [profile, blogs, results] = await Promise.all([
  fetchProfile(),
  fetchBlogs(),
  fetchResults(),
]);

トップページでプロフィール・記事・実績を同時に出す、カテゴリ一覧と記事一覧を同時に取る。こうした画面では、並列化の有無が体感速度に直結します。

失敗時の振る舞いはチームで決めてください。Promise.all は1件でも失敗すると全体が rejected になります。一部失敗しても残りの表示が必要なら Promise.allSettled です。

const settled = await Promise.allSettled([
  fetchBlogs(),
  fetchResults(),
]);

const blogs = settled[0].status === "fulfilled" ? settled[0].value : [];
const results = settled[1].status === "fulfilled" ? settled[1].value : [];

現場の判断基準は単純です。

  • 次の処理が前の戻り値を使う → 直列(await を重ねる)
  • 互いに独立している → 並列(Promise.all
  • 一部失敗しても画面を出したい → allSettled

「await すれば正しい」ではありません。待ち方を設計するのが、実務での使い道です。

現場でハマりがちな注意点

文法は簡単でも、事故はここに集中します。コードレビューでは、この2点を先に見ます。

await の付け忘れ:Promise { <pending> } 事故

関数が Promise を返すのに、呼び出し側が待ち忘れるパターンです。ログにオブジェクトが出る、一覧が空、React の state に Promise が入る、といった症状になります。

async function getTitle(id) {
  const article = await fetchArticle(id);
  return article.title;
}

// NG: title は文字列ではなく Promise
const title = getTitle("abc");
console.log(title); // Promise { <pending> }

// OK
const title = await getTitle("abc");

条件分岐の片側だけ await する、早期 return で Promise を生のまま返す、も同類です。TypeScript を入れると stringPromise<string> の取り違えに気づけます。素の JavaScript でも、「async 関数の戻り値は、await するか、そのまま次の Promise チェーンに渡すか」 を関数の出口で確認する習慣が効きます。

forEach 内の await は効かない

配列を順に待ちたいときに forEach へ async 関数を渡すのは、現場で一番多い罠です。

// NG: forEach は Promise を待たない
async function saveAll(ids) {
  ids.forEach(async (id) => {
    await saveItem(id);
  });
  console.log("完了"); // 保存の前に実行される
}

forEach のコールバックを async にしても、外側は待ちません。ループを待つなら for...of です。

// OK: 1件ずつ順番に待つ
async function saveAll(ids) {
  for (const id of ids) {
    await saveItem(id);
  }
}

順番が不要で、件数も多すぎなければ並列の方が速いです。

// OK: 同時に投げて、全部終わるのを待つ
async function saveAll(ids) {
  await Promise.all(ids.map((id) => saveItem(id)));
}

大量の書き込みを無制限に並列すると、API制限やDB接続数で落ちます。その場合は、一定件数ずつ Promise.all するチャンク処理にします。直列か並列かは、速さだけでなく、相手側の耐性で決めてください。

map で async 関数を使うこと自体は問題ありません。返るのが Promise[] になる、という事実を忘れないことです。待つのが Promise.all なら正しく、待たずに console.log するなら未完了のままです。

ステップアップにおすすめの教材とツール

async/await は「書ける」と「使い分けられる」の間に一段あります。次に進むなら、基礎の穴を閉じつつ、毎日の実装速度を上げる投資が効率的です。

日本語で、Promise から async/await までの位置づけが正確です。まずここを通すと、断片知識が一本につながります。

  • 『改訂3版 JavaScript本格入門』(SBクリエイティブ)

実務に出る前の「なんとなく動く」を減らす用途で、チームの共通言語になりやすい一冊です。

  • 『リーダブルコード』(オライリー・ジャパン)

非同期の書き方以前に、名前と流れで読めるコードにする視点が得られます。リファクタリング案件の判断基準になります。

コールバックや .then() の塊を、意図を保ったまま async/await へ書き換える作業に向いています。ただし、並列化の是非までは自動では決まりません。Promise.all にすべき依存関係は、人が確認してください。

書籍やツールは、文法の暗記より 「待ちを設計する目」 を作るために使います。古いコードを1関数ずつ直すときのチェックリストにすると、投資が回収しやすいです。

フロントエンドのモダン化、一緒に整理しませんか

既存コードにコールバックと .then() と async/await が混在している状態は、よくあります。動いているから触らない、ではなく、障害が起きた関数から型を揃える方が安全です。

次のような相談を受けています。

  • 古い jQuery / コールバック実装を、Fetch + async/await へ段階的に移す
  • Next.js や microCMS 連携で、直列取得を並列化して表示を速くする
  • エラーハンドリングとログを try...catch に統一し、運用で追える状態にする

要件が固まっていなくても構いません。現状のコードを見たうえで、どこから直すと効果が大きいかを切り分けます。ご相談は プロフィール からお気軽にどうぞ。