Promise をキャッシュして並列処理の重複実行を防ぐ
リテールアプリ共創部の末永です。
取得 API を並列に呼び出す処理では、同じキーのリクエストが同時に走ることがあります。レスポンスが返る前に次のリクエストが始まるため、取得結果をキャッシュするだけでは重複を防げません。
このような場合は、取得結果ではなく Promise 自体を Map に保存することで、進行中のリクエストを共有できます。値をキャッシュする場合との違いと、失敗した Promise を残さないためのエラー処理を紹介します。
並列処理では同じリクエストが重複する
まずは、何もキャッシュせずに呼び出す場合を考えます。API が複数件の一括取得に対応していなければ、次のように Promise.allSettled で1件ずつ並列に取得できます。各リクエストの成功と失敗は、返された結果から個別に確認できます。
const results = await Promise.allSettled(
keys.map((key) => client.fetch(key)),
);
keys に同じキーが複数入っていると、client.fetch もその数だけ実行されます。同じキーは1回だけ取得したいので、最初に思い浮かぶのは取得結果を保存する方法です。
const cache = new Map<string, ResponseData>();
const fetchData = async (key: string) => {
const cached = cache.get(key);
if (cached !== undefined) return cached;
const data = await client.fetch(key);
cache.set(key, data);
return data;
};
順番に呼ばれる場合はこれで重複を防げます。しかし、並列処理では最初の client.fetch が完了する前に、同じキーに対する次の呼び出しが始まります。取得結果がまだ Map に保存されていないため、どちらもキャッシュミスになってしまいます。
await Promise.allSettled([
fetchData("key-1"),
fetchData("key-1"),
]);
値のキャッシュは、取得が完了した後の重複を防げますが、並列に進んでいる取得処理そのものは共有できません。

Promise を保存して進行中の取得を共有する
取得処理が完了する前にもキャッシュを利用できるよう、client.fetch が返した Promise をすぐに保存します。
const cache = new Map<string, Promise<ResponseData>>();
const fetchData = async (key: string) => {
const cached = cache.get(key);
if (cached !== undefined) return cached;
const promise = client.fetch(key);
cache.set(key, promise);
return promise;
};
1つ目の呼び出しは、client.fetch の完了を待つ前に Promise を Map へ保存します。2つ目の呼び出しは Map から同じ Promise を読み、すでに始まっている取得処理の完了を待ちます。同じキーに対するリクエストは1回だけです。

値をキャッシュする場合と Promise をキャッシュする場合では、保存するタイミングが異なります。
| タイミング | 値をキャッシュする場合 | Promise をキャッシュする場合 |
|---|---|---|
| 最初の呼び出しが取得を開始 | キャッシュは空のまま | 開始した取得処理の Promise を保存 |
| 同じキーの呼び出しが開始 | キャッシュミスになり、別の取得を開始 | 保存済みの Promise を読み、同じ取得を待つ |
| 取得が完了 | 結果を保存 | 保存済みの Promise が fulfilled になる |
成功した Promise はそのまま残り、以降の呼び出しでも同じ結果を取得できます。進行中のリクエストをまとめる処理と、取得後の結果キャッシュを同じコードで扱えます。
Promise が失敗したときのエラー処理
Promise は一度 rejected になると、後から fulfilled には変わりません。失敗した Promise を Map に残したままにすると、取得先が復旧しても後続の呼び出しは同じ失敗を受け取り続けます。
取得に失敗した場合は、キャッシュから Promise を削除してエラーを投げ直します。
} catch (error) {
cache.delete(key);
throw error;
}
削除後の呼び出しではキャッシュが見つからないため、client.fetch がもう一度実行されます。エラーは throw し直しているので、その呼び出し自体の失敗は通常どおり呼び出し元へ伝わります。
ここまでのコードでは、キャッシュに Promise が残っている間、同じキーに対する後続の呼び出しもその Promise を利用します。別の Promise に差し替わる経路がないため、失敗時は無条件に削除して問題ありません。
キャッシュを途中で更新する場合は参照を比較する
タイムアウト、強制再取得、手動のキャッシュ無効化などを追加すると、古い Promise が完了する前に新しい Promise がキャッシュへ保存される可能性があります。
たとえば、promise1 の処理中にタイムアウトでキャッシュを削除し、次の呼び出しが promise2 を保存したとします。その後で promise1 が失敗した場合、本来その後処理が削除したいのは失敗した promise1 です。しかし、delete は Promise ではなくキーに対して実行するため、無条件に削除するとその時点で保存されている promise2 まで削除してしまいます。

この場合は、現在のキャッシュと失敗した Promise の参照が一致するときだけ削除します。
} catch (error) {
if (cache.get(key) === promise) {
cache.delete(key);
}
throw error;
}
Promise はオブジェクトなので、=== では同じ Promise インスタンスかどうかを確認できます。この比較により、自分が保存した Promise がまだキャッシュに入っている場合だけ削除できます。
タイムアウトの処理でキャッシュを削除する場合も同様です。古い Promise が設定したタイマーが、後から保存された Promise を削除しないようにします。
setTimeout(() => {
if (cache.get(key) === promise) {
cache.delete(key);
}
}, 3000);
ここでのタイムアウトはキャッシュを削除するだけで、promise1 の処理自体は継続します。
Promise のキャッシュからエラー処理まで続けて書くと、次のようになります。参考までに。
const cache = new Map<string, Promise<ResponseData>>();
const fetchData = async (key: string): Promise<ResponseData> => {
const cached = cache.get(key);
if (cached !== undefined) {
return cached;
}
const promise = client.fetch(key);
cache.set(key, promise);
try {
return await promise;
} catch (error) {
if (cache.get(key) === promise) {
cache.delete(key);
}
throw error;
}
};
最後に
使える場面はあまり多くないかもしれませんが、同じ取得処理が並列に重なる場面ではかなり重宝する実装だと思います。
Promise 自体を保存して進行中の処理まで共有し、必要に応じて参照を比較してから削除する書き方がとても鮮やかで、個人的にかなり好きです。
では👋







