Skip to content

Conversation

@jacekradko
Copy link
Member

@jacekradko jacekradko commented Nov 26, 2025

Description

This PR implements proactive token refresh for session tokens. Instead of waiting for getToken() calls to trigger refresh when a token is close to expiration, we schedule a timer when tokens are cached that fires proactively before the leeway period begins.

Fixes: USER-4087

Dashboard testing PR: https://github.com/clerk/dashboard/pull/7990

Screenshot 2026-01-15 at 9 38 16 AM

How It Works

Token TTL Timeline (60s token, 15s leeway)
─────────────────────────────────────────────────────────────────────►
T=0                    T=43s              T=55s                    T=60s
(issued)           (timer fires)       (leeway)                 (expires)

│←─────── Fresh zone ───────→│←── Refresh ──→│←─ Danger ─→│
         (return cached)         (proactive      (force sync
                                  refresh)        refresh)

         No action needed        Timer triggers   Token deleted
                                 background       from cache
                                 refresh

Key Behaviors

  1. Fresh zone (TTL > leeway + 2s): Return cached token, no action needed
  2. Refresh timer fires (at TTL - leeway - 2s = 43s for 60s tokens): Proactively trigger background refresh
  3. Danger zone (TTL < 5s): Token deleted from cache, forces synchronous fetch

Benefits

  • More consistent refresh: Tokens are refreshed proactively regardless of getToken() call frequency
  • Zero risk: If the timer doesn't fire for any reason, the poller catches it as a fallback
  • Cross-tab sync: New tokens are broadcast to other tabs via BroadcastChannel

Implementation Details

Token Cache (tokenCache.ts)

  • onRefresh callback on cache entries for proactive refresh
  • Timer scheduled at (TTL - leeway - 2s) when tokens are cached
  • Tokens < 5s from expiration are deleted from cache (forces sync fetch)
  • Cross-tab synchronization via BroadcastChannel

Session (Session.ts)

  • #refreshTokenInBackground(): Fires token refresh without blocking
  • #backgroundRefreshInProgress Set prevents duplicate concurrent refreshes
  • onRefresh callback passed when caching tokens
  • resolvedToken on cache entries enables synchronous reads (avoids microtask overhead)

Constants

  • Default leeway: 15 seconds
  • Minimum leeway: 5 seconds (poller interval)
  • Refresh lead time: 2 seconds before leeway starts

Breaking Changes

  • Removed leewayInSeconds option: The leewayInSeconds option has been removed from getToken(). Token refresh timing is now handled automatically by the proactive refresh system. A codemod is available via @clerk/upgrade to remove this option from existing code.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

Summary by CodeRabbit

  • New Features

    • Proactive background token refresh with stale-while-revalidate behavior, cross-tab synchronization, and support for pre-resolved tokens (observable resolved-token state).
  • Breaking Changes

    • getToken/get cache lookups now return a wrapped result with an entry and needsRefresh indicator; leewayInSeconds removed from token options.
  • Documentation

    • Added guidance on stale-while-revalidate behavior and refresh timing.
  • Tests

    • Expanded tests for refresh timers, resolved-token handling, expiry/leeway edge cases, and multi-session isolation.

✏️ Tip: You can customize this high-level summary in your review settings.

@changeset-bot
Copy link

changeset-bot bot commented Nov 26, 2025

🦋 Changeset detected

Latest commit: 6da6883

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@clerk/clerk-js Major
@clerk/chrome-extension Patch
@clerk/expo Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 26, 2025

📝 Walkthrough

Walkthrough

Session token handling was refactored to a stale-while-revalidate model with proactive background refresh. SessionTokenCache.get now returns a TokenCacheGetResult object with an entry field; TokenCacheEntry gained onRefresh and resolvedToken fields and stores refresh timers. A new exported POLLER_INTERVAL_IN_MS constant is used in freshness calculations. leewayInSeconds was removed from GetToken options and codemods were added to remove it from call sites. Session now tracks in‑flight background refreshes to avoid concurrent refreshes and uses private fetch/refresh paths that update the cache and emit TokenUpdate and SessionTokenResolved events.

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.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 title 'feat(clerk-js): Proactive session token refresh' directly and accurately describes the main change: implementing proactive/background token refresh instead of waiting for getToken() calls.
Linked Issues check ✅ Passed The PR fully addresses all four objectives from USER-4087: (1) returns cached tokens without deletion during leeway [tokenCache.ts, Session.ts], (2) returns valid cached tokens immediately even within leeway [Session.ts #hydrateCache], (3) refreshes tokens asynchronously via timer-triggered #refreshTokenInBackground [Session.ts, tokenCache.ts], and (4) eliminates periodic latency spikes by preventing cache deletion and forced synchronous refresh [tokenCache.ts logic].
Out of Scope Changes check ✅ Passed All code changes align with the stated objective of implementing proactive token refresh. The removal of leewayInSeconds option from getToken() is directly scoped to this feature [packages/shared/src/types/session.ts]. Documentation updates and codemods for migration [packages/upgrade/] are appropriate supporting changes. Bundle size adjustment reflects implementation overhead.

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

Tip

🧪 Unit Test Generation v2 is now available!

We have significantly improved our unit test generation capabilities.

To enable: Add this to your .coderabbit.yaml configuration:

reviews:
  finishing_touches:
    unit_tests:
      enabled: true

Try it out by using the @coderabbitai generate unit tests command on your code files or under ✨ Finishing Touches on the walkthrough!

Have feedback? Share your thoughts on our Discord thread!


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

@vercel
Copy link

vercel bot commented Nov 26, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jan 30, 2026 1:49am

Request Review

@jacekradko jacekradko marked this pull request as ready for review December 1, 2025 17:23
@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 2, 2025

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7317

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7317

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7317

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7317

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7317

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7317

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7317

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7317

@clerk/express

npm i https://pkg.pr.new/@clerk/express@7317

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7317

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7317

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7317

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7317

@clerk/react

npm i https://pkg.pr.new/@clerk/react@7317

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7317

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7317

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7317

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7317

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7317

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7317

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7317

commit: 6da6883

Copy link
Member

@Ephem Ephem left a comment

Choose a reason for hiding this comment

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

I like the latest changes! Using the poller for the background revalidation makes sense to me. I think there's still some torny parts to untangle, and besides the latest comments, something still feels harder than it should be here, so I wonder if we've found the correct mental model for this yet?

I have a few early thoughts, let's chat!


if (expiresSoon) {
// Token expired or dangerously close to expiration - force synchronous refresh
if (remainingTtl <= POLLER_INTERVAL_IN_MS / 1000) {
Copy link
Member

Choose a reason for hiding this comment

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

Default LEEWAY was 10s, and SYNC_LEEWAY was 5s, so if I'm reading this correctly, we used to sync fetch when less than 15s was remaining? Now this value is 5s?

I wonder if 5s is enough considering latency and all other factors?

However we change this it's a breaking change so let's remember to document it properly in the changelog when we've figured it out.

Copy link
Member Author

Choose a reason for hiding this comment

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

Right. Before we would do a blocking fetch under the 15s, now we use the stale value when it's under 15s but more than 5 seconds. The idea there is that the poller would async fetch the token before we get to the 5s. If that doesn't occur then we would force a sync fetch.

Copy link
Member Author

Choose a reason for hiding this comment

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

And yes, this is a breaking change so it's going to be part of Core 3

}

return value.entry;
const effectiveLeeway = Math.max(leeway, MIN_REMAINING_TTL_IN_SECONDS);
Copy link
Member

Choose a reason for hiding this comment

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

This part is a bit weird. MIN_REMAINING_TTL_IN_SECONDS is 15s, and default LEEWAY is 10s, but the Math.max ensures we never go below 15s right?

If this is what we want(?), I think we should increase the LEEWAY to 15s as well, that way we document that "you can only raise this".

Thinking more on it, this PR now changes the public leewayInSeconds to only apply if you also use the internal refreshTokenOnFocus option? That seems off.

Copy link
Member

Choose a reason for hiding this comment

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

🤔 In agreement here, this effectively forces leeway to always be equal to or greater than MIN_REMAINING_TTL_IN_SECONDS, do we want that value to be 5 or 15 going forward?

try {
const token = await this.clerk.session.getToken();
// Use refreshIfStale to fetch fresh token when cached token is within leeway period
const token = await this.clerk.session.getToken({ refreshIfStale: true });
Copy link
Member

Choose a reason for hiding this comment

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

This also affects the refreshTokenOnFocus, but do we want that? Because those cases can be a race to finish setting the cookie before any external data fetching runs, it seems prudent to use the available token then?

Copy link
Member Author

Choose a reason for hiding this comment

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

🤔

Comment on lines +380 to +381
// Prefer synchronous read to avoid microtask overhead when token is already resolved
const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver);
Copy link
Member

Choose a reason for hiding this comment

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

As I mentioned in chat, when I made the comment about this I didn't think it through fully. To get the full benefits, we'd have to make sure _getToken/getToken itself to be synchronous when data is already available (or maybe more likely add the .status and .value/.reason fields to the promise).

This likely only makes sense if/when we decide to expose this promise to the end user so they can use it though.

I see you've used resolvedToken as the way to be able to have a background refetch running while still reading the currently cached token though so this still has immediate value. That was a bit unclear at first, but I think it makes sense, will need to think some more on it.

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed. And yeah, right now the value is key to allow for returning the stale value, while we wait for the poller to do it's thing

@nikosdouvlis nikosdouvlis force-pushed the vincent-and-the-doctor branch from d24d455 to 12a12f5 Compare December 8, 2025 17:40
Copy link
Contributor

@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: 1

🤖 Fix all issues with AI agents
In @.changeset/fresh-tigers-hunt.md:
- Around line 1-4: The changeset incorrectly marks a breaking change as "minor"
— reintroduce the removed public option and/or make leewayInSeconds optional on
the GetTokenOptions interface (or add it back with a deprecated comment) so
existing callers continue to compile, and update the .changeset entry to a major
bump; look for the GetTokenOptions declaration and the
.changeset/fresh-tigers-hunt.md file and either restore leewayInSeconds to the
public interface (or mark it optional/deprecated) and change the version line(s)
from minor to major.
🧹 Nitpick comments (1)
.changeset/fresh-tigers-hunt.md (1)

6-8: Add migration guidance for leewayInSeconds removal.

The changeset should include migration guidance for users currently passing leewayInSeconds. Consider adding a note explaining that the option is no longer needed and will be ignored, or provide alternative configuration if applicable.

📝 Suggested improvement
 Add proactive session token refresh. Tokens are now automatically refreshed in the background before they expire, eliminating the need for manual refresh configuration.
 
-Remove `leewayInSeconds` from `GetTokenOptions`. Token refresh timing is now handled automatically.
+**BREAKING**: Remove `leewayInSeconds` from `GetTokenOptions`. Token refresh timing is now handled automatically. If you were previously passing `leewayInSeconds` to `getToken()`, you can safely remove this option—tokens will now be refreshed proactively in the background without additional configuration.

shouldDispatchTokenUpdate,
leewayInSeconds,
),
});
Copy link
Member

Choose a reason for hiding this comment

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

Why are we triggering the same method here?

- Remove leewayInSeconds from GetTokenOptions type
- Delete gettoken-leeway-minimum.md upgrade doc
- Update changeset to major (breaking change)
- Add comments explaining refreshLeadTime and onRefresh re-registration
- Rename upgrade doc title to "proactive background refresh"
- Simplify internal token cache to use default threshold
@jacekradko
Copy link
Member Author

!allow-major

@jacekradko
Copy link
Member Author

!snapshot

@clerk-cookie
Copy link
Collaborator

Hey @jacekradko - the snapshot version command generated the following package versions:

Package Version
@clerk/agent-toolkit 0.2.9-snapshot.v20260129035546
@clerk/astro 3.0.0-snapshot.v20260129035546
@clerk/backend 3.0.0-snapshot.v20260129035546
@clerk/chrome-extension 3.0.0-snapshot.v20260129035546
@clerk/clerk-js 6.0.0-snapshot.v20260129035546
@clerk/dev-cli 1.0.0-snapshot.v20260129035546
@clerk/expo 3.0.0-snapshot.v20260129035546
@clerk/expo-passkeys 1.0.0-snapshot.v20260129035546
@clerk/express 2.0.0-snapshot.v20260129035546
@clerk/fastify 2.7.0-snapshot.v20260129035546
@clerk/localizations 4.0.0-snapshot.v20260129035546
@clerk/msw 0.0.1-snapshot.v20260129035546
@clerk/nextjs 7.0.0-snapshot.v20260129035546
@clerk/nuxt 2.0.0-snapshot.v20260129035546
@clerk/react 6.0.0-snapshot.v20260129035546
@clerk/react-router 3.0.0-snapshot.v20260129035546
@clerk/shared 4.0.0-snapshot.v20260129035546
@clerk/tanstack-react-start 1.0.0-snapshot.v20260129035546
@clerk/testing 2.0.0-snapshot.v20260129035546
@clerk/ui 1.0.0-snapshot.v20260129035546
@clerk/upgrade 2.0.0-snapshot.v20260129035546
@clerk/vue 2.0.0-snapshot.v20260129035546

