From 181318adbf20df81876547eea9eb213143bda569 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Mon, 30 Mar 2026 07:57:23 +0300 Subject: [PATCH 1/2] feat: add opt-in request deduplication for concurrent identical requests Add `dedupe` option that coalesces identical concurrent GET/HEAD requests into a single network call, sharing the response between all callers. Only applies to non-payload methods. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fetch.ts | 19 ++++++++++++++++++ src/types.ts | 7 +++++++ test/index.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/src/fetch.ts b/src/fetch.ts index 10c91583..abbee428 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -35,6 +35,7 @@ const nullBodyResponses = new Set([101, 204, 205, 304]); export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { const { fetch = globalThis.fetch } = globalOptions; + const _pendingRequests = new Map>>(); async function onError(context: FetchContext): Promise> { // Is Abort @@ -257,6 +258,24 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { }; const $fetch = async function $fetch(request, options) { + // Request deduplication for concurrent identical requests + if (options?.dedupe && !isPayloadMethod(options?.method)) { + const method = (options?.method || "GET").toUpperCase(); + const key = `${method}:${typeof request === "string" ? request : (request as Request).url}`; + const pending = _pendingRequests.get(key); + if (pending) { + const r = await pending; + return r._data; + } + const promise = $fetchRaw(request, options); + _pendingRequests.set(key, promise); + try { + const r = await promise; + return r._data; + } finally { + _pendingRequests.delete(key); + } + } const r = await $fetchRaw(request, options); return r._data; } as $Fetch; diff --git a/src/types.ts b/src/types.ts index 66a84faf..49750626 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,13 @@ export interface FetchOptions /** Default is [408, 409, 425, 429, 500, 502, 503, 504] */ retryStatusCodes?: number[]; + + /** + * When enabled, identical concurrent requests are coalesced into a single + * network call. The response is shared between all callers. + * Only applies to non-payload methods (GET, HEAD) by default. + */ + dedupe?: boolean; } export interface ResolvedFetchOptions< diff --git a/test/index.test.ts b/test/index.test.ts index 5ac20b07..e1707468 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -524,4 +524,54 @@ describe("ofetch", () => { timeout: 10_000, }); }); + + describe("request deduplication", () => { + it("coalesces identical concurrent GET requests", async () => { + const url = getURL("ok"); + const [r1, r2, r3] = await Promise.all([ + $fetch(url, { dedupe: true }), + $fetch(url, { dedupe: true }), + $fetch(url, { dedupe: true }), + ]); + expect(r1).toBe("ok"); + expect(r2).toBe("ok"); + expect(r3).toBe("ok"); + // Only one actual fetch call + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("does not dedupe without opt-in", async () => { + const url = getURL("ok"); + await Promise.all([$fetch(url), $fetch(url), $fetch(url)]); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it("does not dedupe POST requests", async () => { + const url = getURL("post"); + await Promise.all([ + $fetch(url, { method: "POST", body: { a: 1 }, dedupe: true }), + $fetch(url, { method: "POST", body: { a: 1 }, dedupe: true }), + ]); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("dedupes different URLs separately", async () => { + const [r1, r2] = await Promise.all([ + $fetch(getURL("ok"), { dedupe: true }), + $fetch(getURL("params?x=1"), { dedupe: true }), + ]); + expect(r1).toBe("ok"); + expect(r2).toEqual({ x: "1" }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("allows new request after previous completes", async () => { + const url = getURL("ok"); + await $fetch(url, { dedupe: true }); + expect(fetch).toHaveBeenCalledOnce(); + fetch.mockClear(); + await $fetch(url, { dedupe: true }); + expect(fetch).toHaveBeenCalledOnce(); + }); + }); }); From fe9938086abf5ff9e1cf55c4272913c5960b83ff Mon Sep 17 00:00:00 2001 From: productdevbook Date: Mon, 30 Mar 2026 10:28:04 +0300 Subject: [PATCH 2/2] fix: move dedupe to $fetchRaw level, include query in key, add error test - Move deduplication logic into $fetchRaw so it works for both $fetch() and $fetch.raw() - Compute dedupe key AFTER URL resolution (baseURL + query applied), so different query params get different keys - Extract _executeFetch for the non-dedupe path - Add tests: different query params not coalesced, error propagation, $fetch.raw dedupe support Co-Authored-By: Claude Opus 4.6 (1M context) --- src/fetch.ts | 50 ++++++++++++++++++++++++++++------------------ test/index.test.ts | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 19 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index abbee428..faa2f72d 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -128,6 +128,36 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { } } + // Request deduplication (after URL is resolved with baseURL/query) + if (context.options.dedupe && !isPayloadMethod(context.options.method)) { + const method = (context.options.method || "GET").toUpperCase(); + const requestUrl = + typeof context.request === "string" + ? context.request + : (context.request as Request).url; + const dedupeKey = `${method}:${requestUrl}`; + const pending = _pendingRequests.get(dedupeKey); + if (pending) { + return pending; + } + const execute = async (): Promise> => { + try { + return await _executeFetch(context); + } finally { + _pendingRequests.delete(dedupeKey); + } + }; + const promise = execute(); + _pendingRequests.set(dedupeKey, promise); + return promise; + } + + return _executeFetch(context); + }; + + async function _executeFetch( + context: FetchContext + ): Promise> { if (context.options.body && isPayloadMethod(context.options.method)) { if (isJSONSerializable(context.options.body)) { const contentType = context.options.headers.get("content-type"); @@ -255,27 +285,9 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { } return context.response; - }; + } const $fetch = async function $fetch(request, options) { - // Request deduplication for concurrent identical requests - if (options?.dedupe && !isPayloadMethod(options?.method)) { - const method = (options?.method || "GET").toUpperCase(); - const key = `${method}:${typeof request === "string" ? request : (request as Request).url}`; - const pending = _pendingRequests.get(key); - if (pending) { - const r = await pending; - return r._data; - } - const promise = $fetchRaw(request, options); - _pendingRequests.set(key, promise); - try { - const r = await promise; - return r._data; - } finally { - _pendingRequests.delete(key); - } - } const r = await $fetchRaw(request, options); return r._data; } as $Fetch; diff --git a/test/index.test.ts b/test/index.test.ts index e1707468..57e3e461 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -573,5 +573,41 @@ describe("ofetch", () => { await $fetch(url, { dedupe: true }); expect(fetch).toHaveBeenCalledOnce(); }); + + it("different query params are NOT coalesced", async () => { + const [r1, r2] = await Promise.all([ + $fetch(getURL("params"), { query: { a: "1" }, dedupe: true }), + $fetch(getURL("params"), { query: { a: "2" }, dedupe: true }), + ]); + expect(r1).toEqual({ a: "1" }); + expect(r2).toEqual({ a: "2" }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("propagates errors to all deduped callers", async () => { + const results = await Promise.all([ + $fetch(getURL("403"), { retry: 0, dedupe: true }).catch( + (error_: any) => error_ + ), + $fetch(getURL("403"), { retry: 0, dedupe: true }).catch( + (error_: any) => error_ + ), + ]); + for (const r of results) { + expect(r.status).toBe(403); + } + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("works with $fetch.raw", async () => { + const url = getURL("ok"); + const [r1, r2] = await Promise.all([ + $fetch.raw(url, { dedupe: true }), + $fetch.raw(url, { dedupe: true }), + ]); + expect(r1._data).toBe("ok"); + expect(r2._data).toBe("ok"); + expect(fetch).toHaveBeenCalledOnce(); + }); }); });