Skip to content

fix(react): deduplicate concurrent fetchAccessToken calls#267

Merged
erquhart merged 1 commit intoget-convex:mainfrom
ramonclaudio:fix/dedup-token-requests
Feb 22, 2026
Merged

fix(react): deduplicate concurrent fetchAccessToken calls#267
erquhart merged 1 commit intoget-convex:mainfrom
ramonclaudio:fix/dedup-token-requests

Conversation

@ramonclaudio
Copy link
Copy Markdown
Contributor

@ramonclaudio ramonclaudio commented Feb 19, 2026

When cachedToken is null (initial load, post-signout), concurrent calls to fetchAccessToken each fire a separate authClient.convex.token() HTTP request before the first resolves. On page load, sessionId transitions from undefined to a value, creating a new fetchAccessToken reference that triggers ConvexProviderWithAuth to call setAuth() again while the first request is still in-flight. React 18 StrictMode doubles this in dev.

Each /convex/token call runs session middleware (DB hit) + JWT signing. N concurrent calls = N redundant round-trips, only one result used.

A useRef stores the in-flight promise so concurrent callers share it instead of firing duplicates:

const pendingTokenRef = useRef<Promise<string | null> | null>(null);

// in fetchAccessToken:
if (!forceRefreshToken && pendingTokenRef.current) {
  return pendingTokenRef.current;
}
pendingTokenRef.current = authClient.convex
  .token({ fetchOptions: { throw: false } })
  .then(...)
  .catch(...)
  .finally(() => { pendingTokenRef.current = null; });
return pendingTokenRef.current;

forceRefreshToken: true bypasses the guard and overwrites with a fresh request. .finally() clears the ref after resolution so the next call starts fresh.

Changes

Single file, src/react/index.tsx:

  • Add useRef to React imports (+1 token)
  • Add pendingTokenRef declaration after cachedToken useState
  • Replace try/catch with promise chain (needed to capture the promise object for sharing)
  • Add dedup guard before the fetch

Test plan

rm -rf dist/ && npm run build, npm run typecheck, npm run test all pass. 55 tests passed, 0 type errors. Rebased on upstream/main.

Closes #219. Likely reduces the excessive action count reported in #186.

Summary by CodeRabbit

  • Performance Improvements
    • Optimized token request handling to prevent duplicate concurrent requests, improving efficiency and reducing unnecessary network operations.

@vercel
Copy link
Copy Markdown

vercel bot commented Feb 19, 2026

@ramonclaudio is attempting to deploy a commit to the Convex Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 19, 2026

📝 Walkthrough

Walkthrough

Modified src/react/index.tsx to implement request deduplication for concurrent token fetches using useRef. When a token fetch is in-flight, subsequent calls return the existing promise instead of initiating new requests, preventing duplicate /token API calls.

Changes

Cohort / File(s) Summary
Token fetch deduplication
src/react/index.tsx
Added pendingTokenRef to track in-flight token requests; replaced direct await/try-catch with promise-based flow that caches and returns in-flight promises for concurrent calls; ensures only one fetch executes when multiple calls occur simultaneously.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • PR #218: Implements the same in-flight token promise deduplication pattern in src/react/index.tsx using useRef to prevent concurrent token requests.
  • PR #223: Modifies token fetch and caching behavior in src/react/index.tsx, adjusting cachedToken initialization and auth state handling alongside token management logic.

Suggested reviewers

  • erquhart
  • devstojko

Poem

