まだやってない?Async/Awaitの落とし穴と本当の使い方【2026年版】
async/awaitを何年も書いてきた。Promiseの仕組みもバッチリ。なのに、深夜3時に発生したあの本番障害、結局自分が書いたasync関数が原因だった...。こんな経験、ありませんか?
async/awaitが厄介なのは、見た目がシンプルすぎるからです。まるで同期コードのように見えてしまう。だから同じミスを繰り返してしまうんです。この記事では、2026年になっても開発者を悩ませている落とし穴を一つずつ解説していきます。
入門記事ではありません。async/awaitは知っているけど、正しく使いたい方向けの実践ガイドです。
ループ内のawaitが遅い理由
asyncコードでパフォーマンスを殺す原因No.1がこれです:
async function fetchUserData(userIds) { const users = []; for (const id of userIds) { const user = await fetchUser(id); users.push(user); } return users; }
一見問題なさそうですよね?でも、ユーザーが10人いてfetchUserがそれぞれ100msかかる場合、この関数は1秒かかります。並列処理すれば100msで終わるのに。
何が問題なのか
awaitはPromiseが完了するまで次の行に進みません。ループ内で使うと、各リクエストが順番待ちすることになります。同時に送れるものを一つずつ送っているわけです。
解決策:Promise.allを使う
async function fetchUserData(userIds) { const userPromises = userIds.map(id => fetchUser(id)); const users = await Promise.all(userPromises); return users; }
これで全リクエストが一斉に発火し、一番遅いものだけを待てば良くなります。
でも順次実行が必要な場合もある
async function processPayments(payments) { const results = []; for (const payment of payments) { // 各支払い後に残高が変わるので順番に処理 const result = await processPayment(payment); results.push(result); } return results; }
大事なのは意図しているかどうかです。並列にできるものを間違えて順次処理しないように。
上級編:同時実行数を制限する
Promise.allも万能ではありません。API 1000件を一気に叩くとサーバーが落ちることも。こういう時は同時リクエスト数を制限します:
async function fetchWithConcurrency(urls, concurrency = 5) { const results = []; const executing = new Set(); for (const url of urls) { const promise = fetch(url).then(response => { executing.delete(promise); return response.json(); }); executing.add(promise); results.push(promise); if (executing.size >= concurrency) { await Promise.race(executing); } } return Promise.all(results); }
セマフォパターンを使う方法もあります:
class Semaphore { #queue = []; #running = 0; constructor(concurrency) { this.concurrency = concurrency; } async acquire() { if (this.#running >= this.concurrency) { const { promise, resolve } = Promise.withResolvers(); this.#queue.push(resolve); await promise; } this.#running++; } release() { this.#running--; if (this.#queue.length > 0) { const next = this.#queue.shift(); next(); } } async run(fn) { await this.acquire(); try { return await fn(); } finally { this.release(); } } } // 使用例 const semaphore = new Semaphore(5); const results = await Promise.all( urls.map(url => semaphore.run(() => fetch(url))) );
Unhandled Rejectionでサーバーが死ぬ
Node.js 22以降では、未処理のPromise rejectionがプロセスを終了させます。この変更だけで多くの本番サーバーが落ちました。
静かにやってくる死
async function riskyOperation() { const result = await fetchData(); // ここでエラーする可能性 return result; } // 危険:エラー処理なしで呼び出し riskyOperation();
fetchData()がエラーを投げたらどうなるか?どこにも行きません。try/catchもなければ.catch()もないから。Node.js 15より前は警告が出るだけでした。今は?サーバーが落ちます。
もっと怖いのは発見しにくいこと
app.get('/users', async (req, res) => { const users = await getUsers(); // ここでエラーが起きると... res.json(users); });
Express 4.xでは、これが自動でエラーレスポンスを返しません。リクエストがハングしてタイムアウトになります。Express 5で修正されましたが、まだ4.x使ってるプロジェクト多いですよね。
正しい対処法
1. グローバルハンドラは最後の砦として:
process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection:', reason); Sentry.captureException(reason); });
2. Expressではラッパー関数を使う:
const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; app.get('/users', asyncHandler(async (req, res) => { const users = await getUsers(); res.json(users); }));
3. 境界では必ずtry/catch:
APIルート、イベントハンドラ、cronジョブなど「終端」では必ずエラーを捕捉:
async function cronJob() { try { await performScheduledTask(); } catch (error) { await notifyOnCall(error); // rethrowしない。ここが終端。 } }
Fire-and-Forgetでも.catch()を忘れずに
// ❌ これはダメ async function saveAndNotify(data) { await saveToDatabase(data); sendNotification(data); // awaitしないのはOK、でも... } // ✅ こうする async function saveAndNotify(data) { await saveToDatabase(data); sendNotification(data).catch(err => { console.error('通知送信失敗:', err); }); }
メモリリーク:数日動かすと落ちる理由
asyncコードは思わぬ場所でメモリを食います。数日動かさないと気づかないのが厄介です。
クロージャが大量データを掴んでいると
async function processLargeFile(filePath) { const hugeData = await readEntireFile(filePath); // 500MB return async function getSlice(start, end) { return hugeData.slice(start, end); }; }
返された関数がhugeDataを掴み続けています。小さなスライスしか必要なくても500MBがメモリに残り続けます。
解決策:WeakRef
async function processLargeFile(filePath) { let hugeData = await readEntireFile(filePath); const dataRef = new WeakRef(hugeData); hugeData = null; // 参照を切る return async function getSlice(start, end) { const data = dataRef.deref(); if (!data) { throw new Error('データはGCされました'); } return data.slice(start, end); }; }
イベントリスナーの漏れ
これ、結構見落としがちです:
class DataProcessor { constructor() { this.eventEmitter = new EventEmitter(); } async processWithUpdates(data) { return new Promise((resolve, reject) => { const onProgress = (progress) => { console.log(`Progress: ${progress}%`); }; const onComplete = (result) => { // ⚠️ onProgressを削除してない! resolve(result); }; this.eventEmitter.on('progress', onProgress); this.eventEmitter.on('complete', onComplete); this.eventEmitter.on('error', reject); this.startProcessing(data); }); } }
呼び出すたびにリスナーが積み重なります。1000回呼び出せば3000個のゾンビリスナーが発生。
必ずクリーンアップする
async processWithUpdates(data) { return new Promise((resolve, reject) => { const cleanup = () => { this.eventEmitter.off('progress', onProgress); this.eventEmitter.off('complete', onComplete); this.eventEmitter.off('error', onError); }; const onProgress = (progress) => { console.log(`Progress: ${progress}%`); }; const onComplete = (result) => { cleanup(); resolve(result); }; const onError = (error) => { cleanup(); reject(error); }; this.eventEmitter.on('progress', onProgress); this.eventEmitter.on('complete', onComplete); this.eventEmitter.on('error', onError); this.startProcessing(data); }); }
AbortControllerパターン
モダンなJavaScriptではこっちの方がきれい:
async function fetchWithTimeout(url, timeoutMs = 5000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); return await response.json(); } finally { clearTimeout(timeoutId); } }
レースコンディション:開発環境では動くのに
レースコンディションはタイミングによって挙動が変わるバグです。開発中は問題なく動くのに、本番だけで発生するから厄介。
Reactでよくあるパターン
function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { async function loadUser() { const userData = await fetchUser(userId); setUser(userData); // userIdが変わっていたら? } loadUser(); }, [userId]); return <div>{user?.name}</div>; }
ユーザーが素早く2つのリンクをクリックしたら?両方のリクエストが発火して、先に送ったリクエストが後から返ってくることも。結果として古いユーザーデータが表示されます。
修正:前のリクエストをキャンセル
function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { const controller = new AbortController(); async function loadUser() { try { const userData = await fetchUser(userId, { signal: controller.signal }); setUser(userData); } catch (error) { if (error.name !== 'AbortError') { console.error('ユーザー読み込み失敗:', error); } } } loadUser(); return () => controller.abort(); }, [userId]); return <div>{user?.name}</div>; }
バックエンドのダブルクリック問題
app.post('/orders', async (req, res) => { const { userId, productId } = req.body; // 既存の注文確認 const existing = await Order.findOne({ userId, productId, status: 'pending' }); if (existing) { return res.status(400).json({ error: '既に注文済み' }); } // 注文作成 const order = await Order.create({ userId, productId, status: 'pending' }); res.json(order); });
ユーザーが注文ボタンをダブルクリックすると?2つのリクエストがほぼ同時に到着し、両方とも「既存の注文なし」を確認して、両方とも注文を作成してしまいます。
解決策:DBのユニーク制約
// スキーマで Order.createIndex({ userId: 1, productId: 1, status: 1 }, { unique: true }); // ルートで app.post('/orders', async (req, res) => { try { const order = await Order.create({ userId: req.body.userId, productId: req.body.productId, status: 'pending' }); res.json(order); } catch (error) { if (error.code === 11000) { // 重複キーエラー return res.status(400).json({ error: '既に注文済み' }); } throw error; } });
サーバーが複数台なら分散ロック
import Redis from 'ioredis'; const redis = new Redis(); async function withDistributedLock(key, ttlMs, fn) { const lockKey = `lock:${key}`; const lockValue = crypto.randomUUID(); const acquired = await redis.set(lockKey, lockValue, 'PX', ttlMs, 'NX'); if (!acquired) { throw new Error('ロック取得失敗'); } try { return await fn(); } finally { const script = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end `; await redis.eval(script, 1, lockKey, lockValue); } }
コンストラクタでawaitは使えない
たまにこういうコードを見かけます:
class DatabaseConnection { constructor() { // ❌ 構文エラー! await this.connect(); } async connect() { this.connection = await mongodb.connect(); } }
これは構文エラーです。こう回避する人もいますが:
class DatabaseConnection { constructor() { this.ready = this.connect(); } async connect() { this.connection = await mongodb.connect(); } async query(sql) { await this.ready; return this.connection.query(sql); } }
動くけど、全メソッドでawait this.readyしなきゃいけなくて面倒です。
ファクトリパターンが正解
class DatabaseConnection { static async create() { const instance = new DatabaseConnection(); await instance.connect(); return instance; } async connect() { this.connection = await mongodb.connect(); } async query(sql) { return this.connection.query(sql); } } // 使い方 const db = await DatabaseConnection.create();
Promise.all vs Promise.allSettled
使い分けを間違えると、エラーが消えたり、一つの失敗で全部巻き添えになったりします。
Promise.allは一つでも失敗したら全部失敗
const results = await Promise.all([ fetchUser(1), fetchUser(2), fetchUser(3) ]);
ユーザー2のfetchが失敗すると?成功したユーザー1と3の結果も全部消えます。
Promise.allSettledは全部待ってくれる
const results = await Promise.allSettled([ fetchUser(1), fetchUser(2), fetchUser(3) ]); const users = results .filter(r => r.status === 'fulfilled') .map(r => r.value); const errors = results .filter(r => r.status === 'rejected') .map(r => r.reason);
どっちを使うべきか
- Promise.all: 全部成功しないと意味がない時(トランザクションとか)
- Promise.allSettled: 一部失敗しても残りは処理したい時(大量通知送信とか)
ハイブリッド:個別エラー処理
const results = await Promise.all([ fetchUser(1).catch(err => ({ error: err, id: 1 })), fetchUser(2).catch(err => ({ error: err, id: 2 })), fetchUser(3).catch(err => ({ error: err, id: 3 })) ]); results.forEach(result => { if (result.error) { console.log(`ユーザー${result.id}の取得に失敗`); } });
Async Generator:超便利なのに知られていない
大量データを処理する時、async generatorが最強です:
async function* readLargeFile(path) { const stream = fs.createReadStream(path, { encoding: 'utf8' }); for await (const chunk of stream) { yield chunk; } } // メモリ爆発なしで処理 for await (const chunk of readLargeFile('huge.txt')) { await processChunk(chunk); }
デバッグのコツ
1. 名前付き関数を使う
// ❌ スタックトレースで何かわからない const result = await somePromise.then(x => x.map(y => y.value)); // ✅ エラーが起きた場所がすぐわかる const result = await somePromise.then(function extractValues(items) { return items.map(function getValue(item) { return item.value; }); });
2. トレース用IDを入れる
async function debuggableOperation(input) { const startTime = performance.now(); const operationId = crypto.randomUUID().slice(0, 8); console.log(`[${operationId}] 開始:`, input); try { const result = await doSomething(input); console.log(`[${operationId}] 完了 (${performance.now() - startTime}ms)`); return result; } catch (error) { console.error(`[${operationId}] 失敗 (${performance.now() - startTime}ms):`, error); throw error; } }
3. Promiseの状態を確認する
Promiseの状態は直接見れませんが、raceを使えば確認できます:
async function getPromiseState(promise) { const sentinel = Symbol('pending'); const result = await Promise.race([ promise, Promise.resolve(sentinel) ]); if (result === sentinel) { return 'pending'; } return 'resolved'; }
4. async_hooksで非同期をトレース
Node.jsのasync_hooksモジュールで非同期処理を追跡できます:
import async_hooks from 'async_hooks'; import fs from 'fs'; const contexts = new Map(); const hook = async_hooks.createHook({ init(asyncId, type, triggerAsyncId) { fs.writeSync(1, `${type} 作成: ${asyncId} (親: ${triggerAsyncId})\n`); contexts.set(asyncId, { type, parent: triggerAsyncId }); }, destroy(asyncId) { contexts.delete(asyncId); } }); hook.enable();
テストで気をつけること
awaitを忘れるとテストが偽の成功になる
// ❌ これは常に通ってしまう it('ユーザーを取得できる', async () => { fetchUser(1).then(user => { expect(user.name).toBe('Alice'); }); }); // ✅ こうする it('ユーザーを取得できる', async () => { const user = await fetchUser(1); expect(user.name).toBe('Alice'); });
rejectionのテスト
it('不正な入力でエラーを投げる', async () => { await expect(fetchUser(-1)).rejects.toThrow('Invalid ID'); });
Fake Timerとasyncの併用
// Fake timerはasyncテストを壊すことがある jest.useFakeTimers(); it('5秒後にタイムアウトする', async () => { const promise = fetchWithTimeout('/slow'); // タイマーを進める jest.advanceTimersByTime(5000); // マイクロタスクキューも処理する必要がある await jest.runAllTimersAsync(); // Jest 29+ await expect(promise).rejects.toThrow('Timeout'); });
見落としがちなパフォーマンス問題
マイクロタスクキューの爆発
await一つにつきマイクロタスクが一つ生成されます。タイトなループではI/Oがブロックされることも:
// マイクロタスクキュー爆発 async function processItems(items) { for (const item of items) { await processItem(item); // 毎回マイクロタスク生成 } }
CPU処理とI/Oが混在している場合、途中で譲るべきです:
async function processItems(items) { for (let i = 0; i < items.length; i++) { await processItem(items[i]); // 100件ごとにI/Oに譲る if (i % 100 === 0) { await new Promise(resolve => setImmediate(resolve)); } } }
V8最適化の限界
V8はasync関数を同期関数ほど最適化できないことがあります。ホットパスでは:
// ホットパス:可能なら同期で function getValue(cache, key) { const cached = cache.get(key); if (cached !== undefined) { return cached; // 同期リターン } return fetchValue(key).then(value => { cache.set(key, value); return value; }); }
キャッシュヒット時に同期で返すことで、Promiseのオーバーヘッドを避けられます。
まとめ:asyncを使いこなすマインドセット
async/awaitを上手く使うには、パターンを覚えるだけでなく流れを理解することが大事です。
ポイントをまとめると:
- 基本は並列: 特別な理由がなければ
Promise.allを使う - エラーは必ず捕捉: 境界では誰が処理するか決める
- リソースは自分で片付ける: リスナー、タイマー、コネクションは勝手に消えない
- レースコンディションはどこにでもいる: 最初から同時アクセスを考慮して設計
- 失敗ケースもテストする: rejection、タイムアウト、部分的な失敗も確認
この記事で取り上げたバグは全部、実際の障害から来ています。最初は全部問題なさそうに見えていました。それがasyncの怖いところです—構文が複雑さを隠してしまう。
でも一度理解すれば、ちゃんとコントロールできます。深夜3時に起こされることなく、安心して眠れるコードを書いていきましょう。
コードには意図を込めて。エッジケースもテストして。そしてrejectionは必ず処理してください。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう