はじめに
Claude Codeとやり取りしていたとき、プログラムの中で完了を待たない処理のことを「fire and forget」と呼んでいて、おそらく初見の用語だったため、気になりました。
また調べていくうちに、普段なんとなくawaitを外すことはあっても、これをパターンとして意識したことがなかったので、まとめて記事として残してみるのも面白いかなと思って書くことにしました。
- 通知を送る
- 監査ログを書く
- 分析イベントを送る
こうした処理は、ユーザーへのレスポンスには直接関係ないはずです。
それでも、レスポンスにとって不要な処理までawaitしてしまい、応答が遅くなっているコードは珍しくないような気がします。
自分でも意識しないととりあえずawaitしてしまうことがあるなぁと、これを機に気付かされました。
この記事では、awaitしないという判断がいつ妥当で、いつ危険なのかを整理してみます。
そして読み終わったら「この処理はfire and forgetでいいな」と判断できるようになることを目標にしています。
対象読者
async/awaitの基本は理解しているが、fire and forgetを意識的に使い分けたことがないエンジニア
(つまりは自分のことです。なんならasync/awaitの基本さえもそこまで自信がないので間違っていたら指摘してください。)
Fire and Forget とは
Fire and Forgetは、もともと軍事用語で「発射したら誘導せずに放置する」ミサイルの方式を指します。
ソフトウェアでは、非同期処理を開始するが、その完了を待たない(awaitしない)パターンのことです。
すなわち、呼び出し元は完了を待たずに次へ進む処理のことを指します。
だからといって「失敗してもいい」と言っているわけではなくて、失敗したら監視やログで気づけるようにしておく、というのがセットです。
// 通常: 完了を待つ
await sendNotification(userId, message);
// Fire and Forget: 投げっぱなし
sendNotification(userId, message);
このようにたった1語 await を外すだけですが、振る舞いは大きく変わります。
基本的な動作 — コードで比較する
具体的にどれくらいの差が出るか、単純な例で違いを確認します。
async function sendNotification(userId: string, message: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log(`[通知送信完了] userId=${userId}`);
}
async function writeAuditLog(action: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log(`[監査ログ書き込み完了] action="${action}"`);
}
通知送信に2秒、ログ書き込みに1秒かかる処理があるとします。
直列await版
async function handleRequest(userId: string): Promise<string> {
await sendNotification(userId, "プロフィールが更新されました");
await writeAuditLog("profile_updated");
return "プロフィールを更新しました";
}
この場合、レスポンスが返るまで 約3000ms かかります。
並列await版(Promise.all)
async function handleRequest(userId: string): Promise<string> {
await Promise.all([
sendNotification(userId, "プロフィールが更新されました"),
writeAuditLog("profile_updated"),
]);
return "プロフィールを更新しました";
}
2つの処理に依存関係がないなら、Promise.all で並列に待てます。これで 約2000ms に短縮されます。
完了を保証しつつ、直列より速くなりましたね。
fire and forgetに飛びつく前に、まずこれで十分じゃないか考えてみるのは大事です。
Fire and Forget版
async function handleRequest(userId: string): Promise<string> {
// ⚠ 説明のため単純化した例。実運用では .catch() か専用ヘルパーが必要
sendNotification(userId, "プロフィールが更新されました");
writeAuditLog("profile_updated");
return "プロフィールを更新しました";
}
こちらはレスポンス経路で通知・ログの完了を待たないため、待ち時間をほぼ加算せずに返せます。
処理自体は同一プロセスのイベントループ上で継続されますが、レスポンスはブロックされません。
ここまでの3パターンを並べてみます。
| 方式 | レスポンス時間 | 完了保証 |
|---|---|---|
| 直列await | 約3000ms | あり |
| 並列await(Promise.all) | 約2000ms | あり |
| Fire and Forget | ほぼ即時 | なし |
※ただし「(直列/並列)awaitか、fire and forgetか」の二択ではありません。実務ではもう1つ重要な選択肢として、「キューに永続化してから返す」方式があります
どこで使うか — 実践的なユースケース
もう少し実務に近い例として、記事公開APIを考えてみます。
このAPIは記事をDBに保存した後、いくつかの副作用を実行します。
-
analyticsへの記録
- 公開イベントを分析基盤に送る
-
Slack通知
- チームのチャンネルに「新記事が出た」と知らせる
-
キャッシュ無効化
- 著者の記事一覧キャッシュを古いまま残さないようにする
-
検索インデックス更新
- 新しい記事が検索に引っかかるようにする
これらの副作用は、すべて同じ重要度ではありません。失敗したときの影響がそれぞれ違います。
その影響を踏まえて実装を以下のようにしてみました。
async function publishArticle(article: Article): Promise<{ success: boolean; url: string }> {
// ---- awaitが必要な処理(レスポンスに直結)----
await saveToDatabase(article);
const url = `https://example.com/articles/${article.id}`;
// ---- best-effortでよい処理 → Fire and Forget(後述するヘルパー関数)----
fireAndForget(() => recordAnalytics("article_published", { articleId: article.id }), "analytics");
fireAndForget(() => notifySlack("tech-blog", `新記事: ${article.title}`), "slack");
// ---- 整合性に関わる処理 → awaitするか、キュー/リトライで保証 ----
await invalidateCache(`author:${article.authorId}:articles`);
await enqueueSearchIndexJob(article.id);
return { success: true, url };
}
ここで分けているのは、副作用ごとの失敗コストです。
-
analytics・Slack通知
- 欠損してもユーザー体験には直接影響が出ない
- →fire and forgetで十分
-
キャッシュ無効化
- 失敗すると古いデータがユーザーに見え続ける
- →awaitして確実に実行する
-
検索インデックス更新
- 即時性は不要だが欠損は困る
- →キューへの投入をawaitして確実に永続化する
- 実際のインデックス構築はキューのワーカーが非同期に処理する
- この「キューに入れてから返す」方式は、後の判断基準で選択肢として出てきます
全部fire and forgetにするのではなく、失敗したときの痛さで扱いを変えるのが大事です。
どういう処理ならfire and forgetにしていいか
- レスポンスに影響しない副作用である
- 失敗しても業務的に致命的ではない
- 多少の遅延や欠損が許容される
- 冪等性がある
- デプロイやリトライで同じリクエストが二重に飛んでも問題ない
- 失敗率を監視できる体制がある
- 欠損を許容する≠見えなくてよい
fire and forgetのリスクと対策
ここからはfire and forgetのリスクに関してです。
エラーが検知できない問題
awaitしていないPromiseがrejectされると、呼び出し元はそれを同期的に検知できません。
async function sendEmail(to: string): Promise<void> {
// 何らかの理由で失敗...
throw new Error(`メール送信失敗: ${to} に到達できません`);
}
function handleRequest() {
// awaitしていないので、このエラーはcatchブロックに入らない
sendEmail("user@example.com");
// 未処理のrejectionは、Node.jsのバージョンや --unhandled-rejections オプションで扱いが異なる。
// 現行のNode.jsでは既定で throw 扱いとなり、プロセス終了につながりうる。
}
これは厄介です。
例えばテスト環境では動くのに、本番でだけ断続的に起きるエラーとして、追跡がかなり面倒になります。
安全な書き方
対策はシンプルで、必ず .catch() をつけることです。
// 各呼び出しに毎回 .catch() を手書きする
sendNotification(userId, message).catch((e) => console.error(e));
writeAuditLog("profile_updated").catch((e) => console.error(e));
notifySlack("tech-blog", text).catch((e) => console.error(e));
これでも動きますが、fire and forgetする箇所が増えるたびに .catch(...) を手書きすることになり、1箇所でも書き忘れると未処理のrejectionになります。
そこで、.catch() のロジックをヘルパー関数にまとめてしまうと、呼び出し側は書き忘れようがなくなります。
const pending = new Set<Promise<void>>();
function fireAndForget(task: () => Promise<void>, context?: string): void {
try {
const p = task().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[fire-and-forget失敗]${context ? ` (${context})` : ""} ${message}`);
// Sentry.captureException(error);
});
pending.add(p);
void p.finally(() => pending.delete(p));
} catch (error) {
console.error(`[fire-and-forget起動失敗]${context ? ` (${context})` : ""}`, error);
}
}
引数をPromiseではなく関数(thunk)にしています。
fireAndForget(doSomething()) のようにPromiseを直接渡す設計だと、Promise生成時に同期的に例外が発生した場合を拾えません。
() => doSomething() として渡せば、try-catchの中でPromise生成まで含めて安全に処理できます。
なお、async function を渡す場合は同期例外がPromiseのrejectに変換されるため、外側のtry-catchが発火することは通常ありません。防御的な措置です。
またpending セットで実行中のタスクを追跡しているのは、後述するgraceful shutdownとテストのためです。
すこし長くなりましたが、この関数を通すことで、以下のような観点で安全になります。
- 未処理のrejectionを防げる
- 同期例外も含めてエラーが記録され、後から調査できる
- 呼び出し側のコードの意図が明確になる
- 「これは意図的にawaitしていない」とわかる
- 実行中のタスクを追跡でき、graceful shutdownやテストで待ち合わせできる
テストでの扱い
fire and forgetはテストが書きづらいパターンです。
awaitしていないので、テスト側から「fire and forgetした処理がいつ終わったか」がわかりません。
assertを書いても、処理がまだ実行中でありタイミング次第で失敗する、ということが起きます。
そこで上で定義した pending セットには「今実行中のfire and forgetタスク」が入っています。
これを全部完了するまで待つ関数(drain = 空にする)を用意すれば、テスト時に安全にassertできるようになります。
async function drainPending(): Promise<void> {
while (pending.size > 0) {
await Promise.allSettled([...pending]);
}
}
export const __test__ = { drain: drainPending };
while ループにしているのは、drain中に新しいタスクが追加されるケースに対応するためです。
// テストコード
it("記事公開時にanalyticsイベントが送信される", async () => {
await publishArticle(article);
await __test__.drain(); // fire and forgetの完了を待つ
expect(analyticsEvents).toContainEqual({ event: "article_published" });
});
ESLintで守る
@typescript-eslint/no-floating-promises ルールを有効にすると、awaitも.catch()もされていないPromiseを検出できます。
fire and forgetを使う場合は void 演算子を付けるか、上記のヘルパー関数を通すことで、意図的な「投げっぱなし」だとlintに伝えられます。
ただし void はあくまでlint上の意図表示にすぎず、実行時のエラーを握りつぶしてくれるわけではありません。安全性のためには .catch() か専用ヘルパーが別途必要です。
ヘルパーを導入するほどでもない小規模なコードでは、以下のようにワンライナーでも良いかもしれません。
void sendNotification(userId, message).catch((e) => console.error(e));
判断基準
迷ったときは、以下の2つの問いに順番に答えてみてください。
この処理は「今このレスポンス」に必要か?
-
YES
- →awaitする(並列化できるなら
Promise.all)
- →awaitする(並列化できるなら
-
NO
- →次の問いへ
この処理が欠損すると、運用・分析・データ整合性で困るか?
-
YES
- →メッセージキューやジョブキューに永続化してから返す(リトライ/DLQで保証)
-
NO
- →fire and forgetの候補
このように選択肢としては「await」「キュー経由」「fire and forget」の3つになるのです。
このなかでfire and forgetは一番手軽ですが、一番保証が弱くなります。このトレードオフを理解したうえで選択するようにしましょう。
注意: プロセスが終わると処理も消える
Fire and Forgetはジョブの保留ではなく、その場で処理を走らせたまま応答だけ先に返す形です。プロセスが終われば、処理も一緒に消えます。
AWS LambdaやCloud Functionsなどのサーバーレス環境では、レスポンスを返した後にプロセスが凍結・終了されることがあります。そうなると、fire and forgetした処理は実行されないまま消えてしまいます。
これはサーバーレスに限った話ではありません。コンテナの再起動、デプロイ、スケールイン、SIGTERMによるプロセス終了など、いずれも同一プロセス内のfire and forgetを途中で失わせます。
そもそもfire and forgetは「同一プロセスのイベントループに載せるだけ」の仕組みです。
プロセスの寿命を超えられないのは当然といえます。
対策の一つとして、graceful shutdownで保留中のタスクをbest effortで待ち合わせる方法があります。先ほど定義した pending セットと drainPending をそのまま使えます。
process.once("SIGTERM", () => {
void (async () => {
console.log(`Waiting for ${pending.size} pending tasks...`);
await drainPending();
process.exitCode = 0;
})();
});
process.once を使い、ハンドラ内で async の即時関数を void で実行しています。SIGTERMリスナーを登録するとデフォルトの終了動作がオーバーライドされるため、drain完了まで待つこと自体は可能です。
ただしKubernetesの terminationGracePeriodSeconds のようなプラットフォーム側のタイムアウトで強制終了される場合があるため、あくまでbest effortです。取りこぼしを減らせる可能性はありますが、完了保証にはなりません。
完了保証が必要なら、メッセージキュー(SQS、Pub/Sub等)やジョブキューに永続化してから返す構成に寄せるほうが安全ですし、よほどのことがない限りこれをまず第一の選択肢とすべきでしょう。
補足: Shift North — アーキテクチャレベルの「投げっぱなし」
fire and forgetの判断を突き詰めると、「そもそもどこでリクエストを受けて、どこまでを同期責務にするか」という設計を考えることにもつながります。
この設計に関して、サーバーレスの文脈ではShift Northという考え方があります。
アーキテクチャ図では一般に上側(North)がクライアント側にあたることから、「トラフィックの終端をできるだけクライアント寄りに持っていく」という意味です。
キューを終端にしてHTTP 202 Acceptedを即返却するパターンや、CDNのキャッシュから返してバックエンドに到達させないパターンが代表的です。
fire and forgetが「プロセス内で投げっぱなし」なら、キュー終端は「永続化してから投げっぱなし」です。
アプリ内でawaitするかどうかだけでなく、「そもそもどこでリクエストを受け止めるか」ということまで視野に入れると、設計の幅はぐっと広がります。
迷ったときのチェックリスト
先ほどの判断基準を参考にざっくり方針を決めたら、実装前にこの5つも確認しておくと安心です。
- その処理の失敗でユーザー体験や整合性が壊れないか
- 失敗を監視・記録できるか
- プロセス終了で消えても許容できるか
- 冪等性があるか
- 将来的にキューへ昇格すべき処理ではないか
すべてクリアなら、fire and forgetで問題ありません。ただ1つでも引っかかるなら、awaitかキュー経由を選ぶべきでしょう。
おわりに
Fire and Forgetはレスポンス速度を手軽に改善できますが、エラーハンドリングをサボると処理が消えて追跡不可能なバグの温床になります。
大事なのは「awaitを外す」こと自体ではなくて、完了保証をレスポンス経路から切り離すという設計判断のほうです。
レスポンスやデータ整合性に影響しない処理なら外し、影響するならawaitかキューで保証するようにしましょう。あと .catch() は絶対につけてください。
既存コードを見直すなら、結果を使っていない(待つ必要がない)のにawaitしている処理から当たるのが手っ取り早いはずです。
おそらく例に挙げたような、通知、監査ログ、分析イベントあたりは候補になりやすいはずです。
そして何より、今回私はそもそも「Fire and Forget」という言葉を知らなかったところから、Claude CodeやCodexにサンプルコードやその概念を説明してもらいながら学ぶことができました。
みなさんもAIとのやり取りでわからない概念や単語などが出てきたら、少し手を止めて調べてみると成長の機会になるはずです。こういう学びの機会を大事にしていきましょう。
FYI: