JavaScriptの非同期処理を理解する:コールバック地獄からPromiseへの進化

はじめに

システム開発第一事業部の奥田です。普段はフルスタックエンジニア(とはいえフロント寄りが多めですが)として、Webアプリの開発を担当しています。

アプリ開発をしていると必ず出会うのが「非同期処理」という考え方です。ただ、初めて触れると直感的に分かりにくい部分も多く、私自身も苦労しました。

今回、チーム内の勉強会で「JavaScriptの非同期処理」について発表する機会があり、その内容を整理した資料を元にせっかくなので社外にも発信してみようと思い、初めてブログを書きました 🎉🎉

内容がボリューミーなのでシリーズ化して、JavaScriptの非同期処理について少しマニアックな部分まで掘り下げていければと思っていますのでご期待ください!

さてさて、本記事ではJSの非同期処理の基礎から「コールバック地獄」と呼ばれる課題、そしてPromiseの登場による進化まで、歴史的な流れも交えてお話ししたいと思います。

JavaScriptの非同期処理とは

非同期処理とは、プログラム全体の流れを止めずに、時間のかかる処理を裏側で進める仕組みのことです。
こう聞くとすごく便利なものなんだなと感じますが、JavaScriptにおいては少し落とし穴があったりします。

JavaScriptはシングルスレッドで動作します。(※)これは「ひとつの道を一列で歩いていくようなイメージ」で、一度に一つの処理しか実行できません。
一方で、C++ や Java のような マルチスレッド言語では「複数の道を同時に使える」ため、処理を並行して進められるという特徴があります。

JavaScriptでも非同期処理を使えば「複数の処理が同時に進んでいるように見える」動きは実現できます。
しかし、実際にはシングルスレッドという一本の道で順番に処理をこなしているだけなので、もし重たい処理が途中で詰まってしまうと、その間に他の処理も待たされてしまうというJS特有の問題が実はあったりします...

なのでJavaScriptで非同期処理を使う時は非同期処理は便利だけど万能ではなく、時間のかかる処理をうまく分散させる工夫が必要ということになります。

※ Web Workers(ブラウザ)や Worker Threads(Node.js)を使えば実質マルチスレッド的な動作が可能だったりしますが、かなり発展的な内容なので ここでは扱わないものとします。

非同期処理の重要性

非同期処理が必要とされる理由は、大きく以下の3つにまとめられます。

  1. ユーザー体験の向上 時間のかかる処理を待たずに他の操作ができるため、快適な利用体験を提供できる。

  2. パフォーマンスの向上 複数の処理を並行して進めることで、結果的に処理全体を早く終わらせられる。

  3. リソースの有効活用 CPUやネットワークを効率的に使い、無駄なく処理を回せる。

このように、非同期処理はユーザーにとっての「快適さ」と、システム全体の「効率性」を両立させる重要な仕組みだと言えます。


コールバック関数とその課題

コールバックの基本

JavaScriptが広く使われ始めた2000年代前半〜2010年代にかけては、非同期処理といえば「コールバック関数」が中心でした。
コールバック関数とは「処理が終わったら呼び出す関数」を引数として渡す方法で、setTimeout()addEventListener()、そしてAjax通信で利用される XMLHttpRequest などが代表的です。

その後、2015年に ES6(ECMAScript 2015) が登場し、Promiseが標準として導入されたことで、コールバック中心の時代は大きく変化していきました。


賢くなった気になれる補足:ECMAScriptとは?

ここで出てきた「ECMAScript」は、JavaScriptの仕様を定める国際的な標準規格のことです。
つまり「JavaScript」という名前の言語そのものではなく、「JavaScriptが従うべきルールブック」のようなものです。

  • JavaScript = 実際にブラウザやNode.jsで動くプログラミング言語
  • ECMAScript = その言語仕様を定めた標準規格

たとえば「ES6」という呼び方は「ECMAScriptの第6版」を意味しており、2015年に公開されたバージョンなので ECMAScript 2015 とも呼ばれます。
このバージョンでPromiseやクラス構文などが追加され、JavaScriptの書き方が大きく進化しました。


賢くなった気になれる補足:仕様と実装の違いについて

ECMAScriptはあくまで「こういう動きをしなければならない」という最低限のルールを定めた仕様書です。
一方で、各ブラウザ(Chrome、Firefox、Safariなど)はその仕様に従いながらも、一部は自由に実装していい部分があったりします