Tip: Use the snippet copy button below to quickly install the required packages.
@clerk/agent-toolkit

npm i @clerk/agent-toolkit@0.2.9-snapshot.v20260129035546 --save-exact

@clerk/astro

npm i @clerk/astro@3.0.0-snapshot.v20260129035546 --save-exact

@clerk/backend

npm i @clerk/backend@3.0.0-snapshot.v20260129035546 --save-exact

@clerk/chrome-extension

npm i @clerk/chrome-extension@3.0.0-snapshot.v20260129035546 --save-exact

@clerk/clerk-js

npm i @clerk/clerk-js@6.0.0-snapshot.v20260129035546 --save-exact

@clerk/dev-cli

npm i @clerk/dev-cli@1.0.0-snapshot.v20260129035546 --save-exact

@clerk/expo

npm i @clerk/expo@3.0.0-snapshot.v20260129035546 --save-exact

@clerk/expo-passkeys

npm i @clerk/expo-passkeys@1.0.0-snapshot.v20260129035546 --save-exact

@clerk/express

npm i @clerk/express@2.0.0-snapshot.v20260129035546 --save-exact

@clerk/fastify

npm i @clerk/fastify@2.7.0-snapshot.v20260129035546 --save-exact

@clerk/localizations

npm i @clerk/localizations@4.0.0-snapshot.v20260129035546 --save-exact

@clerk/msw

npm i @clerk/msw@0.0.1-snapshot.v20260129035546 --save-exact

@clerk/nextjs

npm i @clerk/nextjs@7.0.0-snapshot.v20260129035546 --save-exact

@clerk/nuxt

npm i @clerk/nuxt@2.0.0-snapshot.v20260129035546 --save-exact

@clerk/react

npm i @clerk/react@6.0.0-snapshot.v20260129035546 --save-exact

@clerk/react-router

npm i @clerk/react-router@3.0.0-snapshot.v20260129035546 --save-exact

@clerk/shared

npm i @clerk/shared@4.0.0-snapshot.v20260129035546 --save-exact

@clerk/tanstack-react-start

npm i @clerk/tanstack-react-start@1.0.0-snapshot.v20260129035546 --save-exact

@clerk/testing

npm i @clerk/testing@2.0.0-snapshot.v20260129035546 --save-exact

@clerk/ui

npm i @clerk/ui@1.0.0-snapshot.v20260129035546 --save-exact

@clerk/upgrade

npm i @clerk/upgrade@2.0.0-snapshot.v20260129035546 --save-exact

@clerk/vue

npm i @clerk/vue@2.0.0-snapshot.v20260129035546 --save-exact

@jacekradko
Copy link
Member Author

!snapshot

@clerk-cookie
Copy link
Collaborator

Hey @jacekradko - the snapshot version command generated the following package versions:

Package Version
@clerk/agent-toolkit 0.2.9-snapshot.v20260130015346
@clerk/astro 3.0.0-snapshot.v20260130015346
@clerk/backend 3.0.0-snapshot.v20260130015346
@clerk/chrome-extension 3.0.0-snapshot.v20260130015346
@clerk/clerk-js 6.0.0-snapshot.v20260130015346
@clerk/dev-cli 1.0.0-snapshot.v20260130015346
@clerk/expo 3.0.0-snapshot.v20260130015346
@clerk/expo-passkeys 1.0.0-snapshot.v20260130015346
@clerk/express 2.0.0-snapshot.v20260130015346
@clerk/fastify 2.7.0-snapshot.v20260130015346
@clerk/localizations 4.0.0-snapshot.v20260130015346
@clerk/msw 0.0.1-snapshot.v20260130015346
@clerk/nextjs 7.0.0-snapshot.v20260130015346
@clerk/nuxt 2.0.0-snapshot.v20260130015346
@clerk/react 6.0.0-snapshot.v20260130015346
@clerk/react-router 3.0.0-snapshot.v20260130015346
@clerk/shared 4.0.0-snapshot.v20260130015346
@clerk/tanstack-react-start 1.0.0-snapshot.v20260130015346
@clerk/testing 2.0.0-snapshot.v20260130015346
@clerk/ui 1.0.0-snapshot.v20260130015346
@clerk/upgrade 2.0.0-snapshot.v20260130015346
@clerk/vue 2.0.0-snapshot.v20260130015346

Tip: Use the snippet copy button below to quickly install the required packages.
@clerk/agent-toolkit

npm i @clerk/agent-toolkit@0.2.9-snapshot.v20260130015346 --save-exact

@clerk/astro

npm i @clerk/astro@3.0.0-snapshot.v20260130015346 --save-exact

@clerk/backend

npm i @clerk/backend@3.0.0-snapshot.v20260130015346 --save-exact

@clerk/chrome-extension

npm i @clerk/chrome-extension@3.0.0-snapshot.v20260130015346 --save-exact

@clerk/clerk-js

npm i @clerk/clerk-js@6.0.0-snapshot.v20260130015346 --save-exact

@clerk/dev-cli

npm i @clerk/dev-cli@1.0.0-snapshot.v20260130015346 --save-exact

@clerk/expo

npm i @clerk/expo@3.0.0-snapshot.v20260130015346 --save-exact

@clerk/expo-passkeys

npm i @clerk/expo-passkeys@1.0.0-snapshot.v20260130015346 --save-exact

@clerk/express

npm i @clerk/express@2.0.0-snapshot.v20260130015346 --save-exact

@clerk/fastify

npm i @clerk/fastify@2.7.0-snapshot.v20260130015346 --save-exact

@clerk/localizations

npm i @clerk/localizations@4.0.0-snapshot.v20260130015346 --save-exact

@clerk/msw

npm i @clerk/msw@0.0.1-snapshot.v20260130015346 --save-exact

@clerk/nextjs

npm i @clerk/nextjs@7.0.0-snapshot.v20260130015346 --save-exact

@clerk/nuxt

npm i @clerk/nuxt@2.0.0-snapshot.v20260130015346 --save-exact

@clerk/react

npm i @clerk/react@6.0.0-snapshot.v20260130015346 --save-exact

@clerk/react-router

npm i @clerk/react-router@3.0.0-snapshot.v20260130015346 --save-exact

@clerk/shared

npm i @clerk/shared@4.0.0-snapshot.v20260130015346 --save-exact

@clerk/tanstack-react-start

npm i @clerk/tanstack-react-start@1.0.0-snapshot.v20260130015346 --save-exact

@clerk/testing

npm i @clerk/testing@2.0.0-snapshot.v20260130015346 --save-exact

@clerk/ui

npm i @clerk/ui@1.0.0-snapshot.v20260130015346 --save-exact

@clerk/upgrade

npm i @clerk/upgrade@2.0.0-snapshot.v20260130015346 --save-exact

@clerk/vue

npm i @clerk/vue@2.0.0-snapshot.v20260130015346 --save-exact

@jacekradko jacekradko merged commit 3ff86c4 into main Jan 30, 2026
89 of 92 checks passed
@jacekradko jacekradko deleted the feat/stale-while-revalidate-token branch January 30, 2026 02:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants