fetch-coalescer
v1.0.0
Published
Coalesce duplicate GETs to the same URL at the fetch layer (in-flight merging + short TTL cache). Ships with defaults for Auth.js / NextAuth session; works for any fetch-based duplicate-request pattern via path/match.
Maintainers
Readme
fetch-coalescer
window.fetchwrapper: merge overlapping GETs to the same URL into one in-flight request (coalescing) and serve a short TTL in-memory replay so duplicate callers don’t multiply network work. Intended for small JSON responses; not NextAuth-specific code — it never imports NextAuth.
Defaults target Auth.js / NextAuth: path is /api/auth/session. The most common motivation is NextAuth v5 SessionProvider firing two session fetches on mount (BroadcastChannel echo); this fixes that without monkey-patching NextAuth. Any duplicate fetch pattern to a configured URL benefits — use path, paths, or match for your routes.
- Zero dependencies
- Works with
[email protected]/@auth/*session clients and other libraries that duplicate GETs to the same URL - Tiny (~2 KB min+gzip)
- Safe with
AbortSignal: one caller's abort never kills the shared request or other joined callers - Supports cross-origin auth endpoints and custom matchers
- Honors explicit cache-bypass (
cache: "no-store"/"reload"/"no-cache",Cache-Control: no-cache/no-store) - Explicit
clearCoalescerCache()for post-signOut()/signIn()invalidation - Optional
paths: string[]andgetCoalescerStats() - SSR-safe: no-op on the server; never patches
globalThis.fetchin Node - ESM + CJS, with full TypeScript types
- Install once at module scope; forget about it
Migrating from nextauth-session-dedupe
Published as nextauth-session-dedupe through v0.3.0. fetch-coalescer v1.0.0 changes the package name and uses generic API identifiers (same behavior).
1. Package
npm uninstall nextauth-session-dedupe
npm install fetch-coalescer2. Renames (replace in your codebase):
| Before | After |
| --- | --- |
| installSessionFetchDedupe | installFetchCoalescer |
| clearSessionCache | clearCoalescerCache |
| getSessionFetchDedupeStats | getCoalescerStats |
| SessionFetchDedupeOptions | FetchCoalescerOptions |
| SessionFetchDedupeStats | CoalescerStats |
| SessionMatchContext | CoalescerMatchContext |
After you publish fetch-coalescer, deprecate the old name (from an account that owns the package):
npm deprecate nextauth-session-dedupe@latest "Renamed to fetch-coalescer. Use: npm install fetch-coalescer"Why this exists (NextAuth / Auth.js)
NextAuth v5's SessionProvider does this on mount:
- Calls
getSession()→ fetches/api/auth/session(expected). - When that fetch resolves,
getSession()posts a message on a freshBroadcastChannel("next-auth")so other tabs can sync. SessionProviderlistens on a separate cached instance of the same channel. Because they're different instances, the message delivers into the same tab and triggers a second/api/auth/sessionfetch a few ms later.
That second request is redundant in the single-tab case. This package intercepts it.
Install
npm install fetch-coalescerUsage
Option A — auto-install (simplest)
// Top of a "use client" module, e.g. app/providers.tsx in Next.js
import "fetch-coalescer/auto";That's it. The wrapper is installed at module load, before any React component mounts, with default options.
Option B — explicit install (configurable)
"use client";
import { installFetchCoalescer } from "fetch-coalescer";
// Run at module scope so it's ready before SessionProvider mounts.
if (typeof window !== "undefined") {
installFetchCoalescer({
// path: "/api/auth/session", // default
// ttlMs: 1500, // default
// debug: false, // default
});
}Full Next.js App Router example
// app/providers.tsx
"use client";
import { SessionProvider } from "next-auth/react";
import "fetch-coalescer/auto";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider
refetchOnWindowFocus={false}
refetchInterval={0}
>
{children}
</SessionProvider>
);
}// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Combine the dedupe with refetchOnWindowFocus={false} and refetchInterval={0} for a fully quiet /api/auth/session — one request on first load, zero after.
Next.js Pages Router example
// pages/_app.tsx
import "fetch-coalescer/auto";
import { SessionProvider } from "next-auth/react";
import type { AppProps } from "next/app";
export default function App({
Component,
pageProps: { session, ...pageProps },
}: AppProps) {
return (
<SessionProvider
session={session}
refetchOnWindowFocus={false}
refetchInterval={0}
>
<Component {...pageProps} />
</SessionProvider>
);
}Plain React (Vite / CRA / any SPA)
Anywhere in your client entry — main.tsx, index.tsx, or the module that renders your app root — add the import before rendering:
import "fetch-coalescer/auto";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(<App />);Verifying the install
Enable debug temporarily to confirm the wrapper is intercepting. You should see a console.debug each time the second session fetch is served from cache or coalesced onto the first:
"use client";
import { installFetchCoalescer } from "fetch-coalescer";
if (typeof window !== "undefined") {
installFetchCoalescer({ debug: true });
}In DevTools → Network, with throttling off, you should see exactly one GET /api/auth/session on initial page load instead of two.
Options
| Option | Type | Default | Notes |
| --------- | ------------------------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| path | string | /api/auth/session | Pathname matched with endsWith — apps with a basePath (e.g. /my-app/api/auth/session) work without extra config. Pass an absolute URL (https://auth.example.com/api/auth/session) to dedupe a cross-origin session endpoint. Ignored when match is provided, or when paths is non-empty. |
| paths | string[] | — | Multiple patterns with the same rules as path. When non-empty, takes precedence over path. Each distinct request URL gets its own TTL cache and in-flight slot — responses are not shared across URLs. |
| match | (ctx) => boolean | — | Custom matcher. Receives { url: URL, method: string, sameOrigin: boolean } and returns true to dedupe. Fully replaces path. Non-GET requests are always passed through regardless. |
| ttlMs | number | 1500 | How long a successful response is reused. Short on purpose — just long enough to absorb the BroadcastChannel echo and React Strict Mode double-mounts. |
| debug | boolean | false | When true, logs a console.debug every time a request is served from cache, joined onto an in-flight promise, or bypassed because the caller opted out of caching. |
Observability
getCoalescerStats() returns a snapshot of { hits, misses, coalesced, cacheClears }:
- hits — responses served from the in-memory TTL cache
- misses — new upstream fetches started (not joins)
- coalesced — callers that awaited an existing in-flight request
- cacheClears — how many times
clearCoalescerCache()ran while installed
Returns all zeros on the server or when the wrapper is not installed.
How it works
On window.fetch(input, init):
- If the request isn't a
GETmatched bypath/paths/match, pass through untouched. - If the caller opted out of caching (
cache: "no-store" | "reload" | "no-cache"or aCache-Control: no-cache/no-storerequest header), pass through untouched. - If the caller's
AbortSignalis already aborted, reject immediately with anAbortError(matching nativefetch). - If a successful response for this exact request URL (
origin+pathname+search) is in the cache and younger thanttlMs, return a freshResponsebuilt from the cached body. - If a network request is already in flight for that same URL key, await the shared promise and return a
.clone(). Each caller'sAbortSignalis honored individually — aborting one caller does not cancel the shared upstream request or affect other joined callers. - Otherwise, issue the real request, store the shared promise in the per-URL in-flight map, buffer the body on success, and return the response.
Errors (response.ok === false) are not cached, so transient failures re-hit the network on the next attempt.
AbortSignal semantics
The shared upstream network request is deliberately detached from every caller's AbortSignal. Each caller's returned promise rejects with an AbortError when their own signal fires, but the network request itself continues so that other concurrent and future callers (and the TTL cache) still benefit.
This is the safe choice for a session endpoint: the request is tiny, always needed, and losing the ability for a single caller to cancel it is vastly better than letting one caller's cancellation cascade into failures for every other consumer on the page.
FAQ
Does this break cross-tab sign-in / sign-out sync?
No. The cache TTL is 1.5 seconds by default. A user signing in or out in another tab happens on human timescales (at minimum many seconds), far outside the window. When the other tab broadcasts its session change, this tab's SessionProvider refetches, the cache has expired, and the new session propagates normally.
What if I run my app under a basePath?
The default path is matched with endsWith, so /my-app/api/auth/session matches automatically. If you need a different match rule, pass path explicitly, or use match for full control.
What if my auth endpoint is on a different origin?
Pass an absolute URL as path:
installFetchCoalescer({
path: "https://auth.example.com/api/auth/session",
});The wrapper will dedupe requests whose origin matches auth.example.com and whose pathname ends with /api/auth/session. Same-origin requests to unrelated paths are untouched.
Do I need this if I only use auth() on the server?
No. The duplicate request is a behavior of the client-side SessionProvider. If your app doesn't use SessionProvider (or useSession, getSession, etc.), the client never hits /api/auth/session and there's nothing to dedupe. Importing this package in that case is harmless — every entry point is guarded by typeof window !== "undefined" and does nothing on the server — but it's also unnecessary.
How does this interact with AbortController / AbortSignal?
The upstream network request is detached from every caller's signal, so aborting one caller never cancels the shared request or affects other joined callers. Each caller's returned promise still honors its own signal and rejects with an AbortError when aborted, matching native fetch semantics. In practice NextAuth's internal getSession() doesn't pass a signal, but this keeps the wrapper safe for any library that might (React Query, SWR, custom code).
How do I force a fresh network request?
Pass cache: "no-store" (or "reload" / "no-cache"), or a Cache-Control: no-cache / no-store header. These requests bypass the dedupe entirely and hit the network directly.
What about signOut() / signIn()?
The cache TTL is 1.5 seconds by default. If your app calls getSession() (or hits /api/auth/session directly) within that window after sign-out or sign-in, it may be answered from the stale pre-signout response.
This is a UX consistency window, not a security issue — the server has already revoked the session, and the stale read expires within ttlMs. For apps that want zero such window, call clearCoalescerCache() explicitly:
import { signOut, signIn } from "next-auth/react";
import { clearCoalescerCache } from "fetch-coalescer";
await signOut({ redirect: false });
clearCoalescerCache();
await signIn("credentials", { redirect: false, /* ... */ });
clearCoalescerCache();clearCoalescerCache() clears the in-memory cache (all URL keys) without uninstalling the wrapper. The next matching request hits the network and repopulates the cache with the new state. It's a safe no-op on the server and when the wrapper is not installed.
Note: if a session fetch was already in flight at the moment you call clearCoalescerCache(), any response that lands from it is delivered to its existing callers but is not written to the cache. New callers that arrive after invalidation start a fresh upstream request rather than joining onto the soon-to-be-discarded one. This means you can temporarily see multiple concurrent session fetches immediately after clearCoalescerCache() — this is intentional and the tradeoff for guaranteed freshness.
Is it safe to call installFetchCoalescer() more than once?
Yes. The second and subsequent calls only refresh path, paths, match, ttlMs, and debug on the existing wrapper. They do not re-wrap window.fetch.
Can I uninstall?
installFetchCoalescer() returns an uninstall function that restores the original fetch. Most apps never need it; the wrapper is designed to live for the page's lifetime.
Does it work with SWR / React Query / any other library that calls useSession internally?
Yes. This library dedupes at the fetch level, not at the NextAuth level, so any caller that hits /api/auth/session benefits — including libraries you don't control.
Why not just set SessionProvider session={...} to pre-hydrate?
You can, but it forces your root layout to be dynamic (await auth() there calls cookies()), which disables static rendering for every page in the tree. This package is the escape hatch when you want to keep the layout static.
License
MIT