そのため、同じJavaScriptコードでもブラウザごとに挙動が少し異なったり、機能の対応状況が違う場合があります。
これがいわゆる「クロスブラウザ対応」が必要とされる理由のひとつです。


賢くなった気になれる補足:クロスブラウザ対応とは?

クロスブラウザ対応とは、異なるブラウザ環境でも同じようにアプリやWebサイトが正しく動作するようにすることを指します。

かつては、あるブラウザでは動くのに別のブラウザではレイアウトが崩れる、特定の機能が使えない...といった問題がよく発生しました。 (私自身はまだ歴浅いので聞いたり調べたりした範囲での解釈ですが...)

近年は標準化の努力により差は小さくなりましたが、今でも新しいAPIや機能は「対応ブラウザを確認して使う」ことが大切です。
ブラウザ対応状況は Can I use のようなサイトで調べられるので、開発の際に役立ちますよ〜

...話が脱線しましたが、コールバックの続きを見ていきましょう!

コールバック地獄(Callback Hell)の発生

非同期処理といえば「コールバック関数」が中心という時代において非同期処理が複雑になると、コールバック関数が深くネストする 「コールバック地獄」 と呼ばれる状態が発生しました。

例えば「タスク1 → タスク2 → タスク3」を順番に実行したいケースだと本来は単純な流れのはずですが、コールバックを使って書くと次のようになります。

function asyncTask1(callback) {
  setTimeout(() => {
    console.log('Task 1 completed');
    callback(null, 'result1');
  }, 1000);
}

function asyncTask2(result1, callback) {
  setTimeout(() => {
    console.log('Task 2 completed with', result1);
    callback(null, 'result2');
  }, 1000);
}

function asyncTask3(result2, callback) {
  setTimeout(() => {
    console.log('Task 3 completed with', result2);
    callback(null, 'final result');
  }, 1000);
}

// コールバック地獄の例
asyncTask1((err, result1) => {
  if (err) {
    console.error('Task 1 error:', err);
    return;
  }
  asyncTask2(result1, (err, result2) => {
    if (err) {
      console.error('Task 2 error:', err);
      return;
    }
    asyncTask3(result2, (err, finalResult) => {
      if (err) {
        console.error('Task 3 error:', err);
        return;
      }
      console.log('Final result:', finalResult);
    });
  });
});

えー... 誰が見ても分かりにくいコードになりました。もう読みたくないですね。

このように、処理が増えるたびにインデントが深くなり、右側にどんどんコードがずれていきます。 これが「コールバック地獄」と呼ばれる所以です。

(※コールバック地獄じゃなくても右側にどんどんネストするコードは読みにくいです。)


コールバック地獄の問題点

コールバック地獄は、以下のような深刻な問題を引き起こしました。

  1. 可読性の低下:ネストが深くなるほど、コードの構造が把握しにくくなる(右側にコードが膨れ上がる)
  2. 保守性の低下:コードの変更やデバッグが困難になる
  3. エラー処理の複雑化:各コールバック関数内でエラー処理を行う必要があるため、コードが冗長になる
  4. 制御フローの把握が困難:非同期処理の実行順序や依存関係が分かりにくくなる

try-catchでキャッチできない非同期エラー

さらに、コールバック関数を使った非同期処理では、エラーハンドリングにも問題がありました。

// setTimeoutの中身はtry...catchブロックを抜けてから実行されるため
// エラーをキャッチできない
try {
  setTimeout(() => {
    throw new Error('エラーだよ');
  }, 1000);
  console.log('setTimeoutを呼び出しました');
} catch (error) {
  // このcatchブロックは実行されない
  console.error('catchでエラー!', error);
}

簡単に説明すると

  1. try { ... } が実行される
  2. setTimeout() が呼ばれる(この時点ではまだコールバック関数は実行されない)
  3. console.log('setTimeoutを呼び出しました'); が出力される
  4. try ブロックを抜けたあと、1秒後に setTimeout 内の関数が実行される
  5. その関数の中で throw new Error('エラーだよ') が発生する
  6. しかしこれは 既に try-catch のスコープ外 なので、catch には届かない

という感じになり、非同期処理内で発生したエラーは外側のtry-catch文で捉えることができない問題が発生します。

