awesome-hacks
Docs

非同期処理の基礎 — コールバック・Promise・async/await の違いと読み方

JavaScript/Node.jsの非同期処理を、コールバック→Promise→async/awaitという進化の流れで理解し、他人のコードを読み書きできる状態を作る

最終更新:2026/07/10

TL;DR

  • 非同期処理とは「時間のかかる処理の完了を待たずに次のコードを進め、完了したら結果を受け取る」書き方のこと。JavaScriptは基本的にシングルスレッドなので、これがないとファイル読み込みやAPIリクエストの間、他の処理が全部止まってしまう
  • コールバックPromiseasync/await は同じ非同期処理を書くための別の書き方であり、内部的にはPromiseがベースになっている。新しく書くコードは基本的に async/await でよい
  • 他人のコードを読むときに詰まりやすいのは「これは同期処理か非同期処理か」「このエラーはどこでcatchされるか」の2点。まずこの2つを意識して読めば大体のコードは追える

この記事でやること

コールバック、Promise、async/await の3つを、なぜ生まれたか・何が違うか・どう読むかという観点で順に説明する。ゴールは、業務でよく出てくる非同期コードを読んでコードレビューできる状態になること。

前提

  • Node.js または ブラウザ上で動くJavaScript/TypeScriptを想定
  • 「関数」「引数」など基本的なJavaScriptの文法は理解している前提

非同期処理とは

同期処理(synchronous)は、コードを書いた順番どおりに、1つの処理が終わるまで次の処理に進まない実行方式。それに対して非同期処理(asynchronous)は、時間のかかる処理を「後で終わったら教えてもらう」形で裏に投げておき、その完了を待たずに次のコードへ進む実行方式である。

言葉だけだと分かりにくいので、実行順序で比較する。

// src/example/sync-vs-async.ts
console.log("1");

setTimeout(() => {
  console.log("2");
}, 1000); // 1秒後に実行される非同期処理

console.log("3");

このコードの出力順は 132 になる。setTimeout は「1秒後にこの関数を実行してほしい」と非同期に依頼するだけで、その場では実行されない。そのため、待たされることなく console.log("3") が先に実行され、1秒後にようやく console.log("2") が実行される。

もし setTimeout が同期処理だったら、出力順は書いた順どおり 123 になり、かつ2が出るまでの1秒間、他の処理は一切進められない。この「待っている間も他の処理を進められる」性質をノンブロッキングと呼ぶ。

なぜ非同期処理が必要か

JavaScriptの実行環境(ブラウザやNode.js)は基本的にシングルスレッドで動く。1本の道路を1台の車しか通れないイメージで、コードは基本的に上から順番に1つずつしか実行できない。

ここで、ファイル読み込みやDBへの問い合わせ、外部APIへのリクエストのような「結果が返ってくるまで時間がかかる処理」をそのまま書くとどうなるか。

graph TD
    A["処理開始"] --> B["DBに問い合わせ"]
    B --> C{"どう書くか?"}
    C -->|同期的に書く| D["結果が返るまで何もできない<br/>画面もフリーズする"]
    C -->|非同期処理を使う| E["問い合わせだけ投げて次に進む"]
    E --> F["結果が返ってきたタイミングで<br/>続きの処理を実行"]

もし同期的に(=結果が返ってくるまで待つ形で)書いてしまうと、その間ブラウザなら画面がフリーズし、Node.jsのサーバーなら他のリクエストを一切処理できなくなる。これを避けるために「時間のかかる処理は裏で進めておいて、終わったら教えてもらう」という非同期処理の仕組みが必要になる。


コールバックによる非同期処理

最も原始的なやり方がコールバック関数である。「処理が終わったら呼んでほしい関数」を引数として渡しておく方式。

// src/example/callback.ts
import fs from "fs";

fs.readFile("data.txt", "utf-8", (err, data) => {
  if (err) {
    console.error("読み込み失敗", err);
    return;
  }
  console.log("読み込んだ内容:", data);
});

console.log("readFileの呼び出し後、すぐここが実行される");

fs.readFile はファイルの読み込みが終わった時点で、渡しておいた (err, data) => {...} を実行する。読み込み中も処理は止まらず、次の console.log が先に実行される。

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

非同期処理を複数つなげたいとき、コールバックの中にコールバックをネストしていくことになる。

// src/example/callback-hell.ts
getUser(userId, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getOrderDetail(orders[0].id, (err, detail) => {
      if (err) return handleError(err);
      console.log(detail);
    });
  });
});

これがコールバック地獄(callback hell)と呼ばれる状態。処理が増えるほどネストが深くなり、エラーハンドリングも各階層で書く必要があって読みにくく・直しにくくなる。この問題を解決するために生まれたのがPromiseである。


