コールバック地獄

非同期処理のコールバックが深くネストし、コードの可読性と保守性が著しく低下する状態

JavaScript非同期

コールバック地獄とは

コールバック地獄 (Callback Hell) は、非同期処理のコールバック関数が深くネストし、コードが右に伸びて可読性と保守性が著しく低下する状態である。「ピラミッド・オブ・ドゥーム (Pyramid of Doom)」とも呼ばれる。

getUser(id, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getItems(orders[0].id, (err, items) => {
      if (err) return handleError(err);
      getReviews(items[0].id, (err, reviews) => {
        if (err) return handleError(err);
        // さらにネスト...
      });
    });
  });
});

この問題の本質は、ネストの深さだけではない。エラーハンドリングの分散、制御フローの不透明さ、テストの困難さが複合的に絡み合い、コードの信頼性を根本から損なう点にある。

なぜコールバック地獄が生まれたのか

Node.js が 2009 年に登場した当時、JavaScript にはイベントリスナーとコールバック以外に非同期処理を扱う手段がなかった。ブラウザの DOM イベント処理では 1 段のコールバックで十分だったが、サーバーサイドでは DB 接続 → クエリ実行 → 結果加工 → レスポンス返却のように非同期処理が連鎖する。この連鎖をコールバックで表現すると、必然的にネストが深くなった。

Node.js の初期コミュニティでは「エラーファーストコールバック」(第 1 引数が err) が慣習として定着したが、各コールバック内で if (err) を書く冗長さも問題を悪化させた。

非同期処理の進化 - コールバックから async/await へ

時期 手法 特徴
2009 年〜 コールバック ネストが深くなる、エラー処理が分散
2012 年〜 Promise (ライブラリ) チェーンでフラット化、.catch() で一元的エラー処理
2015 年 Promise (ES2015 標準) 言語仕様に組み込まれ、エコシステムが統一
2017 年 async/await (ES2017) 同期的な見た目で非同期処理を記述、try/catch でエラー処理
// Promise チェーン (2015年〜)
getUser(id)
  .then(user => getOrders(user.id))
  .then(orders => getItems(orders[0].id))
  .then(items => getReviews(items[0].id))
  .catch(handleError);

// async/await (2017年〜)
try {
  const user = await getUser(id);
  const orders = await getOrders(user.id);
  const items = await getItems(orders[0].id);
  const reviews = await getReviews(items[0].id);
} catch (err) {
  handleError(err);
}

async/await はコールバック地獄を根本的に解決した。フラットな構造で、同期的なコードと同じ読みやすさを実現し、エラーハンドリングも try/catch で統一的に行える。

現代でもコールバック地獄に陥るケース

async/await が普及した現在でも、以下の場面でコールバック地獄に似た問題が発生する。

  • イベントリスナーの入れ子: DOM イベントや EventEmitter のリスナー内で非同期処理を行うと、コールバック的なネストが復活する
  • ストリーム処理: Node.js の Readable/Writable ストリームのイベントハンドラは依然としてコールバックベース
  • サードパーティ SDK: 古いライブラリがコールバック API しか提供していない場合がある。util.promisify() で Promise 化して対処する
import { promisify } from 'util';
const readFile = promisify(fs.readFile);
const data = await readFile('/path/to/file', 'utf-8');

よくある誤解

「async/await を使えばすべて解決」と考えるのは早計だ。await を直列に並べると、本来並行実行できる処理まで逐次実行になり、パフォーマンスが低下する。

// ❌ 逐次実行 (遅い): 各処理が前の完了を待つ
const users = await getUsers();
const orders = await getOrders();
const items = await getItems();

// ✅ 並行実行 (速い): 独立した処理は同時に走らせる
const [users, orders, items] = await Promise.all([
  getUsers(),
  getOrders(),
  getItems(),
]);

3 つの API がそれぞれ 200ms かかる場合、逐次実行では 600ms、Promise.all では 200ms で完了する。依存関係のない非同期処理は Promise.all でまとめるのが鉄則だ。

他言語での非同期処理との比較

コールバック地獄は JavaScript 固有の問題ではない。他言語も同様の課題に直面し、それぞれの解決策を発展させてきた。

言語 非同期モデル コールバック地獄の回避策
JavaScript イベントループ + async/await Promise, async/await
Python asyncio async/await (3.5〜)
Rust Future + Tokio async/await (1.39〜)
Go goroutine + チャネル そもそもコールバックを使わない設計
Java CompletableFuture Virtual Threads (21〜) で同期的に記述可能

Go は goroutine とチャネルによるメッセージパッシングを採用し、コールバックという概念自体を排除した。言語設計の段階でコールバック地獄を回避した好例だ。

JavaScript の技術書で、非同期処理の進化 (コールバック → Promise → async/await) として解説されている。

体系的に学ぶなら関連書籍を参照してほしい。

関連用語