はじめに
Promise.race() は、複数の Promise のうち最初に終わったものの結果を返す API です。非同期処理にタイムアウトを付けたいとき、この API を使う例をよく見かけます。
たとえば、外部 API や DNS 検証に 5 秒のタイムアウトを付けたつもりなのに、プロセスの接続数や DNS 問い合わせがなかなか減らないことがあります。このとき原因になりやすいのが、Promise.race() による「待ち時間の打ち切り」と、元の処理の「キャンセル」を同じものとして扱ってしまうことです。
Promise.race() で起きているのは「待つのをやめる」ことであって、元の処理を止めることではありません。タイムアウトしたように見えても、元の処理はそのまま動き続けることがあります。
この違いは、結果待ちの表示だけを制御したい場面では大きな問題にならないことがあります。一方で、ネットワーク通信や DNS lookup のように、処理の途中でソケットや名前解決の問い合わせを保持する API では、待ち時間だけを打ち切っても元の処理が残り続けます。この記事では、この違いを整理したうえで、本当に止めたい場合に何を使うかを説明します。
以下では、「呼び出し側は終わったように見えるのに、裏では終わっていない」というずれを順に整理します。
結論
-
Promise.race()は早く結果を返すだけで、遅い処理そのものは止めません。 - そのため、タイムアウト後もソケットや DNS 処理が裏で残ることがあります。
- 本当に止めたいなら、
AbortSignalやdns/promisesのResolver#cancel()のようなキャンセル手段を元の処理へ渡す必要があります。 - ここで問題にしたいのは待ち時間だけではなく、裏で走り続ける処理によるリソース消費です。
この記事では、resolve は「Promise が成功として確定すること」、reject は「失敗として確定すること」、pending は「まだ終わっていない状態」を指します。AbortSignal は「この処理を中断してほしい」という通知を渡すための仕組みです。
Promise.race() の挙動
ここでは、Promise.race() が実際に何をしているのかを確認します。これは「競争に勝った Promise の結果を採用する」仕組みであって、「負けた Promise を止める」仕組みではありません。
言い換えると、Promise.race() によるタイムアウトは、元処理のキャンセルではなく、呼び出し側がその結果を待つのをやめるだけです。
先に確認しておきたいのは、Promise 自体には「他の処理を強制的に止める」機能がない、という点です。Promise.race() は、複数の Promise のうち最初に終わったものの結果を返しますが、選ばれなかった側の処理はそのまま残ります。
class TimeoutError extends Error {
constructor(ms: number) {
super(`timed out after ${ms}ms`);
this.name = 'TimeoutError';
}
}
function timeout(ms: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new TimeoutError(ms)), ms);
});
}
function withRaceTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([promise, timeout(ms)]);
}
await withRaceTimeout(slowDnsLookup('example.com'), 5_000);
// 5 秒後にタイムアウト reject されるが、slowDnsLookup の Promise は裏で走り続ける
このコードは説明用の最小形です。呼び出し側を早く reject させることはできますが、promise の元になっている処理そのものは止めません。
また、元の promise が先に resolve / reject した場合でも、timeout() 側の timer は期限まで残ります。実用コードでは clearTimeout() する形にします。ただし、その場合でも元の promise の処理自体がキャンセルされるわけではありません。
タイムアウト後の slowDnsLookup は次のいずれかになります。
- もう少し時間がかかってから resolve / reject される (リソースを解放する)
- ネットワーク失敗で error.code = 'ETIMEDOUT' になる
- 実装やネットワーク条件によって、長時間 pending に見える状態が続く
(2) や (3) のケースで、withRaceTimeout は 5 秒で reject していても、元の Promise は呼び出し側が期待するより長くリソースを保持することがあります。これを大量並列で動かすと、DNS resolver、内部キュー、ソケット、ファイルディスクリプタなどのリソースを圧迫し、プロセス全体の新規接続や名前解決に悪影響を与える可能性があります。
DNS lookup で起こる問題
ここからは、具体例として DNS lookup を扱います。DNS はアプリケーションコードから呼びやすい一方で、待ち時間が外部の名前解決に左右されるため、Promise.race() だけでタイムアウトを付けたときの問題が見えやすい題材です。
Node の DNS API はいくつか種類があります。
-
dns.lookup()は OS の名前解決に寄せた API です。 -
dns.resolveCname()のようなdns.resolve*()系は、Node が DNS query を直接投げる API です。
どちらにしても、Promise.race() だけでは進行中の DNS 問い合わせは止まりません。dns.resolveCname(hostname) を Promise.race() でタイムアウトしても、進行中の DNS query 自体はキャンセルされません。
import dns from 'node:dns/promises';
async function verifyDns(hostname: string): Promise<DnsVerificationResult> {
try {
const cnames = await withRaceTimeout(dns.resolveCname(hostname), 5_000);
return { state: 'verified', resolvedValue: cnames[0] };
} catch (err) {
if (err instanceof TimeoutError) {
return { state: 'pending', errorCode: 'dns_lookup_timeout' };
}
throw err;
}
}
5 秒でタイムアウトして pending を返しても、元の dns.resolveCname の問い合わせ自体は残り続けます。resolver の設定やネットワーク条件によって、呼び出し側が期待するより長く pending のまま残ることがあります。その間は DNS 関連のリソースが消費されます。
100 ドメインを並列に verify する API を 1 秒間隔で叩かれると、毎秒 100 件の DNS lookup が元の処理として残り続け、resolver やソケットの使用量が増えます。ここでいう NXDOMAIN は「そのドメイン名は存在しない」という DNS の応答です。たとえば、応答の遅い権威 DNS に向けた問い合わせが大量に発生すると、resolver 側の負荷が高まりやすくなります。
そのため、外部入力をもとに多数のドメインを検証する処理では、タイムアウトだけでなく、キャンセル、並列数制御、レート制限を合わせて考えることになります。
AbortSignal で元の処理へキャンセルを伝える
ここからは、「止まらない」を「止める」へ置き換える方法を確認します。
fetch は AbortSignal を受け取るので、タイムアウト時に元の処理へ中断を伝えられます。
async function fetchWithTimeout(url: string, ms: number): Promise<Response> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), ms);
try {
return await fetch(url, { signal: ac.signal });
} finally {
clearTimeout(timer);
}
}
fetch 側は signal の abort を受け取ると、通常は AbortError として失敗します。少なくとも、呼び出し側はそのまま待ち続けずに済み、Promise.race() だけで包んだ場合より「裏で処理が走り続ける」問題を抑えやすくなります。
node:timers/promises の setTimeout も signal オプションを受け取ります。Node 16 以降では stable な API として使えます。AbortController を共通の cancel 通知として使えます。
import { setTimeout as setTimeoutPromise } from 'node:timers/promises';
const ac = new AbortController();
setTimeout(() => ac.abort(), 5000);
await setTimeoutPromise(10_000, undefined, { signal: ac.signal }); // 5 秒で AbortError
DNS resolver のキャンセル
ただし DNS だけは少し事情が違います。
少なくとも現在の Node.js API では、dns/promises の resolveCname(hostname) のような関数に AbortSignal を直接渡す引数はありません。前半の dns.resolveCname() はモジュール直下の関数を使っています。一方、キャンセルしたい場合は dns/promises の Resolver インスタンスを作り、そのインスタンスに対して resolver.cancel() を呼びます。
import { Resolver } from 'node:dns/promises';
type NodeError = Error & { code?: string };
class DnsTimeoutError extends Error {
constructor(ms: number) {
super(`DNS lookup timed out after ${ms}ms`);
this.name = 'DnsTimeoutError';
}
}
async function resolveCnameWithCancel(
hostname: string,
timeoutMs: number,
): Promise<string[]> {
const resolver = new Resolver();
const timer = setTimeout(() => {
resolver.cancel();
}, timeoutMs);
try {
return await resolver.resolveCname(hostname);
} catch (err) {
const nodeErr = err as NodeError;
if (nodeErr.code === 'ECANCELLED') {
throw new DnsTimeoutError(timeoutMs);
}
throw err;
} finally {
clearTimeout(timer);
}
}
Resolver.cancel() は進行中の DNS query を打ち切ります。Node.js では、キャンセルされた問い合わせに対応する Promise は code: 'ECANCELLED' の error で reject されるため、ここではそれをタイムアウトとして扱っています。Promise.race() だけで包んだ場合のように、問い合わせが裏で残り続けにくくなります。
より一般化したラッパー
個別対応だけでなく、呼び出し側の形もそろえておくと扱いやすくなります。
Promise.race() のパターンを残しつつ、AbortSignal を内部で使うラッパーが書けます。
export interface CancellableWork<T> {
(signal: AbortSignal): Promise<T>;
}
export async function withDeadline<T>(
work: CancellableWork<T>,
ms: number,
errorMessage = `operation timed out after ${ms}ms`,
): Promise<T> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), ms);
try {
return await work(ac.signal);
} catch (err) {
if (ac.signal.aborted) {
throw new Error(errorMessage);
}
throw err;
} finally {
clearTimeout(timer);
}
}
await withDeadline(
(signal) => fetch(url, { signal }),
5_000,
);
呼び出し側に signal を渡す形にしておくと、対応する API (fetch / setTimeout / dns.Resolver) が中断に対応しているかどうかを呼び出し側でそろえて扱いやすくなります。
ただし、この形にしただけで自動的にキャンセルできるわけではありません。work の内部で signal を対応 API に渡すか、signal.aborted を見て処理を止める実装が必要です。AbortSignal は「止めてほしい」という通知であって、任意の Promise を外側から強制停止する仕組みではありません。
Promise.race() で済む場面 / 済まない場面
Promise.race() は常に避けるべきというわけではありません。問題になりやすいのは、負けた Promise の元の処理がリソースを持ち続ける場面です。
Promise.race() で済むことがある場面
- 短時間で必ず resolve する API: in-memory cache lookup のように 1ms 以内に必ず終わるものです。
- fire-and-forget: 結果を使わない beacon 送信のような処理です。タイムアウトしても気にしない前提なら許容しやすくなります。
別の対策が必要な場面
-
CPU bound な同期処理:
JSON.parse(largeString)のような同期処理は、そもそも実行中にPromise.race()で割り込むことができません。これはPromise.race()で十分というより、別の対策が必要なケースです。タイムアウトで守りたいほど重い処理なら、Worker Thread への分離や入力サイズ制限を検討します。 -
DNS / HTTP / DB など、外部 I/O を伴う処理:
Promise.race()だけでは待ち時間しか打ち切れず、元の処理が残ることがあります。 - 大量並列で実行される処理: 1 件ごとの残り方は小さく見えても、全体ではソケット、内部キュー、名前解決などの負荷につながることがあります。
判断の起点になるのは、「元の Promise がリソースを保持したまま残るかどうか」です。残る可能性があるなら AbortSignal や cancel() を検討し、残りにくい処理なら Promise.race() だけで足りることがあります。
まとめ
Promise.race() は、最初に settle した Promise の結果を返すだけで、他の Promise をキャンセルしません。
そのため、外側でタイムアウトしたように見えても、元の処理がソケットや DNS 問い合わせなどのリソースを保持し続けることがあります。
fetch や node:timers/promises の setTimeout は AbortSignal を受け取れるため、タイムアウト時に元の処理へ中断を伝えられます。DNS のように AbortSignal を直接受け取らない API では、dns/promises の Resolver インスタンスを使い、必要に応じて resolver.cancel() を呼びます。
ここで分けて考えたいのは、タイムアウトを「待つのをやめる」だけで終わらせるのか、それとも「元の処理に中断を伝える」ところまで行うのか、という点です。
なお、キャンセルできるようにするだけで十分とは限りません。DNS / HTTP / DB などの外部 I/O を大量に投げる処理では、タイムアウトとキャンセルに加えて、並列数制限、レート制限、リトライ回数の上限も合わせて設計すると安全です。