そのため、各コールバック関数内で個別にエラー処理を行う必要がありました 。 以下のような感じです。

function task1(callback) {
  setTimeout(() => {
    // 非同期処理内でエラーを発生
    callback(new Error('Task 1 error'));
  }, 500);
}

try {
  // 外側で try-catch しても非同期のエラーは拾えない
  task1((err) => {
    if (err) {
      console.error('Task 1でエラー:', err);
      return;
    }
    console.log('Task 1 completed');
  });
} catch (error) {
  // ここは実行されない
  console.error('外側のcatchで捕捉:', error);
}

Promiseの誕生と進化

コールバック地獄の問題を解決するために、よりシンプルで扱いやすい非同期処理の仕組みが求められました。
そこで2015年の ES6(ECMAScript 2015) で登場したのが Promise です。

Promiseは「非同期処理の結果」を表すオブジェクトで、成功か失敗かを後から受け取れる仕組みを提供します。


Promiseの特徴

Promiseには、コールバックにはなかった以下のような特徴があります。

  • 結果をオブジェクトとして扱える
    非同期処理の成功・失敗を「値」として受け渡せる
  • .then().catch() を使った直線的な記述
    ネストが深くならず、コードを上から下へ自然に読める
  • エラー処理をまとめられる
    .catch() で一括して例外処理を記述でき、個別に書かなくて済む
  • 複数の非同期処理をまとめて扱える
    Promise.all()Promise.race() で並列処理・競合処理を簡単に書ける
  • 非同期処理の「状態」を持つ
    pending(処理中)fulfilled(成功)rejected(失敗) という明確なライフサイクルを持つ

Promiseを使った非同期処理の例

先ほどの「コールバック地獄」の例と比べて、Promiseを使うとコードの見通しがぐっと良くなります。
例えば、データ取得処理を行う場合、次のようにシンプルに書けます。

// fetch APIは既にPromiseを返すため、シンプルに記述できる
function fetchData(url) {
  return fetch(url)
    .then((response) => response.json())
    .catch((error) => {
      console.error(error);
      throw error; // エラーを再スロー
    });
}

fetchData('data.json')
  .then((data) => console.log('取得したデータ:', data))
  .catch((error) => console.error('エラーが発生しました:', error));

Promiseを使うことで、ネストが深くならず、処理の流れが上から下へ素直に読める形になります。 さらに、.catch() を使えば エラー処理を一箇所にまとめられるため、可読性と保守性が大きく向上します。


複数の非同期処理を扱う例

少し先の発展的な内容(このあたりものちのシリーズで取り上げます!)になるのですが、Promiseの強みのひとつは「複数の非同期処理をまとめて制御できる」ことです。

例えば、3つのデータを同時に取得して、すべて完了したら処理を続けたい場合には Promise.all() が使えます。

Promise.all([
  fetch('/data1.json').then((res) => res.json()),
  fetch('/data2.json').then((res) => res.json()),
  fetch('/data3.json').then((res) => res.json())
])
  .then(([data1, data2, data3]) => {
    console.log('すべて取得完了:', data1, data2, data3);
  })
  .catch((error) => {
    console.error('どれかが失敗しました:', error);
  });

さらに進化したasync/await

Promiseによって非同期処理が扱いやすくなった後、さらにコードを簡潔に書ける構文として async/await が登場しました。
async/awaitは、非同期処理を「まるで同期処理のように」書けるのが大きな特徴です。

async function fetchDataAsync() {
  try {
    const response = await fetch('data.json');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(error);
    throw error; // エラーを再スロー
  }
}

// 使用例
fetchDataAsync()
  .then((data) => console.log('取得したデータ:', data))
  .catch((error) => console.error('エラーが発生しました:', error));

async/awaitの特徴

以下のような特徴があります。

  • 同期処理のように書ける
    await を使うことで、Promiseの結果を変数に直接代入でき、処理の流れが直感的になるのは革命的ですね
  • エラー処理がわかりやすい
    try...catch をそのまま使えるため、エラーハンドリングを自然に書けちゃいます
  • ネストが浅くなる
    .then() チェーンを多用せずに書けるので、コードが横に伸びにくい
  • Promiseと相性が良い
    async関数は自動的にPromiseを返すため、.then() / .catch() と組み合わせることも可能です。

賢くなった気になれる補足:async/awaitの裏側

実は、async/await はPromiseの上に成り立っている仕組みです。
先ほどの特徴で Promiseと相性が良い と言っておきながら、裏で動いてるのはPromiseなんかいな!というツッコミは勘弁ください笑

つまり、async/awaitを使ったからといって新しい非同期の仕組みが導入されたわけではなく、Promiseを「より直感的に扱えるようにした構文」なのです。

こういった構文のことをシンタックスシュガー(糖衣構文)といいます。
例えるなら正露丸。独特な匂いのままだと飲みにくいですが、糖衣で包めば飲みやすくなりますよね。
Promiseそのものは少し扱いづらい部分もありますが、async/awaitという“糖衣”をまとうことでぐっと書きやすくなるわけです。

さて、async/awaitには以下のようなルールがあります。

  • async を付けた関数は、必ずPromiseを返す
  • awaitPromiseの完了(成功または失敗)を待って、その結果を返す
  • 失敗した場合は throw が発生し、try...catch で受け止められる

例えば次の2つのコードは、実は同じ意味を持ちます。

// async/await を使った場合
async function getData() {
  const response = await fetch('data.json');
  return response.json();
}

// Promiseチェーンで書いた場合
function getData() {
  return fetch('data.json').then((response) => response.json());
}

見た目は大きく違いますが、裏側ではどちらもPromiseを使っているのです。 この点を理解すると「Promiseの知識がasync/awaitでもそのまま役立つ」ということが分かりますね。


非同期処理を使う上でのベストプラクティス

ここまでで「コールバック → Promise → async/await」と進化の流れを見てきました。
では、実際に開発で非同期処理を扱うときに気をつけるべきポイントを簡単に整理しておきましょう。

  • 処理が増えるときは async/await を基本に
  • 複数の処理を並列で走らせたいときは Promise.all() を活用
  • エラーハンドリングは try...catch を忘れずに
  • 古い環境では動作しない可能性があるのでトランスパイルやポリフィルに注意

このあたりは今後のシリーズ記事でも、より詳しく掘り下げていく予定ですので乞うご期待あれ。


賢くなった気になれる補足:イベントループという仕組み

最後に少しだけ、JavaScriptの非同期処理を支えている「イベントループ」について触れておきます。

最初の方でもお伝えした通りJavaScriptはシングルスレッドですが、非同期処理を扱えるのは イベントループ と呼ばれる仕組みのおかげです。
イベントループは「タスクの順番を管理して、処理が終わったら次の処理を実行する」という交通整理役を担っています。

  • タスクキュー(コールバックキュー)setTimeout などで実行待ちになった処理が溜まる場所
  • マイクロタスクキュー:Promiseの .thenawait 後の処理が溜まる場所

イベントループは、これらのキューからタスクを取り出して順に実行することで、非同期処理が同時に動いているように見えるのです。

この仕組みを理解すると、なぜ「Promiseの方がコールバックよりも制御しやすいのか」や、「await がどのタイミングで実行されるのか」がよりクリアになりますので、一度調べてみては如何でしょうか?


イベントループについて(仕様的な視点から)

イベントループは JavaScript(ECMAScript)を動かす上で非常に重要な仕組みですが、実は ECMAScript の仕様書(ECMA-262)には「Event Loop」という用語や処理アルゴリズムは詳しく規定されていません
この仕組みは、ブラウザなどの ホスト環境(Web API や HTML仕様) が担う部分にあたります。

(ブラウザのイベントループ仕様は HTML仕様の “8.1.7 Event loops” で定義されています。)


まとめ

JavaScriptにおける非同期処理は、Web開発の中で避けて通れない基礎技術です。

  • コールバックから始まり、複雑化による課題(コールバック地獄)が発生
  • それを解決するPromiseの登場
  • さらに書きやすさを追求したasync/await

この流れを押さえておくことで、非同期処理の仕組みがぐっと理解しやすくなると思います。


参考資料

わかりやすい解説記事・動画

公式仕様


もしこの記事が役に立ったら、ぜひシェアしていただけると嬉しいです!

テコテックの採用活動について

テコテックでは新卒・中途採用を積極的に行っています。 採用サイトでは会社の雰囲気や福利厚生、募集ポジションをご確認いただけます。 ご興味をお持ちいただけましたら、ぜひチェックしてみてください。 tecotec.co.jp