[GH-ISSUE #9077] Hundreds of concurrent get-session requests per second #11267

Open
opened 2026-04-13 07:36:52 -05:00 by GiteaMirror · 4 comments
Owner

Originally created by @benhid on GitHub (Apr 9, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/9077

Originally assigned to: @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

Reproduction

While reviewing production logs we noticed that multiple user IPs were each generating an extraordinary number of requests to /api/auth/get-session. We are talking about hundreds of requests per second, in a simultaneous burst, from a single browser tab.

After ruling out browser extensions, service workers, localStorage events, and network-level retries, we believe the root cause is a race condition in query.ts. We've done our best to trace the full chain, but any corrections are very welcome.


The storm requires two conditions to be simultaneously true:

  1. A useSession() consumer whose React fiber is being repeatedly recreated. The most common cause is a <Suspense> boundary with a suspended sibling (any loading.tsx creates one implicitly around the page, and explicit <Suspense> boundaries have the same effect). In our production app, we also observed this happening via Next.js's internal LoadingBoundary between route segments, even without loading.tsx, though this requires enough page complexity to generate chunked RSC streaming and was difficult to isolate in a minimal repro.

  2. A slow get-session response relative to the number of retries. If the server responds in < 50 ms and there are only a handful of retries, the first setTimeout(0) callback resolves, isMounted becomes true, and subsequent callbacks skip the fetch. The storm only manifests when the response is slow enough that isMounted is still false when all queued callbacks drain.

Since the natural retries from RSC streaming require real app complexity to trigger in isolation, the repro below uses a setInterval to simulate them. The mechanism is the same: rapid Suspense boundary retries -> fresh fibers -> store.get() with lc=0 -> onMount -> setTimeout(0) -> fetch.

page.tsx

"use client";

import { lazy, Suspense, useEffect, useState, type ComponentType } from "react";
import { authClient } from "@/lib/auth-client";

function SessionWatcher() {
  authClient.useSession();
  return null;
}

const SlowChunk = lazy<ComponentType>(
  () => new Promise((r) => setTimeout(() => r({ default: () => null }), 30000)),
);

function StormDriver() {
  const [, setTick] = useState(0);

  useEffect(() => {
    const id = setInterval(() => setTick((t) => t + 1), 15);
    return () => clearInterval(id);
  }, []);

  return (
    <Suspense fallback={null}>
      <SessionWatcher />
      <SlowChunk />
    </Suspense>
  );
}

export default function Page() {
  return (
    <main style={{ padding: 40, fontFamily: "sans-serif" }}>
      <h2>How to reproduce</h2>
      <p>
        Open DevTools  Network  filter <code>get-session</code>, then{" "}
        <strong>reload this page</strong>.
      </p>
      <p>
        <strong>Expected:</strong> 1 request.
        <br />
        <strong>Actual:</strong> 1 000+ requests in under 5 seconds.
      </p>
      <StormDriver />
    </main>
  );
}

src/app/api/auth/[...all]/route.ts

import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

const { GET: _GET, POST } = toNextJsHandler(auth);

export async function GET(req: Request) {
  if (req.url.includes("get-session")) {
    await new Promise<void>((r) => setTimeout(r, 30000));
  }
  return _GET(req);
}

export { POST };

Current vs. Expected behavior

Current behavior: A single page load fires hundreds of concurrent get-session requests from one tab.

Expected behavior: One get-session request per mount cycle.

What version of Better Auth are you using?

1.6.2

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.4.0: Thu Mar 19 19:31:09 PDT 2026; root:xnu-12377.101.15~1/RELEASE_ARM64_T8132",
    "release": "25.4.0",
    "cpuCount": 10,
    "cpuModel": "Apple M4",
    "totalMemory": "16.00 GB",
    "freeMemory": "0.10 GB"
  },
  "node": {
    "version": "v25.2.1",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "10.23.0"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "^16.2.3"
    },
    {
      "name": "react",
      "version": "^19.2.5"
    }
  ],
  "databases": null,
  "betterAuth": {
    "version": "1.6.2",
    "config": {
      "emailAndPassword": {
        "enabled": true
      },
      "secret": "[REDACTED]",
      "trustedOrigins": [
        "http://localhost:3000"
      ]
    }
  }
}

Which area(s) are affected? (Select all that apply)

Client

Auth config (if applicable)


Additional context

Root cause

The isMounted guard appears to have a timing gap

packages/better-auth/src/client/query.ts

onMount(value, () => {
  const timeoutId = setTimeout(async () => {
    if (!isMounted) {
      await fn();       // fetch starts here
      isMounted = true; // only flipped after the fetch resolves
    }
  }, 0);
  // ...
});

