【脱・非同期処理アレルギー】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の基本機能と使うメリット
文法は短いです。大事なのは、何が返り、何を待っているのかを取り違えないことです。
async と await の役割
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 は通常の関数と同じ行に止められるため、user や posts をその場で確認できます。
障害対応では、再現手順より先に「今どの待ちで止まっているか」が重要です。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 を入れると string と Promise<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に統一し、運用で追える状態にする
要件が固まっていなくても構いません。現状のコードを見たうえで、どこから直すと効果が大きいかを切り分けます。ご相談は プロフィール からお気軽にどうぞ。