🐰 A rabbit hops through concurrent paths so fine,
One token promise drawn along a single line,
No more duplicate requests in a rush,
Just one in-flight, no futile crush,
Deduplication magic—hop, hop, hooray! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix(react): deduplicate concurrent fetchAccessToken calls' is concise, specific, and accurately summarizes the main change: adding deduplication logic to prevent concurrent token requests.
Linked Issues check ✅ Passed The code changes fully implement the objectives from issue #219: deduplicating concurrent fetchAccessToken calls via pendingTokenRef, ensuring only one network request on cache miss, and supporting forced refresh when needed.
Out of Scope Changes check ✅ Passed All changes are directly related to the deduplication objective; only src/react/index.tsx was modified with useRef hook addition and promise-based token fetch flow, with no unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/react/index.tsx`:
- Around line 133-135: pendingTokenRef can hold an in-flight promise across
session changes causing a stale token to be returned; inside
useAuthFromBetterAuth (where sessionId is in scope) add a useEffect that runs
when sessionId changes and clears pendingTokenRef.current (set to
undefined/null) so that the recreated fetchAccessToken does not return a promise
from the previous session; ensure you reference pendingTokenRef and do not alter
fetchAccessToken signature, and keep setCachedToken behavior unchanged except
that the cleared ref prevents old promises from re-setting a revoked token.
- Around line 147-149: The finally handler unconditionally clears
pendingTokenRef.current, which breaks deduplication when a later force-refresh
overwrites the ref; fix by capturing the promise into a local variable (e.g.,
const myPromise = <the promise assigned to pendingTokenRef.current>) and in the
.finally() only set pendingTokenRef.current = null if pendingTokenRef.current
=== myPromise so you don’t clear a newer in-flight promise (apply this change
where pendingTokenRef.current is assigned and the .finally() is attached, and
keep behavior for forceRefreshToken unchanged).

Comment thread src/react/index.tsx
Comment on lines +133 to 135
if (!forceRefreshToken && pendingTokenRef.current) {
return pendingTokenRef.current;
}
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

pendingTokenRef is not reset when sessionId changes, allowing a stale session's in-flight promise to be returned post-sign-out.

fetchAccessToken is recreated when sessionId changes (via useCallback([sessionId])), but pendingTokenRef persists uncleared. If a fetch is in-flight at the moment the user signs out and sessionId becomes undefined/null, the new fetchAccessToken will return the old session's pending promise to the post-sign-out caller. That promise resolves with the old session's token and also calls setCachedToken(oldToken), momentarily re-setting a revoked token in state.

Add a useEffect inside useAuthFromBetterAuth (where sessionId is already available) to clear the ref on session change:

🛡️ Proposed fix — clear pending ref on session change
 const sessionId = session?.session?.id;
+useEffect(() => {
+  pendingTokenRef.current = null;
+}, [sessionId]);
 useEffect(() => {
   if (!session && !isSessionPending && cachedToken) {
     setCachedToken(null);
   }
 }, [session, isSessionPending]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/react/index.tsx` around lines 133 - 135, pendingTokenRef can hold an
in-flight promise across session changes causing a stale token to be returned;
inside useAuthFromBetterAuth (where sessionId is in scope) add a useEffect that
runs when sessionId changes and clears pendingTokenRef.current (set to
undefined/null) so that the recreated fetchAccessToken does not return a promise
from the previous session; ensure you reference pendingTokenRef and do not alter
fetchAccessToken signature, and keep setCachedToken behavior unchanged except
that the cleared ref prevents old promises from re-setting a revoked token.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convex's AuthenticationManager handles this. When sessionId changes, a new fetchAccessToken is created (via useCallback([sessionId])), which triggers ConvexAuthStateFirstEffect to call client.setAuth() again. That increments configVersion inside fetchTokenAndGuardAgainstRace, so even if the new fetchAccessToken returns the old in-flight promise, the result is discarded as isFromOutdatedConfig: true. The stale token never reaches the WebSocket.

Adding a useEffect to clear the ref would be redundant with Convex's existing race guard.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ramonclaudio, thank you for the detailed explanation! You're right—if Convex's AuthenticationManager already discards results from outdated configs via the configVersion and isFromOutdatedConfig guard, then clearing pendingTokenRef on session change would indeed be redundant. The race condition is already handled at the framework level.

I appreciate you taking the time to clarify the architecture. I've noted this for future reference.


✏️ Learnings added
Learnt from: ramonclaudio
Repo: get-convex/better-auth PR: 267
File: src/react/index.tsx:133-135
Timestamp: 2026-02-19T18:30:07.688Z
Learning: In `src/react/index.tsx` with Convex and Better Auth integration, `pendingTokenRef` does not need to be cleared when `sessionId` changes because Convex's AuthenticationManager handles stale token race conditions through a configVersion mechanism. When sessionId changes, a new fetchAccessToken is created (via useCallback([sessionId])), triggering ConvexAuthStateFirstEffect to call client.setAuth() and increment configVersion inside fetchTokenAndGuardAgainstRace. Even if an old in-flight promise is returned, the result is discarded as isFromOutdatedConfig: true, preventing stale tokens from reaching the WebSocket.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread src/react/index.tsx
Comment on lines +147 to +149
.finally(() => {
pendingTokenRef.current = null;
});
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

.finally() unconditionally clears the ref — breaks deduplication when a force-refresh overwrites it.

If forceRefreshToken: true fires while a regular fetch is already in-flight, pendingTokenRef.current is overwritten with the new force-refresh promise. When the original regular fetch settles, its .finally() blindly sets pendingTokenRef.current = null, clearing a ref that now belongs to the force-refresh promise. Any caller arriving after that .finally() but before the force-refresh promise resolves will see an empty ref and fire a redundant third request.

Fix: capture the promise in a local variable and only clear the ref if it still points to this promise:

🐛 Proposed fix — identity-guarded `.finally()`
-           pendingTokenRef.current = authClient.convex
-             .token({ fetchOptions: { throw: false } })
-             .then(({ data }) => {
-               const token = data?.token || null;
-               setCachedToken(token);
-               return token;
-             })
-             .catch(() => {
-               setCachedToken(null);
-               return null;
-             })
-             .finally(() => {
-               pendingTokenRef.current = null;
-             });
-           return pendingTokenRef.current;
+           const pending = authClient.convex
+             .token({ fetchOptions: { throw: false } })
+             .then(({ data }) => {
+               const token = data?.token || null;
+               setCachedToken(token);
+               return token;
+             })
+             .catch(() => {
+               setCachedToken(null);
+               return null;
+             })
+             .finally(() => {
+               if (pendingTokenRef.current === pending) {
+                 pendingTokenRef.current = null;
+               }
+             });
+           pendingTokenRef.current = pending;
+           return pending;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/react/index.tsx` around lines 147 - 149, The finally handler
unconditionally clears pendingTokenRef.current, which breaks deduplication when
a later force-refresh overwrites the ref; fix by capturing the promise into a
local variable (e.g., const myPromise = <the promise assigned to
pendingTokenRef.current>) and in the .finally() only set pendingTokenRef.current
= null if pendingTokenRef.current === myPromise so you don’t clear a newer
in-flight promise (apply this change where pendingTokenRef.current is assigned
and the .finally() is attached, and keep behavior for forceRefreshToken
unchanged).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .finally() race requires forceRefreshToken: true to fire while a non-force fetch is still in-flight. Looking at AuthenticationManager, forceRefreshToken: true fires via refetchToken() after the server confirms the initial token (onTransition in the waitingForServerConfirmationOfCachedToken state), or via tryToReauthenticate on auth errors, or via the scheduled refresh (~15 min). In all three paths, the initial forceRefreshToken: false fetch has already resolved before the force-refresh fires -- they're sequential, not concurrent.

Even in the theoretical worst case, the .finally() clears the ref and one extra request fires on the next call. Adding a local binding + equality check to guard against it would complicate the diff for no practical gain.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ramonclaudio, that's a fair analysis. If the AuthenticationManager's call patterns ensure forceRefreshToken: true fires sequentially after initial fetches resolve, the race window doesn't exist in practice. And even if it did theoretically occur, the cost of one extra request is negligible compared to the added complexity.

Your engineering judgment on this tradeoff makes sense — thanks for walking through the code paths!

Copy link
Copy Markdown
Contributor Author

@ramonclaudio ramonclaudio left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed both points inline. The configVersion guard in AuthenticationManager already handles the stale session case, and the .finally() race requires concurrent force/non-force calls that can't happen in practice (force-refresh only fires after the initial fetch resolves). Happy to add the guards if you disagree, but I figured I'd keep the diff minimal.

Comment thread src/react/index.tsx
Comment on lines +133 to 135
if (!forceRefreshToken && pendingTokenRef.current) {
return pendingTokenRef.current;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convex's AuthenticationManager handles this. When sessionId changes, a new fetchAccessToken is created (via useCallback([sessionId])), which triggers ConvexAuthStateFirstEffect to call client.setAuth() again. That increments configVersion inside fetchTokenAndGuardAgainstRace, so even if the new fetchAccessToken returns the old in-flight promise, the result is discarded as isFromOutdatedConfig: true. The stale token never reaches the WebSocket.

Adding a useEffect to clear the ref would be redundant with Convex's existing race guard.

Comment thread src/react/index.tsx
Comment on lines +147 to +149
.finally(() => {
pendingTokenRef.current = null;
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .finally() race requires forceRefreshToken: true to fire while a non-force fetch is still in-flight. Looking at AuthenticationManager, forceRefreshToken: true fires via refetchToken() after the server confirms the initial token (onTransition in the waitingForServerConfirmationOfCachedToken state), or via tryToReauthenticate on auth errors, or via the scheduled refresh (~15 min). In all three paths, the initial forceRefreshToken: false fetch has already resolved before the force-refresh fires -- they're sequential, not concurrent.

Even in the theoretical worst case, the .finally() clears the ref and one extra request fires on the next call. Adding a local binding + equality check to guard against it would complicate the diff for no practical gain.

Copy link
Copy Markdown
Member

@erquhart erquhart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good improvement, thanks for this

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Feb 22, 2026

Open in StackBlitz

npm i https://pkg.pr.new/get-convex/better-auth/@convex-dev/better-auth@267

commit: 1e3a291

@erquhart erquhart merged commit 7dd877d into get-convex:main Feb 22, 2026
6 of 7 checks passed
@ramonclaudio
Copy link
Copy Markdown
Contributor Author

NP! Thanks @erquhart

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Potential duplicate token requests during concurrent fetchAccessToken calls

2 participants