If multiple setTimeout(0) callbacks are queued before the first fetch resolves, each one would enter the if (!isMounted) branch and start its own fetch, since isMounted hasn't been set to true yet.

How multiple setTimeout(0) callbacks end up queued

The nanostores React binding (useStore) calls store.get() inside useRef during render:

function useStore(store) {
  let snapshotRef = useRef(store.get()); // fires onMount if lc === 0
  // ...
  return useSyncExternalStore(subscribe, get, get);
}

When the atom's listener count (lc) is 0, store.get() creates a temporary subscription (lc: 0 → 1 → 0) and fires all onMount callbacks synchronously during the render phase, before the commit. The real subscription (via store.listen) would only be registered at commit time by useSyncExternalStore.

When a component inside a <Suspense> boundary suspends, React discards the work-in-progress fiber for siblings inside that boundary, including any component that called useSession. Because there was no commit, the listener count returns to 0.

On the next retry of the boundary, useRef reinitializes, store.get() sees lc = 0, onMount fires again, and another setTimeout(0) is queued.

What causes the rapid retries in production?

In our production app (Next.js App Router), we instrumented the session atom and observed 420 Suspense retries and 852 total re-renders of the component calling useSession() on a single hard refresh, without any explicit <Suspense> boundary or loading.tsx in our code.

We traced the retries to Next.js's internal LoadingBoundary, a Suspense boundary the framework creates between route segments (layout <-> page). When the page segment is complex enough (e.g. heavy component trees), the RSC payload streams in multiple chunks, and each chunk can trigger a retry of the boundary, re-rendering the layout's client components with fresh fibers.

That said, we're not 100% sure this is the only factor. There could be other React internals or framework behaviors contributing to the retry frequency that we haven't identified.

  • #4609 — Same symptom (hundreds of get-session calls per page load). PR #4669 introduced the isMounted guard but placed it after await fn() rather than before, which from what we can tell leaves the race condition intact.