Promise

Promiseは「非同期処理の結果(成功か失敗か)を表すオブジェクト」である。処理が完了したら成功(resolve)か失敗(reject)のどちらかの状態になる。

graph LR
    A["pending<br/>実行中・未確定"] -->|成功| B["fulfilled<br/>resolve(値)"]
    A -->|失敗| C["rejected<br/>reject(エラー)"]

Promiseは3つの状態のいずれかを取る。

  • pending … 実行中でまだ結果が確定していない
  • fulfilled … 処理が成功し、値が確定した
  • rejected … 処理が失敗し、エラーが確定した

一度 fulfilled か rejected になったPromiseは、それ以降状態が変わらない。

Promiseを作る

// src/example/promise-basic.ts
function readFilePromise(path: string): Promise<string> {
  return new Promise((resolve, reject) => {
    fs.readFile(path, "utf-8", (err, data) => {
      if (err) {
        reject(err); // 失敗を伝える
      } else {
        resolve(data); // 成功を伝える
      }
    });
  });
}

Promiseを使う:then / catch

Promiseを受け取る側は .then() で成功時、.catch() で失敗時の処理をつなげる。

// src/example/promise-then.ts
readFilePromise("data.txt")
  .then((data) => {
    console.log("読み込んだ内容:", data);
    return readFilePromise("data2.txt"); // Promiseを返すとチェーンできる
  })
  .then((data2) => {
    console.log("2つ目のファイル:", data2);
  })
  .catch((err) => {
    console.error("どこかで失敗した:", err);
  });

先ほどのコールバック地獄と比較すると、.then() を横に並べる形(Promiseチェーン)で書けるためネストが浅くなる。さらに .catch() を1つ置くだけで、チェーンのどこで失敗してもそこに処理が流れてくる。

チェーンの仕組み

チェーン(chain)とは「鎖のようにつなげる」という意味で、Promiseの世界では「.then() を連続してつなげて書けること」を指す。これができるのは、.then()自体が新しいPromiseを返すためである。

graph LR
    P0["readFilePromise()<br/>Promise&lt;string&gt;"] -->|resolve| T1[".then(data =&gt; ...)"]
    T1 -->|戻り値を包んだ<br/>新しいPromise| T2[".then(data2 =&gt; ...)"]
    T2 -->|戻り値を包んだ<br/>新しいPromise| T3[".catch(err =&gt; ...)"]
  • .then() の中で通常の値を return すると、次の .then() にその値がそのまま渡る
  • .then() の中でPromiseを return すると、そのPromiseが解決するまで次の .then() の実行が待たされ、解決した値が渡る(先ほどの例の return readFilePromise("data2.txt") がこれにあたる)
  • どこかの .then() の中で例外が発生する、または reject されたPromiseを返すと、それ以降の .then() はすべてスキップされ、一番近い .catch() に処理が飛ぶ

async/await

Promiseチェーンは、コールバック地獄よりは読みやすいが、.then() が増えてくるとやはり追いにくくなる。async/awaitは、Promiseをあたかも同期処理のように書けるようにする構文である。

// src/example/async-await.ts
async function loadFiles() {
  try {
    const data = await readFilePromise("data.txt");
    console.log("読み込んだ内容:", data);

    const data2 = await readFilePromise("data2.txt");
    console.log("2つ目のファイル:", data2);
  } catch (err) {
    console.error("どこかで失敗した:", err);
  }
}

先ほどの .then() チェーンと処理内容は同じだが、上から下に読めば処理の順番がそのまま追える。ポイントは次の2つ。

  • 関数の先頭に async を付けると、その関数は自動的にPromiseを返す関数になる
  • 関数の中で await を付けると、そのPromiseが解決(resolve/reject)されるまでその関数の中だけ処理を止めて待つ

エラーハンドリングは try/catch

async/awaitでは、Promiseが reject されると await の箇所で例外(throw)が発生した扱いになる。そのため、Promiseの .catch() の代わりに通常の try/catch でエラーハンドリングできる。

sequenceDiagram
    participant Caller as 呼び出し元
    participant Func as async関数
    participant Promise as Promise(非同期処理)

    Caller->>Func: loadFiles() を呼ぶ
    Func->>Promise: await readFilePromise()
    Note over Func: ここで処理が一時停止
    Promise-->>Func: resolve(data) または reject(err)
    alt 成功
        Func->>Func: 続きの処理を実行
    else 失敗
        Func->>Func: catchブロックへジャンプ
    end
    Func-->>Caller: 結果を返す(Promiseとして)

3つの書き方の比較

同じ「非同期でデータを取得してログに出す」処理を3通りで書くと次のようになる。

観点コールバックPromise(.then)async/await
複数の非同期処理を連結ネストが深くなる.then() を横に並べる上から下に順番に書ける
エラーハンドリング各コールバックで個別に書く.catch() で一括try/catch で一括
可読性処理が増えると悪化コールバックよりは良い同期処理に近く読みやすい
内部的な仕組み独自PromiseそのものPromiseの糖衣構文(syntax sugar)

糖衣構文(syntax sugar)とは、内部的な仕組みは変えずに、より書きやすい・読みやすい見た目を提供する構文のこと。async/awaitは内部的にはPromiseそのものであり、await は「Promiseが解決するまで待って、結果を取り出す」という .then() の言い換えにすぎない。


エラーハンドリングの方法

非同期処理のエラーハンドリングは書き方ごとにルールが異なる。ここで3つを並べて整理する。

コールバック:エラーファースト規約

Node.jsの標準APIの多くは、コールバックの第1引数にエラーを渡すという慣習に従っている。これをエラーファースト・コールバック(error-first callback)と呼ぶ。

// src/example/error-first.ts
fs.readFile("data.txt", "utf-8", (err, data) => {
  if (err) {
    // 第1引数にエラーが入っていたら失敗
    console.error(err);
    return;
  }
  // エラーがなければ第2引数以降が結果
  console.log(data);
});

if (err) のチェックを一箇所でも書き忘れると、そのエラーは握りつぶされて誰にも気づかれなくなる。呼び出し箇所すべてで手動チェックが必要な点が、後述の2つとの大きな違い。

Promise:.catch()

Promiseチェーンでは、.then() をいくつつなげても、末尾に .catch() を1つ置くだけでチェーン全体のエラーを一箇所で拾える(詳しくは前述のチェーンの仕組みを参照)。

readFilePromise("data.txt")
  .then((data) => doSomething(data))
  .then((result) => doSomethingElse(result))
  .catch((err) => console.error(err)); // どの.then()で失敗してもここに来る

async/await:try/catch

async/awaitでは、await した処理が reject されると通常の例外(throw)と同じ扱いになるため、複数の await をまとめて1つの try/catch で囲める。

try {
  const data = await readFilePromise("data.txt");
  const result = await doSomething(data);
  const final = await doSomethingElse(result);
} catch (err) {
  console.error(err); // どのawaitで失敗してもここに来る
}

3つの比較

書き方エラーの受け取り方書き忘れた場合
コールバック第1引数の err を毎回チェックエラーが握りつぶされ、何も起きずに処理が止まる
Promise.catch() をチェーン末尾に1つ置く未処理のPromise拒否(unhandled rejection)になる
async/awaittry/catch で囲む例外が呼び出し元へそのまま伝播する

よくある落とし穴

await を書き忘れる

// NG: awaitを忘れるとPromiseオブジェクトそのものが返る
async function getUserName(id: string) {
  const user = getUser(id); // Promise<User> が代入される
  return user.name; // undefined(userはPromiseなので.nameは存在しない)
}
// OK
async function getUserName(id: string) {
  const user = await getUser(id); // User本体が代入される
  return user.name;
}

async が付いた関数を呼ぶときは、必ずPromiseが返ってくることを意識し、値が欲しい場合は await を付ける。

forEach の中で await しても待ってくれない

// NG: forEachのコールバックはawaitしても外側の処理を待たない
async function processAll(ids: string[]) {
  ids.forEach(async (id) => {
    await processOne(id);
  });
  console.log("全部終わった"); // 実際は全部終わる前に実行される
}

forEach は非同期関数を渡しても、その完了を待たずに次のループへ進んでしまう。順番に確実に待ちたい場合は for...of を使う。

// OK: for...ofならawaitがちゃんと機能する
async function processAll(ids: string[]) {
  for (const id of ids) {
    await processOne(id);
  }
  console.log("全部終わった");
}

並行に実行してよいなら Promise.allmap を組み合わせる方法もある。

// OK: 順番を問わず並行実行してよい場合
async function processAll(ids: string[]) {
  await Promise.all(ids.map((id) => processOne(id)));
  console.log("全部終わった");
}

async関数内のエラーをtry/catchし忘れる

await した処理が reject されたのに try/catch がないと、その例外は関数の外へそのまま伝播する。呼び出し元でも catch していないと、未処理のPromise拒否(unhandled rejection)としてプロセスが警告を出したり、Node.jsのバージョンによっては落ちたりする。非同期処理を呼ぶ側では、必ずどこかで catch する経路を用意しておく。


関連