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

    • Tokens now refresh proactively in the background with immediate cached responses and updated tokens available after refresh.
    • Cross-tab synchronization retained.
  • Breaking Changes

    • Removed leewayInSeconds from token options; use background refresh configuration instead.
  • Documentation

    • Added guidance describing the stale-while-revalidate behavior and how refresh timing works.
  • Tests

    • Expanded and updated tests to cover proactive refresh, resolved-token handling, timing, and edge cases.

✏️ 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: 6c3a5bf

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 gains onRefresh and resolvedToken. A POLLER_INTERVAL_IN_MS constant drives refresh timing and timers are stored/cleared on entries. leewayInSeconds was removed from GetToken options. Session now tracks in-flight background refreshes to prevent concurrent refreshes and emits token update/resolution events; tests and codemods were updated accordingly.

🚥 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
Title check ✅ Passed The title accurately reflects the main feature introduced: proactive session token refresh scheduling and background refresh mechanism.
Linked Issues check ✅ Passed The PR successfully addresses all four coding objectives from USER-4087: avoids cache deletion for leeway tokens, returns cached tokens immediately even within leeway, implements asynchronous background refresh via timers, and eliminates periodic latency spikes.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing proactive token refresh. The modifications to test fixtures, codemods for removing deprecated leewayInSeconds, and documentation align with the feature requirements.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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


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 Review Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jan 29, 2026 3:04am

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: 6c3a5bf

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
Replace the stale-while-revalidate approach with a timer-based
proactive refresh mechanism. Instead of relying on getToken() calls
to trigger background refresh, we now schedule a timer when tokens
are cached that fires before the leeway period begins.

Key changes:
- Add onRefresh callback to TokenCacheEntry for proactive refresh
- Schedule refresh timer at (TTL - leeway - 2s) = 43s for 60s tokens
- Remove needsRefresh flag and refreshThreshold parameter from cache
- Remove backgroundRefreshThreshold and refreshIfStale from GetTokenOptions
- Update Session to pass onRefresh callback when caching tokens

Benefits:
- More consistent refresh regardless of getToken() call frequency
- Zero risk: if timer doesn't fire, poller catches it as fallback
- Fits naturally with existing cleanup timer pattern
@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.v20260127022535
@clerk/astro 3.0.0-snapshot.v20260127022535
@clerk/backend 3.0.0-snapshot.v20260127022535
@clerk/chrome-extension 3.0.0-snapshot.v20260127022535
@clerk/clerk-js 6.0.0-snapshot.v20260127022535
@clerk/dev-cli 1.0.0-snapshot.v20260127022535
@clerk/expo 3.0.0-snapshot.v20260127022535
@clerk/expo-passkeys 1.0.0-snapshot.v20260127022535
@clerk/express 2.0.0-snapshot.v20260127022535
@clerk/fastify 2.6.9-snapshot.v20260127022535
@clerk/localizations 4.0.0-snapshot.v20260127022535
@clerk/msw 0.0.1-snapshot.v20260127022535
@clerk/nextjs 7.0.0-snapshot.v20260127022535
@clerk/nuxt 2.0.0-snapshot.v20260127022535
@clerk/react 6.0.0-snapshot.v20260127022535
@clerk/react-router 3.0.0-snapshot.v20260127022535
@clerk/shared 4.0.0-snapshot.v20260127022535
@clerk/tanstack-react-start 1.0.0-snapshot.v20260127022535
@clerk/testing 2.0.0-snapshot.v20260127022535
@clerk/ui 1.0.0-snapshot.v20260127022535
@clerk/upgrade 2.0.0-snapshot.v20260127022535
@clerk/vue 2.0.0-snapshot.v20260127022535

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

npm i @clerk/[email protected] --save-exact

@clerk/astro

npm i @clerk/[email protected] --save-exact

@clerk/backend

npm i @clerk/[email protected] --save-exact

@clerk/chrome-extension

npm i @clerk/[email protected] --save-exact

@clerk/clerk-js

npm i @clerk/[email protected] --save-exact

@clerk/dev-cli

npm i @clerk/[email protected] --save-exact

@clerk/expo

npm i @clerk/[email protected] --save-exact

@clerk/expo-passkeys

npm i @clerk/[email protected] --save-exact

@clerk/express

npm i @clerk/[email protected] --save-exact

@clerk/fastify

npm i @clerk/[email protected] --save-exact

@clerk/localizations

npm i @clerk/[email protected] --save-exact

@clerk/msw

npm i @clerk/[email protected] --save-exact

@clerk/nextjs

npm i @clerk/[email protected] --save-exact

@clerk/nuxt

npm i @clerk/[email protected] --save-exact

@clerk/react

npm i @clerk/[email protected] --save-exact

@clerk/react-router

npm i @clerk/[email protected] --save-exact

@clerk/shared

npm i @clerk/[email protected] --save-exact

@clerk/tanstack-react-start

npm i @clerk/[email protected] --save-exact

@clerk/testing

npm i @clerk/[email protected] --save-exact

@clerk/ui

npm i @clerk/[email protected] --save-exact

@clerk/upgrade

npm i @clerk/[email protected] --save-exact

@clerk/vue

npm i @clerk/[email protected] --save-exact

@jacekradko jacekradko changed the title feat(clerk-js): Stale-while-revalidate session token feat(clerk-js): Proactive session token refresh Jan 27, 2026
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

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