Originally created by @benhid on GitHub (Apr 9, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/9077 Originally assigned to: @ping-maxwell on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### Reproduction While reviewing production logs we noticed that multiple user IPs were each generating an extraordinary number of requests to `/api/auth/get-session`. We are talking about hundreds of requests per second, in a simultaneous burst, from a single browser tab. After ruling out browser extensions, service workers, localStorage events, and network-level retries, we believe the root cause is a race condition in `query.ts`. We've done our best to trace the full chain, but any corrections are very welcome. --- The storm requires two conditions to be simultaneously true: 1. **A `useSession()` consumer whose React fiber is being repeatedly recreated.** The most common cause is a `<Suspense>` boundary with a suspended sibling (any [`loading.tsx`](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) creates one implicitly around the page, and explicit `<Suspense>` boundaries have the same effect). In our production app, we also observed this happening via Next.js's internal `LoadingBoundary` between route segments, even without `loading.tsx`, though this requires enough page complexity to generate chunked RSC streaming and was difficult to isolate in a minimal repro. 2. **A slow `get-session` response relative to the number of retries.** If the server responds in < 50 ms and there are only a handful of retries, the first `setTimeout(0)` callback resolves, `isMounted` becomes `true`, and subsequent callbacks skip the fetch. The storm only manifests when the response is slow enough that `isMounted` is still `false` when all queued callbacks drain. Since the natural retries from RSC streaming require real app complexity to trigger in isolation, the repro below uses a `setInterval` to simulate them. The mechanism is the same: rapid Suspense boundary retries -> fresh fibers -> `store.get()` with `lc=0` -> `onMount` -> `setTimeout(0)` -> fetch. **`page.tsx`** ```tsx "use client"; import { lazy, Suspense, useEffect, useState, type ComponentType } from "react"; import { authClient } from "@/lib/auth-client"; function SessionWatcher() { authClient.useSession(); return null; } const SlowChunk = lazy<ComponentType>( () => new Promise((r) => setTimeout(() => r({ default: () => null }), 30000)), ); function StormDriver() { const [, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 15); return () => clearInterval(id); }, []); return ( <Suspense fallback={null}> <SessionWatcher /> <SlowChunk /> </Suspense> ); } export default function Page() { return ( <main style={{ padding: 40, fontFamily: "sans-serif" }}> <h2>How to reproduce</h2> <p> Open DevTools → Network → filter <code>get-session</code>, then{" "} <strong>reload this page</strong>. </p> <p> <strong>Expected:</strong> 1 request. <br /> <strong>Actual:</strong> 1 000+ requests in under 5 seconds. </p> <StormDriver /> </main> ); } ``` **`src/app/api/auth/[...all]/route.ts`** ```typescript import { toNextJsHandler } from "better-auth/next-js"; import { auth } from "@/lib/auth"; const { GET: _GET, POST } = toNextJsHandler(auth); export async function GET(req: Request) { if (req.url.includes("get-session")) { await new Promise<void>((r) => setTimeout(r, 30000)); } return _GET(req); } export { POST }; ``` ### Current vs. Expected behavior Current behavior: A single page load fires hundreds of concurrent get-session requests from one tab. Expected behavior: One get-session request per mount cycle. ### What version of Better Auth are you using? 1.6.2 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.4.0: Thu Mar 19 19:31:09 PDT 2026; root:xnu-12377.101.15~1/RELEASE_ARM64_T8132", "release": "25.4.0", "cpuCount": 10, "cpuModel": "Apple M4", "totalMemory": "16.00 GB", "freeMemory": "0.10 GB" }, "node": { "version": "v25.2.1", "env": "development" }, "packageManager": { "name": "pnpm", "version": "10.23.0" }, "frameworks": [ { "name": "next", "version": "^16.2.3" }, { "name": "react", "version": "^19.2.5" } ], "databases": null, "betterAuth": { "version": "1.6.2", "config": { "emailAndPassword": { "enabled": true }, "secret": "[REDACTED]", "trustedOrigins": [ "http://localhost:3000" ] } } } ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript ``` ### Additional context ### Root cause #### The `isMounted` guard appears to have a timing gap **`packages/better-auth/src/client/query.ts`** ```typescript onMount(value, () => { const timeoutId = setTimeout(async () => { if (!isMounted) { await fn(); // fetch starts here isMounted = true; // only flipped after the fetch resolves } }, 0); // ... }); ``` If multiple `setTimeout(0)` callbacks are queued before the first fetch resolves, each one would enter the `if (!isMounted)` branch and start its own fetch, since `isMounted` hasn't been set to `true` yet. #### How multiple `setTimeout(0)` callbacks end up queued The nanostores React binding (`useStore`) calls `store.get()` inside `useRef` during render: ```js function useStore(store) { let snapshotRef = useRef(store.get()); // fires onMount if lc === 0 // ... return useSyncExternalStore(subscribe, get, get); } ``` When the atom's listener count (`lc`) is 0, `store.get()` creates a temporary subscription (`lc: 0 → 1 → 0`) and fires all `onMount` callbacks synchronously during the render phase, before the commit. The real subscription (via `store.listen`) would only be registered at commit time by `useSyncExternalStore`. When a component inside a `<Suspense>` boundary suspends, React discards the work-in-progress fiber for siblings inside that boundary, including any component that called `useSession`. Because there was no commit, the listener count returns to 0. On the next retry of the boundary, `useRef` reinitializes, `store.get()` sees `lc = 0`, `onMount` fires again, and another `setTimeout(0)` is queued. #### What causes the rapid retries in production? In our production app (Next.js App Router), we instrumented the session atom and observed **420 Suspense retries** and **852 total re-renders** of the component calling `useSession()` on a single hard refresh, without any explicit `<Suspense>` boundary or `loading.tsx` in our code. We traced the retries to Next.js's internal `LoadingBoundary`, a Suspense boundary the framework creates between route segments (layout <-> page). When the page segment is complex enough (e.g. heavy component trees), the RSC payload streams in multiple chunks, and each chunk can trigger a retry of the boundary, re-rendering the layout's client components with fresh fibers. That said, we're not 100% sure this is the only factor. There could be other React internals or framework behaviors contributing to the retry frequency that we haven't identified. ### Related - #4609 — Same symptom (hundreds of `get-session` calls per page load). PR #4669 introduced the `isMounted` guard but placed it **after** `await fn()` rather than before, which from what we can tell leaves the race condition intact.
GiteaMirror added the corebug labels 2026-04-13 07:36:52 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Apr 9, 2026):

Hi @benhid! I'm Dosu and I’m helping the better-auth team.

This is a well-investigated issue. The analysis is accurate — the race condition is real and remains unaddressed.

The core problem is in query.ts: isMounted is set to true after await fn() resolves [1], so when multiple setTimeout(0) callbacks drain before the first fetch completes, each one passes the if (!isMounted) check and fires its own request. PR #4669 fixed the sequential case reported in #4609 but not this concurrent variant [2].

Additionally, better-auth has no request deduplication or in-flight promise caching for get-session — each trigger creates an independent fetch with no check for whether one is already pending [3].

The minimal fix would be moving isMounted = true before the await:

if (!isMounted) {
    isMounted = true;   // flip BEFORE the async call
    await fn();
}

A more robust fix would add a pending-promise cache so concurrent callers share a single in-flight request:

let pendingFetch: Promise<void> | null = null;

async function deduplicatedFn() {
    if (pendingFetch) return pendingFetch;
    pendingFetch = fn();
    try { await pendingFetch; } finally { pendingFetch = null; }
}

Both layers together would close the race for both the Suspense-remount scenario described here and any future rapid-trigger path.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:4217071880 --> @dosubot[bot] commented on GitHub (Apr 9, 2026): <!-- Greeting --> Hi @benhid! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a well-investigated issue. The analysis is accurate — the race condition is real and remains unaddressed. The core problem is in `query.ts`: `isMounted` is set to `true` **after** `await fn()` resolves [[1]](https://github.com/better-auth/better-auth/blob/41679fa81795a2305ddf5f70a9cba27ea483f33e/packages/better-auth/src/client/query.ts#L136-L139), so when multiple `setTimeout(0)` callbacks drain before the first fetch completes, each one passes the `if (!isMounted)` check and fires its own request. PR #4669 fixed the sequential case reported in #4609 but not this concurrent variant [[2]](https://github.com/better-auth/better-auth/pull/4669). Additionally, better-auth has **no request deduplication or in-flight promise caching** for `get-session` — each trigger creates an independent fetch with no check for whether one is already pending [[3]](https://github.com/better-auth/better-auth/blob/41679fa81795a2305ddf5f70a9cba27ea483f33e/packages/better-auth/src/client/session-refresh.ts#L107-L133). The minimal fix would be moving `isMounted = true` **before** the `await`: ```typescript if (!isMounted) { isMounted = true; // flip BEFORE the async call await fn(); } ``` A more robust fix would add a pending-promise cache so concurrent callers share a single in-flight request: ```typescript let pendingFetch: Promise<void> | null = null; async function deduplicatedFn() { if (pendingFetch) return pendingFetch; pendingFetch = fn(); try { await pendingFetch; } finally { pendingFetch = null; } } ``` Both layers together would close the race for both the Suspense-remount scenario described here and any future rapid-trigger path. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=brand-link).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-great_response#message_id=1208833b-31b9-4403-8761-99f5eb7c00f8&feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-irrelevant_answer#message_id=1208833b-31b9-4403-8761-99f5eb7c00f8&feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-incorrect_sources#message_id=1208833b-31b9-4403-8761-99f5eb7c00f8&feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-too_verbose#message_id=1208833b-31b9-4403-8761-99f5eb7c00f8&feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-hallucination#message_id=1208833b-31b9-4403-8761-99f5eb7c00f8&feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-bug_report#message_id=1208833b-31b9-4403-8761-99f5eb7c00f8&feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-other#message_id=1208833b-31b9-4403-8761-99f5eb7c00f8&feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=chat-badge)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20is%20a%20well-investigated%20issue.%20The%20analysis%20is%20accurate%20%E2%80%94%20the%20race%20condition%20is%20real%20and%20remains%20unaddressed.%0A%0AThe%20core%20problem%20is%20in%20%60query.ts%60%3A%20%60isMounted%60%20is%20set%20to%20%60true%60%20%2A%2Aafter%2A%2A%20%60await%20fn%28%29%60%20resolves%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/41679fa81795a2305ddf5f70a9cba27ea483f33e/packages/better-auth/src/client/query.ts%23L136-L139%29%2C%20so%20when%20multiple%20%60setTimeout%280%29%60%20callbacks%20drain%20before%20the%20first%20fetch%20completes%2C%20each%20one%20passes%20the%20%60if%20%28%21isMounted%29%60%20check%20and%20fires%20its%20own%20request.%20PR%20%234669%20fixed%20the%20sequential%20case%20reported%20in%20%234609%20but%20not%20this%20concurrent%20variant%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/pull/4669%29.%0A%0AAdditionally%2C%20better-auth%20has%20%2A%2Ano%20request%20deduplication%20or%20in-flight%20promise%20caching%2A%2A%20for%20%60get-session%60%20%E2%80%94%20each%20trigger%20creates%20an%20independent%20fetch%20with%20no%20check%20for%20whether%20one%20is%20already%20pending%20%5B%5B3%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/41679fa81795a2305ddf5f70a9cba27ea483f33e/packages/better-auth/src/client/session-refresh.ts%23L107-L133%29.%0A%0AThe%20minimal%20fix%20would%20be%20moving%20%60isMounted%20%3D%20true%60%20%2A%2Abefore%2A%2A%20the%20%60await%60%3A%0A%0A%60%60%60typescript%0Aif%20%28%21isMounted%29%20%7B%0A%20%20%20%20isMounted%20%3D%20true%3B%20%20%20//%20flip%20BEFORE%20the%20async%20call%0A%20%20%20%20await%20fn%28%29%3B%0A%7D%0A%60%60%60%0A%0AA%20more%20robust%20fix%20would%20add%20a%20pending-promise%20cache%20so%20concurrent%20callers%20share%20a%20single%20in-flight%20request%3A%0A%0A%60%60%60typescript%0Alet%20pendingFetch%3A%20Promise%3Cvoid%3E%20%7C%20null%20%3D%20null%3B%0A%0Aasync%20function%20deduplicatedFn%28%29%20%7B%0A%20%20%20%20if%20%28pendingFetch%29%20return%20pendingFetch%3B%0A%20%20%20%20pendingFetch%20%3D%20fn%28%29%3B%0A%20%20%20%20try%20%7B%20await%20pendingFetch%3B%20%7D%20finally%20%7B%20pendingFetch%20%3D%20null%3B%20%7D%0A%7D%0A%60%60%60%0A%0ABoth%20layers%20together%20would%20close%20the%20race%20for%20both%20the%20Suspense-remount%20scenario%20described%20here%20and%20any%20future%20rapid-trigger%20path.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=join-discord)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/9077)
Author
Owner

@ping-maxwell commented on GitHub (Apr 9, 2026):

That said, we're not 100% sure this is the only factor. There could be other React internals or framework behaviors contributing to the retry frequency that we haven't identified.

Hey @benhid I'm happy to open a patch with a custom package for you to test and see if it does fix your issue.

<!-- gh-comment-id:4217140123 --> @ping-maxwell commented on GitHub (Apr 9, 2026): > That said, we're not 100% sure this is the only factor. There could be other React internals or framework behaviors contributing to the retry frequency that we haven't identified. Hey @benhid I'm happy to open a patch with a custom package for you to test and see if it does fix your issue.
Author
Owner

@ping-maxwell commented on GitHub (Apr 9, 2026):

please give this a shot, and let me know if it resolves your issue:
https://github.com/better-auth/better-auth/pull/9078#issuecomment-4217157028

<!-- gh-comment-id:4217181125 --> @ping-maxwell commented on GitHub (Apr 9, 2026): please give this a shot, and let me know if it resolves your issue: https://github.com/better-auth/better-auth/pull/9078#issuecomment-4217157028
Author
Owner

@benhid commented on GitHub (Apr 9, 2026):

@ping-maxwell Thanks! I’ve actually already tested the fix on my side (via pnpm patch, better-auth@1.4.10), and it seems to be working correctly so far.

One thing: I’ve also added isMounted = false in the cleanup function, so that a legitimate unmount + remount still triggers the initial fetch. Without that reset, isMounted stays true forever and the component never refetches after a real remount:

 		else onMount(value, () => {
 			const timeoutId = setTimeout(async () => {
 				if (!isMounted) {
-					await fn();
 					isMounted = true;
+					await fn();
 				}
 			}, 0);
 			return () => {
+				isMounted = false;
 				value.off();
 				initAtom.off();
 				clearTimeout(timeoutId);

(The isMounted = false in the cleanup is not related to the storm bug described above, but I noticed it was missing from the original code introduced in #4669. Honestly, not sure if it has any practical impact.)

<!-- gh-comment-id:4217332571 --> @benhid commented on GitHub (Apr 9, 2026): @ping-maxwell Thanks! I’ve actually already tested the fix on my side (via `pnpm patch`, `better-auth@1.4.10`), and it seems to be working correctly so far. One thing: I’ve also added `isMounted = false` in the cleanup function, so that a legitimate unmount + remount still triggers the initial fetch. Without that reset, `isMounted` stays true forever and the component never refetches after a real remount: ```diff else onMount(value, () => { const timeoutId = setTimeout(async () => { if (!isMounted) { - await fn(); isMounted = true; + await fn(); } }, 0); return () => { + isMounted = false; value.off(); initAtom.off(); clearTimeout(timeoutId); ``` (The `isMounted = false` in the cleanup is not related to the storm bug described above, but I noticed it was missing from the original code introduced in #4669. Honestly, not sure if it has any practical impact.)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#11267