From b5f683de5f421697a4da91befce57093727a7a36 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 21:45:07 -0600 Subject: [PATCH 01/25] feat(clerk-js): Stale-while-revalidate session token --- .../src/core/__tests__/tokenCache.test.ts | 45 +++++-------------- .../src/core/auth/SessionCookiePoller.ts | 5 ++- .../clerk-js/src/core/resources/Session.ts | 2 +- packages/clerk-js/src/core/tokenCache.ts | 23 +++++----- 4 files changed, 27 insertions(+), 48 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index a5d7f892bb8..dcd59f4bbff 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -355,7 +355,7 @@ describe('SessionTokenCache', () => { expect(cachedEntry).toBeUndefined(); }); - it('removes token when it expires within the leeway threshold', async () => { + it('removes token when less than 5 seconds remain', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const iat = nowSeconds; const exp = iat + 20; @@ -366,7 +366,8 @@ describe('SessionTokenCache', () => { jwt: { claims: { exp, iat } }, } as any); - SessionTokenCache.set({ createdAt: nowSeconds - 13, tokenId: 'soon_expired_token', tokenResolver }); + // Token has 20s TTL, created 16s ago = 4s remaining (< 5s threshold) + SessionTokenCache.set({ createdAt: nowSeconds - 16, tokenId: 'soon_expired_token', tokenResolver }); await tokenResolver; @@ -532,59 +533,37 @@ describe('SessionTokenCache', () => { }); }); - describe('leeway precision', () => { - it('includes 5 second sync leeway on top of default 10 second leeway', async () => { + describe('minimum TTL threshold', () => { + it('returns token until less than 5 seconds remain', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'leeway-token', + id: 'threshold-token', jwt, object: 'token', }); const tokenResolver = Promise.resolve(token); - const key = { audience: 'leeway-test', tokenId: 'leeway-token' }; + const key = { audience: 'threshold-test', tokenId: 'threshold-token' }; SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toMatchObject({ tokenId: 'leeway-token' }); + expect(SessionTokenCache.get(key)).toMatchObject({ tokenId: 'threshold-token' }); - vi.advanceTimersByTime(44 * 1000); + // At 54s elapsed, 6s remaining - should still return token + vi.advanceTimersByTime(54 * 1000); expect(SessionTokenCache.get(key)).toBeDefined(); + // At 55s elapsed, 5s remaining - should still return token vi.advanceTimersByTime(1 * 1000); expect(SessionTokenCache.get(key)).toBeDefined(); + // At 56s elapsed, 4s remaining - should force sync refresh vi.advanceTimersByTime(1 * 1000); expect(SessionTokenCache.get(key)).toBeUndefined(); }); - - it('enforces minimum 5 second sync leeway even when leeway is set to 0', async () => { - const nowSeconds = Math.floor(Date.now() / 1000); - const jwt = createJwtWithTtl(nowSeconds, 60); - - const token = new Token({ - id: 'zero-leeway-token', - jwt, - object: 'token', - }); - - const tokenResolver = Promise.resolve(token); - const key = { audience: 'zero-leeway-test', tokenId: 'zero-leeway-token' }; - - SessionTokenCache.set({ ...key, tokenResolver }); - await tokenResolver; - - expect(SessionTokenCache.get(key, 0)).toMatchObject({ tokenId: 'zero-leeway-token' }); - - vi.advanceTimersByTime(54 * 1000); - expect(SessionTokenCache.get(key, 0)).toBeDefined(); - - vi.advanceTimersByTime(2 * 1000); - expect(SessionTokenCache.get(key, 0)).toBeUndefined(); - }); }); describe('dynamic TTL calculation', () => { diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index 91e8040f79d..ed9f1f04c76 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -3,7 +3,8 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers'; import { SafeLock } from './safeLock'; const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; -const INTERVAL_IN_MS = 5 * 1_000; + +export const POLLER_INTERVAL_IN_MS = 5 * 1_000; export class SessionCookiePoller { private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); @@ -20,7 +21,7 @@ export class SessionCookiePoller { const run = async () => { this.initiated = true; await this.lock.acquireLockAndRun(cb); - this.timerId = this.workerTimers.setTimeout(run, INTERVAL_IN_MS); + this.timerId = this.workerTimers.setTimeout(run, POLLER_INTERVAL_IN_MS); }; void run(); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index a31123b8d71..2ab595d8c66 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -369,7 +369,7 @@ export class Session extends BaseResource implements SessionResource { const tokenId = this.#getCacheId(template, organizationId); - const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); + const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }); // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index ddf54e06c0a..d8491ca64df 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -3,6 +3,7 @@ import type { TokenResource } from '@clerk/shared/types'; import { debugLogger } from '@/utils/debug'; import { TokenId } from '@/utils/tokenId'; +import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; import { Token } from './resources/internal'; /** @@ -59,10 +60,9 @@ export interface TokenCache { * Retrieves a cached token entry if it exists and has not expired. * * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry - * @param leeway - Optional seconds before expiration to treat token as expired (default: 10s). Combined with 5s sync leeway. * @returns The cached TokenCacheEntry if found and valid, undefined otherwise */ - get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheEntry | undefined; + get(cacheKeyJSON: TokenCacheKeyJSON): TokenCacheEntry | undefined; /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. @@ -82,9 +82,9 @@ export interface TokenCache { const KEY_PREFIX = 'clerk'; const DELIMITER = '::'; -const LEEWAY = 10; -// This value should have the same value as the INTERVAL_IN_MS in SessionCookiePoller -const SYNC_LEEWAY = 5; +// Minimum remaining TTL (in seconds) before forcing a synchronous refresh. +// Uses the poller interval to ensure the poller has time to refresh before expiration. +const MIN_REMAINING_TTL_IN_SECONDS = POLLER_INTERVAL_IN_MS / 1000; const BROADCAST = { broadcast: true }; const NO_BROADCAST = { broadcast: false }; @@ -176,7 +176,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { cache.clear(); }; - const get = (cacheKeyJSON: TokenCacheKeyJSON, leeway = LEEWAY): TokenCacheEntry | undefined => { + const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheEntry | undefined => { ensureBroadcastChannel(); const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); @@ -188,13 +188,11 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const nowSeconds = Math.floor(Date.now() / 1000); const elapsed = nowSeconds - value.createdAt; + const remainingTtl = (value.expiresIn ?? Infinity) - elapsed; - // Include poller interval as part of the leeway to ensure the cache value - // will be valid for more than the SYNC_LEEWAY or the leeway in the next poll. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const expiresSoon = value.expiresIn! - elapsed < (leeway || 1) + SYNC_LEEWAY; - - if (expiresSoon) { + // Token expires within the poller interval - force synchronous refresh + // to avoid returning a token that may expire before the poller runs. + if (remainingTtl < MIN_REMAINING_TTL_IN_SECONDS) { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } @@ -202,6 +200,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { return; } + // Return the entry - the session token poller handles background refresh. return value.entry; }; From caa97e28f51a71de89a051dc098815b298eeacca Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 26 Nov 2025 20:09:41 -0600 Subject: [PATCH 02/25] wip --- .../src/core/__tests__/tokenCache.test.ts | 233 +++++++++++++----- .../clerk-js/src/core/resources/Session.ts | 38 ++- packages/clerk-js/src/core/tokenCache.ts | 46 +++- 3 files changed, 245 insertions(+), 72 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index dcd59f4bbff..ed420bdde01 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -206,9 +206,9 @@ describe('SessionTokenCache', () => { } as MessageEvent; broadcastListener(newerEvent); - const cachedEntryAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntryAfterNewer).toBeDefined(); - const newerCreatedAt = cachedEntryAfterNewer?.createdAt; + const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(resultAfterNewer).toBeDefined(); + const newerCreatedAt = resultAfterNewer?.entry.createdAt; // mockJwt has iat: 1666648250, so create an older one with iat: 1666648190 (60 seconds earlier) const olderJwt = @@ -226,9 +226,9 @@ describe('SessionTokenCache', () => { broadcastListener(olderEvent); - const cachedEntryAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntryAfterOlder).toBeDefined(); - expect(cachedEntryAfterOlder?.createdAt).toBe(newerCreatedAt); + const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(resultAfterOlder).toBeDefined(); + expect(resultAfterOlder?.entry.createdAt).toBe(newerCreatedAt); }); it('successfully updates cache with valid token', () => { @@ -245,9 +245,9 @@ describe('SessionTokenCache', () => { broadcastListener(event); - const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntry).toBeDefined(); - expect(cachedEntry?.tokenId).toBe('session_123'); + const result = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('session_123'); }); it('does not re-broadcast when receiving a broadcast message', async () => { @@ -271,8 +271,8 @@ describe('SessionTokenCache', () => { await Promise.resolve(); // Verify cache was updated - const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntry).toBeDefined(); + const result = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(result).toBeDefined(); // Critical: postMessage should NOT be called when handling a broadcast expect(mockBroadcastChannel.postMessage).not.toHaveBeenCalled(); @@ -331,9 +331,10 @@ describe('SessionTokenCache', () => { // Wait for promise to resolve await tokenResolver; - const cachedEntry = SessionTokenCache.get({ tokenId: 'future_token' }); - expect(cachedEntry).toBeDefined(); - expect(cachedEntry?.tokenId).toBe('future_token'); + const result = SessionTokenCache.get({ tokenId: 'future_token' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('future_token'); + expect(result?.needsRefresh).toBe(false); }); it('removes token when it has already expired based on duration', async () => { @@ -351,11 +352,11 @@ describe('SessionTokenCache', () => { await tokenResolver; - const cachedEntry = SessionTokenCache.get({ tokenId: 'expired_token' }); - expect(cachedEntry).toBeUndefined(); + const result = SessionTokenCache.get({ tokenId: 'expired_token' }); + expect(result).toBeUndefined(); }); - it('removes token when less than 5 seconds remain', async () => { + it('returns token with needsRefresh when remaining TTL is less than leeway (SWR)', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const iat = nowSeconds; const exp = iat + 20; @@ -366,13 +367,16 @@ describe('SessionTokenCache', () => { jwt: { claims: { exp, iat } }, } as any); - // Token has 20s TTL, created 16s ago = 4s remaining (< 5s threshold) - SessionTokenCache.set({ createdAt: nowSeconds - 16, tokenId: 'soon_expired_token', tokenResolver }); + // Token has 20s TTL, created 11s ago = 9s remaining (< 10s default leeway) + SessionTokenCache.set({ createdAt: nowSeconds - 11, tokenId: 'soon_expired_token', tokenResolver }); await tokenResolver; - const cachedEntry = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); - expect(cachedEntry).toBeUndefined(); + // SWR: Token is still valid (9s > 0), so it should be returned with needsRefresh=true + const result = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('soon_expired_token'); + expect(result?.needsRefresh).toBe(true); }); it('returns token when expiresAt is undefined (promise not yet resolved)', () => { @@ -381,9 +385,9 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ tokenId: 'pending_token', tokenResolver: pendingTokenResolver }); - const cachedEntry = SessionTokenCache.get({ tokenId: 'pending_token' }); - expect(cachedEntry).toBeDefined(); - expect(cachedEntry?.tokenId).toBe('pending_token'); + const result = SessionTokenCache.get({ tokenId: 'pending_token' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('pending_token'); }); }); @@ -472,7 +476,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); expect(SessionTokenCache.size()).toBe(1); SessionTokenCache.clear(); @@ -513,56 +517,173 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); - const cachedWhilePending = SessionTokenCache.get(key); - expect(cachedWhilePending).toBeDefined(); - expect(cachedWhilePending?.tokenId).toBe('lifecycle-token'); + const resultWhilePending = SessionTokenCache.get(key); + expect(resultWhilePending).toBeDefined(); + expect(resultWhilePending?.entry.tokenId).toBe('lifecycle-token'); expect(isResolved).toBe(false); vi.advanceTimersByTime(100); await tokenResolver; - const cachedAfterResolved = SessionTokenCache.get(key); + const resultAfterResolved = SessionTokenCache.get(key); expect(isResolved).toBe(true); - expect(cachedAfterResolved).toBeDefined(); - expect(cachedAfterResolved?.tokenId).toBe('lifecycle-token'); + expect(resultAfterResolved).toBeDefined(); + expect(resultAfterResolved?.entry.tokenId).toBe('lifecycle-token'); vi.advanceTimersByTime(60 * 1000); - const cachedAfterExpiration = SessionTokenCache.get(key); - expect(cachedAfterExpiration).toBeUndefined(); + const resultAfterExpiration = SessionTokenCache.get(key); + expect(resultAfterExpiration).toBeUndefined(); }); }); - describe('minimum TTL threshold', () => { - it('returns token until less than 5 seconds remain', async () => { + describe('SWR leeway behavior', () => { + it('returns needsRefresh=false when token has plenty of time remaining', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'threshold-token', + id: 'fresh-token', jwt, object: 'token', }); const tokenResolver = Promise.resolve(token); - const key = { audience: 'threshold-test', tokenId: 'threshold-token' }; + const key = { audience: 'fresh-test', tokenId: 'fresh-token' }; SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toMatchObject({ tokenId: 'threshold-token' }); + // Token just created, 60s remaining - should return needsRefresh=false + const result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('fresh-token'); + expect(result?.needsRefresh).toBe(false); + }); - // At 54s elapsed, 6s remaining - should still return token - vi.advanceTimersByTime(54 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + it('returns needsRefresh=true when token is within default leeway (SWR)', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'expiring-token', + jwt, + object: 'token', + }); - // At 55s elapsed, 5s remaining - should still return token - vi.advanceTimersByTime(1 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + const tokenResolver = Promise.resolve(token); + const key = { audience: 'expiring-test', tokenId: 'expiring-token' }; - // At 56s elapsed, 4s remaining - should force sync refresh - vi.advanceTimersByTime(1 * 1000); - expect(SessionTokenCache.get(key)).toBeUndefined(); + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // At 49s elapsed, 11s remaining - fresh, no refresh needed + vi.advanceTimersByTime(49 * 1000); + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('expiring-token'); + expect(result?.needsRefresh).toBe(false); + + // At 51s elapsed, 9s remaining (< 10s leeway) - SWR: return token with needsRefresh=true + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('expiring-token'); + expect(result?.needsRefresh).toBe(true); + + // At 60s elapsed, 0s remaining - token actually expired, return undefined + vi.advanceTimersByTime(9 * 1000); + result = SessionTokenCache.get(key); + expect(result).toBeUndefined(); + }); + + it('returns needsRefresh=true only once per token (prevents duplicate refreshes)', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'dedupe-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { audience: 'dedupe-test', tokenId: 'dedupe-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // Advance to within leeway + vi.advanceTimersByTime(51 * 1000); // 9s remaining + + // First call: needsRefresh=true + let result = SessionTokenCache.get(key); + expect(result?.needsRefresh).toBe(true); + + // Second call: needsRefresh=false (already marked for refresh) + result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('dedupe-token'); + expect(result?.needsRefresh).toBe(false); + }); + + it('honors larger custom leeway values', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'custom-leeway-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { audience: 'custom-leeway-test', tokenId: 'custom-leeway-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // At 29s elapsed, 31s remaining - fresh with 30s leeway + vi.advanceTimersByTime(29 * 1000); + let result = SessionTokenCache.get(key, 30); + expect(result?.entry.tokenId).toBe('custom-leeway-token'); + expect(result?.needsRefresh).toBe(false); + + // At 31s elapsed, 29s remaining (< 30s leeway) - needs refresh + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key, 30); + expect(result?.entry.tokenId).toBe('custom-leeway-token'); + expect(result?.needsRefresh).toBe(true); + }); + + it('enforces minimum 5 second leeway even when leeway is set to 0', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'zero-leeway-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { audience: 'zero-leeway-test', tokenId: 'zero-leeway-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // At 54s elapsed, 6s remaining - still fresh with min 5s leeway + vi.advanceTimersByTime(54 * 1000); + let result = SessionTokenCache.get(key, 0); + expect(result?.entry.tokenId).toBe('zero-leeway-token'); + expect(result?.needsRefresh).toBe(false); + + // At 56s elapsed, 4s remaining (< 5s min leeway) - needs refresh + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key, 0); + expect(result?.entry.tokenId).toBe('zero-leeway-token'); + expect(result?.needsRefresh).toBe(true); + + // At 60s elapsed, 0s remaining - actually expired + vi.advanceTimersByTime(4 * 1000); + result = SessionTokenCache.get(key, 0); + expect(result).toBeUndefined(); }); }); @@ -583,7 +704,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); vi.advanceTimersByTime(30 * 1000); @@ -606,10 +727,10 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); vi.advanceTimersByTime(90 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); vi.advanceTimersByTime(30 * 1000); expect(SessionTokenCache.get(key)).toBeUndefined(); @@ -635,7 +756,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ tokenId: label, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get({ tokenId: label })).toBeDefined(); + expect(SessionTokenCache.get({ tokenId: label })?.entry).toBeDefined(); vi.advanceTimersByTime(ttl * 1000); expect(SessionTokenCache.get({ tokenId: label })).toBeUndefined(); @@ -663,9 +784,9 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...keyWithAudience, tokenResolver }); await tokenResolver; - const cached = SessionTokenCache.get(keyWithAudience); - expect(cached).toBeDefined(); - expect(cached?.audience).toBe('https://api.example.com'); + const result = SessionTokenCache.get(keyWithAudience); + expect(result).toBeDefined(); + expect(result?.entry.audience).toBe('https://api.example.com'); }); it('treats tokens with different audiences as separate entries', async () => { @@ -688,8 +809,8 @@ describe('SessionTokenCache', () => { await Promise.all([resolver1, resolver2]); expect(SessionTokenCache.size()).toBe(2); - expect(SessionTokenCache.get(key1)).toBeDefined(); - expect(SessionTokenCache.get(key2)).toBeDefined(); + expect(SessionTokenCache.get(key1)?.entry).toBeDefined(); + expect(SessionTokenCache.get(key2)?.entry).toBeDefined(); }); }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 2ab595d8c66..ca2e5d82392 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -369,20 +369,27 @@ export class Session extends BaseResource implements SessionResource { const tokenId = this.#getCacheId(template, organizationId); - const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }); + const cacheResult = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; - if (cachedEntry) { + if (cacheResult) { + // SWR: If token is expiring soon, trigger background refresh (fire-and-forget) + if (cacheResult.needsRefresh) { + debugLogger.debug('Token expiring soon, triggering background refresh', { tokenId }, 'session'); + void this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate); + } + debugLogger.debug( - 'Using cached token (no fetch needed)', + 'Using cached token', { + needsRefresh: cacheResult.needsRefresh, tokenId, }, 'session', ); - const cachedToken = await cachedEntry.tokenResolver; + const cachedToken = await cacheResult.entry.tokenResolver; if (shouldDispatchTokenUpdate) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); } @@ -390,6 +397,15 @@ export class Session extends BaseResource implements SessionResource { return cachedToken.getRawString() || null; } + return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate); + } + + #fetchToken( + template: string | undefined, + organizationId: string | undefined | null, + tokenId: string, + shouldDispatchTokenUpdate: boolean, + ): Promise { debugLogger.info( 'Fetching new token from API', { @@ -405,7 +421,7 @@ export class Session extends BaseResource implements SessionResource { // TODO: update template endpoint to accept organizationId const params: Record = template ? {} : { organizationId }; - const tokenResolver = Token.create(path, params, skipCache); + const tokenResolver = Token.create(path, params, false); // Cache the promise immediately to prevent concurrent calls from triggering duplicate requests SessionTokenCache.set({ tokenId, tokenResolver }); @@ -426,6 +442,18 @@ export class Session extends BaseResource implements SessionResource { }); } + #refreshTokenInBackground( + template: string | undefined, + organizationId: string | undefined | null, + tokenId: string, + shouldDispatchTokenUpdate: boolean, + ): void { + // Fire-and-forget background refresh - errors are silently ignored + this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate).catch(() => { + debugLogger.warn('Background token refresh failed', { tokenId }, 'session'); + }); + } + get currentTask() { const [task] = this.tasks ?? []; return task; diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index d8491ca64df..9bc919a6722 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -40,9 +40,20 @@ interface TokenCacheValue { createdAt: Seconds; entry: TokenCacheEntry; expiresIn?: Seconds; + /** Indicates a background refresh is in progress to prevent duplicate requests */ + isRefreshing?: boolean; timeoutId?: ReturnType; } +/** + * Result from cache lookup containing the entry and refresh status. + */ +export interface TokenCacheGetResult { + entry: TokenCacheEntry; + /** Indicates the token is valid but expiring soon and should be refreshed in the background */ + needsRefresh: boolean; +} + export interface TokenCache { /** * Removes all cached entries and clears associated timeouts. @@ -58,11 +69,13 @@ export interface TokenCache { /** * Retrieves a cached token entry if it exists and has not expired. + * Implements stale-while-revalidate: returns valid tokens immediately and signals when background refresh is needed. * * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry - * @returns The cached TokenCacheEntry if found and valid, undefined otherwise + * @param leeway - Seconds before expiration to trigger background refresh (default: 10s). Minimum is the poller interval. + * @returns Result with entry and refresh flag, or undefined if token doesn't exist or is expired */ - get(cacheKeyJSON: TokenCacheKeyJSON): TokenCacheEntry | undefined; + get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheGetResult | undefined; /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. @@ -82,6 +95,7 @@ export interface TokenCache { const KEY_PREFIX = 'clerk'; const DELIMITER = '::'; +const DEFAULT_LEEWAY = 10; // Minimum remaining TTL (in seconds) before forcing a synchronous refresh. // Uses the poller interval to ensure the poller has time to refresh before expiration. const MIN_REMAINING_TTL_IN_SECONDS = POLLER_INTERVAL_IN_MS / 1000; @@ -176,7 +190,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { cache.clear(); }; - const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheEntry | undefined => { + const get = (cacheKeyJSON: TokenCacheKeyJSON, leeway = DEFAULT_LEEWAY): TokenCacheGetResult | undefined => { ensureBroadcastChannel(); const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); @@ -190,9 +204,8 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const elapsed = nowSeconds - value.createdAt; const remainingTtl = (value.expiresIn ?? Infinity) - elapsed; - // Token expires within the poller interval - force synchronous refresh - // to avoid returning a token that may expire before the poller runs. - if (remainingTtl < MIN_REMAINING_TTL_IN_SECONDS) { + // Token is actually expired - remove it and don't return + if (remainingTtl <= 0) { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } @@ -200,8 +213,19 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { return; } - // Return the entry - the session token poller handles background refresh. - return value.entry; + // Use the larger of: caller's requested leeway or minimum needed for poller. + const effectiveLeeway = Math.max(leeway, MIN_REMAINING_TTL_IN_SECONDS); + + // Token is valid but expiring soon - signal for background refresh + // Only signal once per token to prevent duplicate refresh requests + const needsRefresh = remainingTtl < effectiveLeeway && !value.isRefreshing; + + if (needsRefresh) { + value.isRefreshing = true; + } + + // SWR: Return the valid token immediately, caller handles background refresh if needed + return { entry: value.entry, needsRefresh }; }; /** @@ -250,9 +274,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } try { - const existingEntry = get({ tokenId: data.tokenId }); - if (existingEntry) { - const existingToken = await existingEntry.tokenResolver; + const result = get({ tokenId: data.tokenId }); + if (result) { + const existingToken = await result.entry.tokenResolver; const existingIat = existingToken.jwt?.claims?.iat; if (existingIat && existingIat >= iat) { debugLogger.debug( From 8c4092f60e5ed80a15b87333f577779ded975903 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 1 Dec 2025 11:58:27 -0600 Subject: [PATCH 03/25] add hard cutoff for background refresh --- .../src/core/__tests__/tokenCache.test.ts | 157 +++++++++++++++++- packages/clerk-js/src/core/tokenCache.ts | 21 ++- 2 files changed, 163 insertions(+), 15 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index ed420bdde01..a70acf1235d 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -652,7 +652,7 @@ describe('SessionTokenCache', () => { expect(result?.needsRefresh).toBe(true); }); - it('enforces minimum 5 second leeway even when leeway is set to 0', async () => { + it('enforces minimum 15 second threshold even when leeway is set to 0', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); @@ -668,25 +668,170 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - // At 54s elapsed, 6s remaining - still fresh with min 5s leeway - vi.advanceTimersByTime(54 * 1000); + // 16s remaining (above 15s threshold) + vi.advanceTimersByTime(44 * 1000); let result = SessionTokenCache.get(key, 0); expect(result?.entry.tokenId).toBe('zero-leeway-token'); expect(result?.needsRefresh).toBe(false); - // At 56s elapsed, 4s remaining (< 5s min leeway) - needs refresh + // 14s remaining (below 15s threshold) vi.advanceTimersByTime(2 * 1000); result = SessionTokenCache.get(key, 0); expect(result?.entry.tokenId).toBe('zero-leeway-token'); expect(result?.needsRefresh).toBe(true); - // At 60s elapsed, 0s remaining - actually expired - vi.advanceTimersByTime(4 * 1000); + // 0s remaining (expired) + vi.advanceTimersByTime(14 * 1000); result = SessionTokenCache.get(key, 0); expect(result).toBeUndefined(); }); }); + describe('conservative threshold behavior', () => { + it('returns needsRefresh=false when TTL is comfortably above 15s threshold', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'above-threshold-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'above-threshold-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // 60s remaining + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('above-threshold-token'); + expect(result?.needsRefresh).toBe(false); + + // 20s remaining + vi.advanceTimersByTime(40 * 1000); + result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('above-threshold-token'); + expect(result?.needsRefresh).toBe(false); + }); + + it('returns needsRefresh=true when TTL drops just below 15s threshold', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'below-threshold-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'below-threshold-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // 16s remaining + vi.advanceTimersByTime(44 * 1000); + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('below-threshold-token'); + expect(result?.needsRefresh).toBe(false); + + // 14s remaining + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('below-threshold-token'); + expect(result?.needsRefresh).toBe(true); + }); + + it('uses caller leeway when larger than 15s threshold', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'large-leeway-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'large-leeway-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // 21s remaining (leeway=20s) + vi.advanceTimersByTime(39 * 1000); + let result = SessionTokenCache.get(key, 20); + expect(result?.entry.tokenId).toBe('large-leeway-token'); + expect(result?.needsRefresh).toBe(false); + + // 19s remaining (leeway=20s) + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key, 20); + expect(result?.entry.tokenId).toBe('large-leeway-token'); + expect(result?.needsRefresh).toBe(true); + }); + + it('ignores caller leeway when smaller than 15s threshold', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'small-leeway-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'small-leeway-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // 16s remaining (leeway=5s, but min threshold is 15s) + vi.advanceTimersByTime(44 * 1000); + let result = SessionTokenCache.get(key, 5); + expect(result?.entry.tokenId).toBe('small-leeway-token'); + expect(result?.needsRefresh).toBe(false); + + // 14s remaining + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key, 5); + expect(result?.entry.tokenId).toBe('small-leeway-token'); + expect(result?.needsRefresh).toBe(true); + }); + + it('forces synchronous refresh when token has less than poller interval remaining', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'hard-cutoff-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'hard-cutoff-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // 6s remaining (just above 5s cutoff) + vi.advanceTimersByTime(54 * 1000); + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('hard-cutoff-token'); + expect(result?.needsRefresh).toBe(true); + + // 4s remaining (below 5s cutoff) - forces sync refresh + vi.advanceTimersByTime(2 * 1000); + result = SessionTokenCache.get(key); + expect(result).toBeUndefined(); + }); + }); + describe('dynamic TTL calculation', () => { it('handles tokens with short TTL (30 seconds)', async () => { const nowSeconds = Math.floor(Date.now() / 1000); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 9bc919a6722..bd58a4e1a70 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -68,12 +68,13 @@ export interface TokenCache { close(): void; /** - * Retrieves a cached token entry if it exists and has not expired. + * Retrieves a cached token entry if it exists and is safe to use. * Implements stale-while-revalidate: returns valid tokens immediately and signals when background refresh is needed. + * Forces synchronous refresh if token has less than one poller interval remaining. * * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry - * @param leeway - Seconds before expiration to trigger background refresh (default: 10s). Minimum is the poller interval. - * @returns Result with entry and refresh flag, or undefined if token doesn't exist or is expired + * @param leeway - Seconds before expiration to trigger background refresh (default: 10s). Minimum is 15s. + * @returns Result with entry and refresh flag, or undefined if token is missing/expired/too close to expiration */ get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheGetResult | undefined; @@ -96,9 +97,12 @@ export interface TokenCache { const KEY_PREFIX = 'clerk'; const DELIMITER = '::'; const DEFAULT_LEEWAY = 10; -// Minimum remaining TTL (in seconds) before forcing a synchronous refresh. -// Uses the poller interval to ensure the poller has time to refresh before expiration. -const MIN_REMAINING_TTL_IN_SECONDS = POLLER_INTERVAL_IN_MS / 1000; + +/** + * Conservative threshold accounting for timer jitter, SafeLock contention (~5s), + * network latency, and tolerance for missed poller ticks. + */ +const MIN_REMAINING_TTL_IN_SECONDS = 15; const BROADCAST = { broadcast: true }; const NO_BROADCAST = { broadcast: false }; @@ -204,8 +208,8 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const elapsed = nowSeconds - value.createdAt; const remainingTtl = (value.expiresIn ?? Infinity) - elapsed; - // Token is actually expired - remove it and don't return - if (remainingTtl <= 0) { + // Token expired or dangerously close to expiration - force synchronous refresh + if (remainingTtl <= POLLER_INTERVAL_IN_MS / 1000) { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } @@ -213,7 +217,6 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { return; } - // Use the larger of: caller's requested leeway or minimum needed for poller. const effectiveLeeway = Math.max(leeway, MIN_REMAINING_TTL_IN_SECONDS); // Token is valid but expiring soon - signal for background refresh From 460964ca9050260522a0fd9046ad3e5a13ee4f4c Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 2 Dec 2025 08:24:32 -0600 Subject: [PATCH 04/25] capture tokenResolver reference before potential cache changes --- .../clerk-js/src/core/resources/Session.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ca2e5d82392..1e40f7792e5 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -375,25 +375,21 @@ export class Session extends BaseResource implements SessionResource { const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; if (cacheResult) { - // SWR: If token is expiring soon, trigger background refresh (fire-and-forget) + // Capture reference before potential cache update from background refresh + const tokenResolver = cacheResult.entry.tokenResolver; + if (cacheResult.needsRefresh) { - debugLogger.debug('Token expiring soon, triggering background refresh', { tokenId }, 'session'); + debugLogger.debug('Serving cached token while refreshing in background', { tokenId }, 'session'); void this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate); + } else { + debugLogger.debug('Using cached token', { tokenId }, 'session'); } - debugLogger.debug( - 'Using cached token', - { - needsRefresh: cacheResult.needsRefresh, - tokenId, - }, - 'session', - ); - const cachedToken = await cacheResult.entry.tokenResolver; + const cachedToken = await tokenResolver; if (shouldDispatchTokenUpdate) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); } - // Return null when raw string is empty to indicate that there it's signed-out + // Return null when raw string is empty to indicate signed-out state return cachedToken.getRawString() || null; } From b3f14aad7f471e28e818c9dd7f28edc3fa49818e Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 2 Dec 2025 09:07:01 -0600 Subject: [PATCH 05/25] preserver SWR semantics on concurrent getToken calls --- .../src/core/__tests__/tokenCache.test.ts | 119 +++++++++++- .../clerk-js/src/core/resources/Session.ts | 80 ++++---- .../core/resources/__tests__/Session.test.ts | 176 ++++++++++++++++-- packages/clerk-js/src/core/tokenCache.ts | 26 ++- 4 files changed, 343 insertions(+), 58 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index a70acf1235d..c08315d69ac 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -576,20 +576,20 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - // At 49s elapsed, 11s remaining - fresh, no refresh needed - vi.advanceTimersByTime(49 * 1000); + // At 44s elapsed, 16s remaining - fresh, no refresh needed (> 15s MIN_REMAINING_TTL) + vi.advanceTimersByTime(44 * 1000); let result = SessionTokenCache.get(key); expect(result?.entry.tokenId).toBe('expiring-token'); expect(result?.needsRefresh).toBe(false); - // At 51s elapsed, 9s remaining (< 10s leeway) - SWR: return token with needsRefresh=true + // At 46s elapsed, 14s remaining (< 15s MIN_REMAINING_TTL) - SWR: return token with needsRefresh=true vi.advanceTimersByTime(2 * 1000); result = SessionTokenCache.get(key); expect(result?.entry.tokenId).toBe('expiring-token'); expect(result?.needsRefresh).toBe(true); // At 60s elapsed, 0s remaining - token actually expired, return undefined - vi.advanceTimersByTime(9 * 1000); + vi.advanceTimersByTime(14 * 1000); result = SessionTokenCache.get(key); expect(result).toBeUndefined(); }); @@ -1007,6 +1007,105 @@ describe('SessionTokenCache', () => { }); }); + describe('markRefreshComplete', () => { + it('resets isRefreshing flag on existing cache entry', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'refresh-test-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'refresh-test-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + + // Advance to within leeway to trigger needsRefresh + vi.advanceTimersByTime(51 * 1000); // 9s remaining + + // First get should return needsRefresh=true and set isRefreshing + let result = SessionTokenCache.get(key); + expect(result?.needsRefresh).toBe(true); + + // Second get should return needsRefresh=false (already refreshing) + result = SessionTokenCache.get(key); + expect(result?.needsRefresh).toBe(false); + + // Call markRefreshComplete to reset the flag + SessionTokenCache.markRefreshComplete(key); + + // Now get should return needsRefresh=true again + result = SessionTokenCache.get(key); + expect(result?.needsRefresh).toBe(true); + }); + + it('does nothing if cache entry does not exist', () => { + // Should not throw + expect(() => { + SessionTokenCache.markRefreshComplete({ tokenId: 'non-existent-token' }); + }).not.toThrow(); + }); + }); + + describe('resolvedToken', () => { + it('is populated after tokenResolver resolves', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'resolved-token-test', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'resolved-token-test' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + + // Before promise resolves, resolvedToken should be undefined + let result = SessionTokenCache.get(key); + expect(result?.entry.resolvedToken).toBeUndefined(); + + // Wait for promise to resolve + await tokenResolver; + + // After promise resolves, resolvedToken should be populated + result = SessionTokenCache.get(key); + expect(result?.entry.resolvedToken).toBeDefined(); + expect(result?.entry.resolvedToken?.getRawString()).toBeTruthy(); + }); + + it('can be provided when setting a pre-resolved token', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'pre-resolved-token', + jwt, + object: 'token', + }); + + const key = { tokenId: 'pre-resolved-token' }; + + // Set with both tokenResolver and resolvedToken + SessionTokenCache.set({ + ...key, + resolvedToken: token, + tokenResolver: Promise.resolve(token), + }); + + // resolvedToken should be immediately available + const result = SessionTokenCache.get(key); + expect(result?.entry.resolvedToken).toBeDefined(); + expect(result?.entry.resolvedToken).toBe(token); + }); + }); + describe('multi-session isolation', () => { it('stores tokens from different session IDs separately without interference', async () => { const nowSeconds = Math.floor(Date.now() / 1000); @@ -1058,15 +1157,15 @@ describe('SessionTokenCache', () => { // (not session2's token) - tokens are isolated by tokenId const retrievedSession1Token = SessionTokenCache.get({ tokenId: session1Id }); expect(retrievedSession1Token).toBeDefined(); - const resolvedSession1Token = await retrievedSession1Token!.tokenResolver; + const resolvedSession1Token = await retrievedSession1Token!.entry.tokenResolver; expect(resolvedSession1Token.jwt?.claims?.iat).toBe(nowSeconds); - expect(retrievedSession1Token!.tokenId).toBe(session1Id); + expect(retrievedSession1Token!.entry.tokenId).toBe(session1Id); // Verify session2's token is separate const retrievedSession2Token = SessionTokenCache.get({ tokenId: session2Id }); expect(retrievedSession2Token).toBeDefined(); - expect(retrievedSession2Token!.tokenId).toBe(session2Id); - expect(retrievedSession2Token!.tokenId).not.toBe(session1Id); + expect(retrievedSession2Token!.entry.tokenId).toBe(session2Id); + expect(retrievedSession2Token!.entry.tokenId).not.toBe(session1Id); }); it('accepts broadcast messages from the same session ID', async () => { @@ -1092,7 +1191,7 @@ describe('SessionTokenCache', () => { const cachedToken = SessionTokenCache.get({ tokenId: sessionId }); expect(cachedToken).toBeDefined(); - const resolvedToken = await cachedToken!.tokenResolver; + const resolvedToken = await cachedToken!.entry.tokenResolver; expect(resolvedToken.jwt?.claims?.iat).toBe(nowSeconds - 10); const newerJwt = createJwtWithTtl(nowSeconds, 60); @@ -1112,7 +1211,7 @@ describe('SessionTokenCache', () => { await vi.waitFor(async () => { const updatedCached = SessionTokenCache.get({ tokenId: sessionId }); expect(updatedCached).toBeDefined(); - const updatedToken = await updatedCached!.tokenResolver; + const updatedToken = await updatedCached!.entry.tokenResolver; expect(updatedToken.jwt?.claims?.iat).toBe(nowSeconds); }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 1e40f7792e5..72ba10b494f 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -375,9 +375,6 @@ export class Session extends BaseResource implements SessionResource { const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; if (cacheResult) { - // Capture reference before potential cache update from background refresh - const tokenResolver = cacheResult.entry.tokenResolver; - if (cacheResult.needsRefresh) { debugLogger.debug('Serving cached token while refreshing in background', { tokenId }, 'session'); void this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate); @@ -385,7 +382,8 @@ export class Session extends BaseResource implements SessionResource { debugLogger.debug('Using cached token', { tokenId }, 'session'); } - const cachedToken = await tokenResolver; + // Prefer synchronous read to avoid microtask overhead when token is already resolved + const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); if (shouldDispatchTokenUpdate) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); } @@ -396,44 +394,45 @@ export class Session extends BaseResource implements SessionResource { return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate); } + #createTokenResolver( + template: string | undefined, + organizationId: string | undefined | null, + ): Promise { + const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; + // TODO: update template endpoint to accept organizationId + const params: Record = template ? {} : { organizationId: organizationId ?? null }; + return Token.create(path, params, false); + } + + #dispatchTokenEvents(token: TokenResource, shouldDispatch: boolean): void { + if (!shouldDispatch) { + return; + } + + eventBus.emit(events.TokenUpdate, { token }); + + if (token.jwt) { + this.lastActiveToken = token; + eventBus.emit(events.SessionTokenResolved, null); + } + } + #fetchToken( template: string | undefined, organizationId: string | undefined | null, tokenId: string, shouldDispatchTokenUpdate: boolean, ): Promise { - debugLogger.info( - 'Fetching new token from API', - { - organizationId, - template, - tokenId, - }, - 'session', - ); + debugLogger.info('Fetching new token from API', { organizationId, template, tokenId }, 'session'); - const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; - - // TODO: update template endpoint to accept organizationId - const params: Record = template ? {} : { organizationId }; - - const tokenResolver = Token.create(path, params, false); + const tokenResolver = this.#createTokenResolver(template, organizationId); // Cache the promise immediately to prevent concurrent calls from triggering duplicate requests SessionTokenCache.set({ tokenId, tokenResolver }); return tokenResolver.then(token => { - if (shouldDispatchTokenUpdate) { - eventBus.emit(events.TokenUpdate, { token }); - - if (token.jwt) { - this.lastActiveToken = token; - // Emits the updated session with the new token to the state listeners - eventBus.emit(events.SessionTokenResolved, null); - } - } - - // Return null when raw string is empty to indicate that there it's signed-out + this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); + // Return null when raw string is empty to indicate signed-out state return token.getRawString() || null; }); } @@ -444,10 +443,23 @@ export class Session extends BaseResource implements SessionResource { tokenId: string, shouldDispatchTokenUpdate: boolean, ): void { - // Fire-and-forget background refresh - errors are silently ignored - this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate).catch(() => { - debugLogger.warn('Background token refresh failed', { tokenId }, 'session'); - }); + // Background refresh must NOT update cache until success to preserve SWR behavior. + // If we updated cache immediately, concurrent getToken calls would await the pending + // promise instead of returning the stale token. + debugLogger.info('Starting background token refresh', { organizationId, template, tokenId }, 'session'); + + this.#createTokenResolver(template, organizationId) + .then(token => { + // Cache only after success with resolvedToken for immediate synchronous reads + SessionTokenCache.set({ resolvedToken: token, tokenId, tokenResolver: Promise.resolve(token) }); + this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); + debugLogger.debug('Background token refresh completed', { tokenId }, 'session'); + }) + .catch(() => { + // Reset isRefreshing flag so future calls can retry. Old valid token remains in cache. + SessionTokenCache.markRefreshComplete({ tokenId }); + debugLogger.warn('Background token refresh failed', { tokenId }, 'session'); + }); } get currentTask() { diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 4de20208046..84bd67bfabd 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -100,7 +100,7 @@ describe('Session', () => { expect(dispatchSpy).toHaveBeenCalledTimes(2); }); - it('does not re-cache token when Session is reconstructed with same token', async () => { + it('returns same token without API call when Session is reconstructed', async () => { BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), }); @@ -119,10 +119,6 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - expect(SessionTokenCache.size()).toBe(1); - const cachedEntry1 = SessionTokenCache.get({ tokenId: 'session_1-activeOrganization' }); - expect(cachedEntry1).toBeDefined(); - const session2 = new Session({ status: 'active', id: 'session_1', @@ -135,8 +131,6 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - expect(SessionTokenCache.size()).toBe(1); - const token1 = await session1.getToken(); const token2 = await session2.getToken(); @@ -145,12 +139,12 @@ describe('Session', () => { expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); }); - it('caches token from cookie during degraded mode recovery', async () => { + it('returns lastActiveToken without API call (degraded mode recovery)', async () => { BaseResource.clerk = clerkMock(); SessionTokenCache.clear(); - const sessionFromCookie = new Session({ + const session = new Session({ status: 'active', id: 'session_1', object: 'session', @@ -162,11 +156,8 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - expect(SessionTokenCache.size()).toBe(1); - const cachedEntry = SessionTokenCache.get({ tokenId: 'session_1' }); - expect(cachedEntry).toBeDefined(); + const token = await session.getToken(); - const token = await sessionFromCookie.getToken(); expect(token).toEqual(mockJwt); expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); }); @@ -426,6 +417,165 @@ describe('Session', () => { expect(requestSpy).toHaveBeenCalledTimes(2); }); + + describe('stale-while-revalidate (SWR) behavior', () => { + it('returns stale token immediately while refreshing in background', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // Advance time so token needs refresh (< 15s remaining of 60s TTL) + vi.advanceTimersByTime(46 * 1000); + + // Hold the network request pending + let resolveNetworkRequest!: (value: any) => void; + requestSpy.mockClear(); + requestSpy.mockReturnValueOnce( + new Promise(resolve => { + resolveNetworkRequest = resolve; + }), + ); + + // Concurrent calls should all return immediately with stale token + const [token1, token2, token3] = await Promise.all([ + session.getToken(), + session.getToken(), + session.getToken(), + ]); + + expect(token1).toEqual(mockJwt); + expect(token2).toEqual(mockJwt); + expect(token3).toEqual(mockJwt); + expect(requestSpy).toHaveBeenCalledTimes(1); + + // Cleanup: resolve the pending request + resolveNetworkRequest({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + await vi.advanceTimersByTimeAsync(0); + }); + + it('continues returning tokens after background refresh failure', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + vi.advanceTimersByTime(46 * 1000); + + // Background refresh fails + requestSpy.mockClear(); + requestSpy.mockRejectedValueOnce(new Error('Network error')); + + const token = await session.getToken(); + expect(token).toEqual(mockJwt); + + // Wait for background failure to complete + await vi.advanceTimersByTimeAsync(100); + + // Subsequent call should still return token (stale is preserved) + const token2 = await session.getToken(); + expect(token2).toEqual(mockJwt); + }); + + it('retries background refresh after previous failure', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + vi.advanceTimersByTime(46 * 1000); + + // First call triggers background refresh that fails + requestSpy.mockClear(); + requestSpy.mockRejectedValueOnce(new Error('Network error')); + + await session.getToken(); + expect(requestSpy).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + + // Second call should trigger another refresh attempt + requestSpy.mockClear(); + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + + await session.getToken(); + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('uses refreshed token for subsequent calls after background refresh succeeds', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const newMockJwt = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg0MDAsImlhdCI6MTY2NjY0ODM0MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5leGFtcGxlLmNvbSIsImp0aSI6Im5ld3Rva2VuIiwibmJmIjoxNjY2NjQ4MzQwLCJzaWQiOiJzZXNzXzFxcTlveTVHaU5IeGRSMlhXVTZnRzZtSWNCWCIsInN1YiI6InVzZXJfMXFxOW95NUdpTkh4ZFIyWFdVNmdHNm1JY0JYIn0.mock'; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + vi.advanceTimersByTime(46 * 1000); + + // Background refresh returns new token + requestSpy.mockClear(); + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: newMockJwt }, status: 200 }); + + // First call returns stale token + const staleToken = await session.getToken(); + expect(staleToken).toEqual(mockJwt); + + // Wait for background refresh to complete + await vi.advanceTimersByTimeAsync(100); + + // Subsequent call returns refreshed token (no new API call needed) + requestSpy.mockClear(); + const freshToken = await session.getToken(); + expect(freshToken).toEqual(newMockJwt); + expect(requestSpy).not.toHaveBeenCalled(); + }); + }); }); describe('touch()', () => { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index bd58a4e1a70..2e4735108cb 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -24,6 +24,11 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { * Used for expiration and cleanup scheduling. */ createdAt?: Seconds; + /** + * The resolved token value for synchronous reads. + * Populated after tokenResolver resolves. Check this first to avoid microtask overhead. + */ + resolvedToken?: TokenResource; /** * Promise that resolves to the TokenResource. * May be pending and should be awaited before accessing token data. @@ -78,6 +83,14 @@ export interface TokenCache { */ get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheGetResult | undefined; + /** + * Resets the isRefreshing flag on an existing cache entry without replacing it. + * Used when a background refresh fails to allow future refresh attempts. + * + * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry + */ + markRefreshComplete(cacheKeyJSON: TokenCacheKeyJSON): void; + /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. * @@ -358,6 +371,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { entry.tokenResolver .then(newToken => { + // Store resolved token for synchronous reads + entry.resolvedToken = newToken; + const claims = newToken.jwt?.claims; if (!claims || typeof claims.exp !== 'number' || typeof claims.iat !== 'number') { return deleteKey(); @@ -431,11 +447,19 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } }; + const markRefreshComplete = (cacheKeyJSON: TokenCacheKeyJSON): void => { + const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); + const value = cache.get(cacheKey.toKey()); + if (value) { + value.isRefreshing = false; + } + }; + const size = () => { return cache.size; }; - return { clear, close, get, set, size }; + return { clear, close, get, markRefreshComplete, set, size }; }; export const SessionTokenCache = MemoryTokenCache(); From d6dd49c43748aaa6ef1230a5fc24f21a3778e736 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 2 Dec 2025 10:15:08 -0600 Subject: [PATCH 06/25] Use poller exlusively for refreshing tokens --- .../src/core/__tests__/tokenCache.test.ts | 50 ++----------------- .../src/core/auth/AuthCookieService.ts | 3 +- .../clerk-js/src/core/resources/Session.ts | 38 +++----------- packages/clerk-js/src/core/tokenCache.ts | 31 ++---------- packages/shared/src/types/session.ts | 10 +++- 5 files changed, 24 insertions(+), 108 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index c08315d69ac..eec3d16ce04 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -594,7 +594,7 @@ describe('SessionTokenCache', () => { expect(result).toBeUndefined(); }); - it('returns needsRefresh=true only once per token (prevents duplicate refreshes)', async () => { + it('returns needsRefresh=true consistently while token is within leeway', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); @@ -617,10 +617,10 @@ describe('SessionTokenCache', () => { let result = SessionTokenCache.get(key); expect(result?.needsRefresh).toBe(true); - // Second call: needsRefresh=false (already marked for refresh) + // Second call: needsRefresh=true (consistently signals refresh needed) result = SessionTokenCache.get(key); expect(result?.entry.tokenId).toBe('dedupe-token'); - expect(result?.needsRefresh).toBe(false); + expect(result?.needsRefresh).toBe(true); }); it('honors larger custom leeway values', async () => { @@ -1007,50 +1007,6 @@ describe('SessionTokenCache', () => { }); }); - describe('markRefreshComplete', () => { - it('resets isRefreshing flag on existing cache entry', async () => { - const nowSeconds = Math.floor(Date.now() / 1000); - const jwt = createJwtWithTtl(nowSeconds, 60); - - const token = new Token({ - id: 'refresh-test-token', - jwt, - object: 'token', - }); - - const tokenResolver = Promise.resolve(token); - const key = { tokenId: 'refresh-test-token' }; - - SessionTokenCache.set({ ...key, tokenResolver }); - await tokenResolver; - - // Advance to within leeway to trigger needsRefresh - vi.advanceTimersByTime(51 * 1000); // 9s remaining - - // First get should return needsRefresh=true and set isRefreshing - let result = SessionTokenCache.get(key); - expect(result?.needsRefresh).toBe(true); - - // Second get should return needsRefresh=false (already refreshing) - result = SessionTokenCache.get(key); - expect(result?.needsRefresh).toBe(false); - - // Call markRefreshComplete to reset the flag - SessionTokenCache.markRefreshComplete(key); - - // Now get should return needsRefresh=true again - result = SessionTokenCache.get(key); - expect(result?.needsRefresh).toBe(true); - }); - - it('does nothing if cache entry does not exist', () => { - // Should not throw - expect(() => { - SessionTokenCache.markRefreshComplete({ tokenId: 'non-existent-token' }); - }).not.toThrow(); - }); - }); - describe('resolvedToken', () => { it('is populated after tokenResolver resolves', async () => { const nowSeconds = Math.floor(Date.now() / 1000); diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 51268dc6bcd..8c747871408 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -162,7 +162,8 @@ export class AuthCookieService { } 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 }); if (updateCookieImmediately) { this.updateSessionCookie(token); } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 72ba10b494f..0ca2576f480 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -356,7 +356,7 @@ export class Session extends BaseResource implements SessionResource { return null; } - const { leewayInSeconds, template, skipCache = false } = options || {}; + const { leewayInSeconds, refreshIfStale = false, skipCache = false, template } = options || {}; // If no organization ID is provided, default to the selected organization in memory // Note: this explicitly allows passing `null` or `""`, which should select the personal workspace. @@ -375,13 +375,14 @@ export class Session extends BaseResource implements SessionResource { const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; if (cacheResult) { - if (cacheResult.needsRefresh) { - debugLogger.debug('Serving cached token while refreshing in background', { tokenId }, 'session'); - void this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate); - } else { - debugLogger.debug('Using cached token', { tokenId }, 'session'); + // If caller requests refresh when stale (e.g., poller), fetch fresh token instead of returning cached + if (cacheResult.needsRefresh && refreshIfStale) { + debugLogger.debug('Token is stale, refreshing as requested', { tokenId }, 'session'); + return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate); } + debugLogger.debug('Using cached token', { tokenId }, 'session'); + // Prefer synchronous read to avoid microtask overhead when token is already resolved const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); if (shouldDispatchTokenUpdate) { @@ -437,31 +438,6 @@ export class Session extends BaseResource implements SessionResource { }); } - #refreshTokenInBackground( - template: string | undefined, - organizationId: string | undefined | null, - tokenId: string, - shouldDispatchTokenUpdate: boolean, - ): void { - // Background refresh must NOT update cache until success to preserve SWR behavior. - // If we updated cache immediately, concurrent getToken calls would await the pending - // promise instead of returning the stale token. - debugLogger.info('Starting background token refresh', { organizationId, template, tokenId }, 'session'); - - this.#createTokenResolver(template, organizationId) - .then(token => { - // Cache only after success with resolvedToken for immediate synchronous reads - SessionTokenCache.set({ resolvedToken: token, tokenId, tokenResolver: Promise.resolve(token) }); - this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); - debugLogger.debug('Background token refresh completed', { tokenId }, 'session'); - }) - .catch(() => { - // Reset isRefreshing flag so future calls can retry. Old valid token remains in cache. - SessionTokenCache.markRefreshComplete({ tokenId }); - debugLogger.warn('Background token refresh failed', { tokenId }, 'session'); - }); - } - get currentTask() { const [task] = this.tasks ?? []; return task; diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 2e4735108cb..06e54e243b2 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -45,8 +45,6 @@ interface TokenCacheValue { createdAt: Seconds; entry: TokenCacheEntry; expiresIn?: Seconds; - /** Indicates a background refresh is in progress to prevent duplicate requests */ - isRefreshing?: boolean; timeoutId?: ReturnType; } @@ -83,14 +81,6 @@ export interface TokenCache { */ get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheGetResult | undefined; - /** - * Resets the isRefreshing flag on an existing cache entry without replacing it. - * Used when a background refresh fails to allow future refresh attempts. - * - * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry - */ - markRefreshComplete(cacheKeyJSON: TokenCacheKeyJSON): void; - /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. * @@ -232,15 +222,10 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const effectiveLeeway = Math.max(leeway, MIN_REMAINING_TTL_IN_SECONDS); - // Token is valid but expiring soon - signal for background refresh - // Only signal once per token to prevent duplicate refresh requests - const needsRefresh = remainingTtl < effectiveLeeway && !value.isRefreshing; - - if (needsRefresh) { - value.isRefreshing = true; - } + // Token is valid but expiring soon - signal that refresh is needed + const needsRefresh = remainingTtl < effectiveLeeway; - // SWR: Return the valid token immediately, caller handles background refresh if needed + // Return the valid token immediately, caller decides whether to refresh return { entry: value.entry, needsRefresh }; }; @@ -447,19 +432,11 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } }; - const markRefreshComplete = (cacheKeyJSON: TokenCacheKeyJSON): void => { - const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); - const value = cache.get(cacheKey.toKey()); - if (value) { - value.isRefreshing = false; - } - }; - const size = () => { return cache.size; }; - return { clear, close, get, markRefreshComplete, set, size }; + return { clear, close, get, set, size }; }; export const SessionTokenCache = MemoryTokenCache(); diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 70a512dbddf..2e10810d9f6 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -339,10 +339,16 @@ export interface SessionTask { } export type GetTokenOptions = { - template?: string; - organizationId?: string; leewayInSeconds?: number; + organizationId?: string; + /** + * @internal + * When true, forces a fresh token fetch if the cached token is within the refresh leeway period. + * Used by the token poller to proactively refresh tokens before they expire. + */ + refreshIfStale?: boolean; skipCache?: boolean; + template?: string; }; /** * @inline From c86f32eac346242c5cc71c2da8e2da1512f65828 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 9 Jan 2026 08:00:55 -0600 Subject: [PATCH 07/25] chore: add changeset for stale-while-revalidate token --- .changeset/fresh-tigers-hunt.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fresh-tigers-hunt.md diff --git a/.changeset/fresh-tigers-hunt.md b/.changeset/fresh-tigers-hunt.md new file mode 100644 index 00000000000..0e799b98cc4 --- /dev/null +++ b/.changeset/fresh-tigers-hunt.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Add stale-while-revalidate support for session tokens From bce3caedfc0e9677e727a1fcfdefb8ae33c80e5a Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 12 Jan 2026 12:59:49 -0600 Subject: [PATCH 08/25] feat(clerk-js): Add automatic background token refresh on SWR - Trigger background refresh when needsRefresh is true, without requiring refreshIfStale option. Guarantees cache revalidation without relying solely on the poller. - Add #refreshTokenInBackground() that doesn't cache pending promise, allowing concurrent getToken() calls to return stale token while refresh is in progress. - Track in-flight background refreshes to prevent duplicate requests. - Rename DEFAULT_LEEWAY to BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS with clear documentation about 15s minimum and rate limiting warning. - Add comprehensive JSDoc for leewayInSeconds option explaining minimum value, default, and rate limiting considerations. --- .../clerk-js/src/core/resources/Session.ts | 55 ++++++++++++++++++- packages/clerk-js/src/core/tokenCache.ts | 29 ++++++---- packages/shared/src/types/session.ts | 17 +++++- 3 files changed, 88 insertions(+), 13 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b37aebbc575..eada9c72172 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -44,6 +44,12 @@ import { SessionVerification } from './SessionVerification'; export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; + /** + * Tracks token IDs with in-flight background refresh requests. + * Prevents multiple concurrent background refreshes for the same token. + */ + static #backgroundRefreshInProgress = new Set(); + id!: string; status!: SessionStatus; lastActiveAt!: Date; @@ -375,7 +381,13 @@ export class Session extends BaseResource implements SessionResource { return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); } - debugLogger.debug('Using cached token', { tokenId }, 'session'); + // Trigger background refresh if token is expiring soon + // This guarantees cache revalidation without relying solely on the poller + if (cacheResult.needsRefresh) { + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate); + } + + debugLogger.debug('Using cached token', { tokenId, needsRefresh: cacheResult.needsRefresh }, 'session'); // Prefer synchronous read to avoid microtask overhead when token is already resolved const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); @@ -439,6 +451,47 @@ export class Session extends BaseResource implements SessionResource { }); } + /** + * Triggers a background token refresh without caching the pending promise. + * This allows concurrent getToken() calls to continue returning the stale cached token + * while the refresh is in progress. The cache is only updated after the refresh succeeds. + * + * Uses a static Set to prevent multiple concurrent background refreshes for the same token. + */ + #refreshTokenInBackground( + template: string | undefined, + organizationId: string | undefined | null, + tokenId: string, + shouldDispatchTokenUpdate: boolean, + ): void { + // Prevent multiple concurrent background refreshes for the same token + if (Session.#backgroundRefreshInProgress.has(tokenId)) { + debugLogger.debug('Background refresh already in progress', { tokenId }, 'session'); + return; + } + + Session.#backgroundRefreshInProgress.add(tokenId); + debugLogger.info('Refreshing token in background', { organizationId, template, tokenId }, 'session'); + + const tokenResolver = this.#createTokenResolver(template, organizationId, false); + + // Don't cache the promise immediately - only update cache on success + // This allows concurrent calls to continue using the stale token + tokenResolver + .then(token => { + // Cache the resolved token for future calls + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token) }); + this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); + }) + .catch(error => { + // Log but don't propagate - callers already have stale token + debugLogger.warn('Background token refresh failed', { error, tokenId }, 'session'); + }) + .finally(() => { + Session.#backgroundRefreshInProgress.delete(tokenId); + }); + } + get currentTask() { const [task] = this.tasks ?? []; return task; diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 418167aea46..270313f859f 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -76,10 +76,11 @@ export interface TokenCache { * Forces synchronous refresh if token has less than one poller interval remaining. * * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry - * @param leeway - Seconds before expiration to trigger background refresh (default: 10s). Minimum is 15s. + * @param refreshThreshold - Seconds before expiration to trigger background refresh (default: 15s, minimum: 15s). + * Higher values trigger earlier background refresh but may cause excessive requests and trip rate limiting. * @returns Result with entry and refresh flag, or undefined if token is missing/expired/too close to expiration */ - get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheGetResult | undefined; + get(cacheKeyJSON: TokenCacheKeyJSON, refreshThreshold?: number): TokenCacheGetResult | undefined; /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. @@ -99,13 +100,16 @@ export interface TokenCache { const KEY_PREFIX = 'clerk'; const DELIMITER = '::'; -const DEFAULT_LEEWAY = 10; /** - * Conservative threshold accounting for timer jitter, SafeLock contention (~5s), - * network latency, and tolerance for missed poller ticks. + * Minimum seconds before token expiration to trigger background refresh via the poller. + * This threshold accounts for timer jitter, SafeLock contention (~5s), network latency, + * and tolerance for missed poller ticks. + * + * Users can increase this value to trigger background refresh earlier, but setting it + * too high may cause excessive token refresh requests and trip rate limiting rules. */ -const MIN_REMAINING_TTL_IN_SECONDS = 15; +const BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS = 15; const BROADCAST = { broadcast: true }; const NO_BROADCAST = { broadcast: false }; @@ -195,7 +199,10 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { cache.clear(); }; - const get = (cacheKeyJSON: TokenCacheKeyJSON, leeway = DEFAULT_LEEWAY): TokenCacheGetResult | undefined => { + const get = ( + cacheKeyJSON: TokenCacheKeyJSON, + refreshThreshold = BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, + ): TokenCacheGetResult | undefined => { ensureBroadcastChannel(); const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); @@ -210,6 +217,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const remainingTtl = (value.expiresIn ?? Infinity) - elapsed; // Token expired or dangerously close to expiration - force synchronous refresh + // Uses poller interval as threshold since the poller might not get to it in time if (remainingTtl <= POLLER_INTERVAL_IN_MS / 1000) { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); @@ -218,10 +226,11 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { return; } - const effectiveLeeway = Math.max(leeway, MIN_REMAINING_TTL_IN_SECONDS); + // Ensure threshold is at least the minimum to account for timer jitter, network latency, etc. + const effectiveThreshold = Math.max(refreshThreshold, BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS); - // Token is valid but expiring soon - signal that refresh is needed - const needsRefresh = remainingTtl < effectiveLeeway; + // Token is valid but expiring soon - signal that background refresh is needed + const needsRefresh = remainingTtl < effectiveThreshold; // Return the valid token immediately, caller decides whether to refresh return { entry: value.entry, needsRefresh }; diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 13cb2a39449..baf45bb3db7 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -339,12 +339,25 @@ export interface SessionTask { } export type GetTokenOptions = { + /** + * Seconds before token expiration to trigger background refresh via the session poller. + * When a token's remaining TTL falls below this threshold, the cache signals that a + * background refresh should occur while still returning the current valid token. + * + * - Minimum value: 15 seconds (values below this are ignored) + * - Default value: 15 seconds + * + * Higher values trigger earlier background refresh, which can reduce latency for + * time-sensitive operations. However, setting this too high may cause excessive + * token refresh requests and potentially trip rate limiting rules. + */ leewayInSeconds?: number; organizationId?: string; /** * @internal - * When true, forces a fresh token fetch if the cached token is within the refresh leeway period. - * Used by the token poller to proactively refresh tokens before they expire. + * When true, triggers a background token refresh if the cached token is within the + * refresh threshold period, while still returning the current valid token immediately. + * Used by the session poller to proactively refresh tokens before they expire. */ refreshIfStale?: boolean; skipCache?: boolean; From 1da9c8faaa6a4f3e3bec1a942d3af40db40afec5 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 12 Jan 2026 13:08:11 -0600 Subject: [PATCH 09/25] docs(upgrade): Add Core 3 documentation for token SWR behavior Add upgrade guide entries for: - getToken `leewayInSeconds` minimum of 15 seconds - stale-while-revalidate pattern for session tokens --- .../core-3/changes/gettoken-leeway-minimum.md | 38 +++++++++++++++ .../gettoken-stale-while-revalidate.md | 48 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md create mode 100644 packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md diff --git a/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md b/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md new file mode 100644 index 00000000000..98103fa3ba6 --- /dev/null +++ b/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md @@ -0,0 +1,38 @@ +--- +title: '`getToken` `leewayInSeconds` has 15s minimum' +matcher: + - 'leewayInSeconds' + - 'getToken' +category: 'behavior-change' +warning: true +--- + +The `leewayInSeconds` option in `session.getToken()` now has a minimum enforced value of 15 seconds. Values below this threshold are ignored and the minimum is used instead. + +This threshold ensures reliable token refresh by accounting for timer jitter, network latency, and background task contention. + +### Why this change? + +Setting `leewayInSeconds` too low could result in tokens expiring before background refresh completes, causing authentication failures. The 15-second minimum provides a safety buffer. + +### Impact + +If you were using `leewayInSeconds` with a value less than 15 seconds: + +```js +// Before: 5s leeway (now enforced as 15s minimum) +const token = await session.getToken({ leewayInSeconds: 5 }); + +// After: Behaves as if leewayInSeconds: 15 was passed +``` + +### Rate Limiting Warning + +Setting `leewayInSeconds` higher than the default (15s) triggers earlier background token refresh. While this can reduce latency for time-sensitive operations, values that are too high may cause excessive token refresh requests and potentially trigger rate limiting. + +```js +// Use with caution - triggers refresh 60s before expiration +const token = await session.getToken({ leewayInSeconds: 60 }); +``` + +No code changes are required unless you want to adjust the refresh timing. diff --git a/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md b/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md new file mode 100644 index 00000000000..6b0661c5454 --- /dev/null +++ b/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md @@ -0,0 +1,48 @@ +--- +title: '`getToken` now uses stale-while-revalidate pattern' +matcher: + - 'getToken' + - 'session\.getToken' +category: 'behavior-change' +warning: true +--- + +`session.getToken()` now implements a stale-while-revalidate pattern that improves performance by returning cached tokens immediately while refreshing them in the background when they're close to expiration. + +### How it works + +1. When a token is within 15 seconds of expiration (configurable via `leewayInSeconds`), `getToken()` returns the valid cached token immediately +2. A background refresh is triggered automatically to fetch a fresh token +3. Subsequent calls receive the new token once the background refresh completes + +### Benefits + +- **Reduced latency**: No more waiting for token refresh on every call near expiration +- **Better user experience**: API calls proceed immediately with valid (though expiring) tokens +- **Automatic refresh**: Fresh tokens are ready before the old ones expire + +### Cross-tab synchronization + +Token updates are automatically synchronized across browser tabs using `BroadcastChannel`. When one tab refreshes a token, other tabs receive the update automatically. + +### Example + +```js +// Token is cached and valid but expiring in 10 seconds +// Core 2 behavior: Would block and fetch new token +// Core 3 behavior: Returns cached token immediately, refreshes in background +const token = await session.getToken(); +``` + +### Compatibility + +This is a transparent improvement - no code changes are required. Your existing `getToken()` calls benefit automatically. + +### Customizing refresh timing + +Use `leewayInSeconds` to trigger background refresh earlier (minimum 15 seconds): + +```js +// Start background refresh 30 seconds before expiration +const token = await session.getToken({ leewayInSeconds: 30 }); +``` From 11551e1dfd0dd2bf5dca940430df959ffb945231 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 12 Jan 2026 15:05:44 -0600 Subject: [PATCH 10/25] test(clerk-js): Add tests for refreshIfStale and leewayInSeconds options - Test refreshIfStale: true forces synchronous refresh when token is stale - Test refreshIfStale: true with fresh token returns cached value - Test leewayInSeconds triggers earlier background refresh - Test minimum 15s leeway enforcement - Test no background refresh when token has sufficient TTL --- .../core/resources/__tests__/Session.test.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 21d824e64e9..8f4575ce6e0 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -576,6 +576,165 @@ describe('Session', () => { expect(freshToken).toEqual(newMockJwt); expect(requestSpy).not.toHaveBeenCalled(); }); + + it('forces synchronous refresh when refreshIfStale is true and token is stale', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const newMockJwt = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg0MDAsImlhdCI6MTY2NjY0ODM0MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5leGFtcGxlLmNvbSIsImp0aSI6Im5ld3Rva2VuIiwibmJmIjoxNjY2NjQ4MzQwLCJzaWQiOiJzZXNzXzFxcTlveTVHaU5IeGRSMlhXVTZnRzZtSWNCWCIsInN1YiI6InVzZXJfMXFxOW95NUdpTkh4ZFIyWFdVNmdHNm1JY0JYIn0.mock'; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // Advance time so token needs refresh (< 15s remaining of 60s TTL) + vi.advanceTimersByTime(46 * 1000); + + // With refreshIfStale: true, should fetch fresh token synchronously + requestSpy.mockClear(); + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: newMockJwt }, status: 200 }); + + const token = await session.getToken({ refreshIfStale: true }); + + // Should return the NEW token (synchronous refresh), not the stale one + expect(token).toEqual(newMockJwt); + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('returns cached token without refresh when refreshIfStale is true but token is fresh', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // Advance only 10s - token still has 50s remaining, not stale yet + vi.advanceTimersByTime(10 * 1000); + + requestSpy.mockClear(); + const token = await session.getToken({ refreshIfStale: true }); + + // Should return cached token, no API call + expect(token).toEqual(mockJwt); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('respects leewayInSeconds for earlier background refresh (minimum 15s)', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // With 30s leeway and 25s remaining (< 30), token should trigger background refresh + vi.advanceTimersByTime(35 * 1000); // 25s remaining + + requestSpy.mockClear(); + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + + const token = await session.getToken({ leewayInSeconds: 30 }); + + // Should return stale token immediately (SWR behavior) + expect(token).toEqual(mockJwt); + // Should trigger background refresh because 25s < 30s leeway + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('enforces minimum 15s leeway (ignores lower values)', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // With 10s remaining, regardless of leewayInSeconds: 5 being passed, + // the minimum 15s threshold should apply and trigger background refresh + vi.advanceTimersByTime(50 * 1000); // 10s remaining (within 15s minimum threshold) + + requestSpy.mockClear(); + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + + const token = await session.getToken({ leewayInSeconds: 5 }); + + // Should return cached token immediately (SWR behavior) + expect(token).toEqual(mockJwt); + + // Background refresh should be triggered because 10s < 15s minimum + // (leewayInSeconds: 5 is ignored, minimum 15s is enforced) + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('does not trigger background refresh when token has more than leeway remaining', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // With 40s remaining and default 15s leeway, token is fresh + vi.advanceTimersByTime(20 * 1000); // 40s remaining + + requestSpy.mockClear(); + const token = await session.getToken(); + + expect(token).toEqual(mockJwt); + expect(requestSpy).not.toHaveBeenCalled(); + }); }); }); From be6336218c33e704f2182d74369d0131959acf7f Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 12 Jan 2026 15:11:47 -0600 Subject: [PATCH 11/25] chore(clerk-js): Increase bundle size limit for SWR background refresh --- packages/clerk-js/bundlewatch.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index cbc3539426c..250c4b24f71 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,8 +1,8 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "538KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "63KB" }, - { "path": "./dist/clerk.chips.browser.js", "maxSize": "63KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "63.5KB" }, + { "path": "./dist/clerk.chips.browser.js", "maxSize": "63.5KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "105KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "305KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "65KB" }, From d2cac65cc6b18615bf1984df3f18bc4b83a34ae2 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 12 Jan 2026 21:48:09 -0600 Subject: [PATCH 12/25] feat(clerk-js): Rename leewayInSeconds to backgroundRefreshThreshold - Rename option from leewayInSeconds to backgroundRefreshThreshold for clarity - Lower minimum threshold from 15s to 5s (poller interval) - Remove explicit refreshIfStale: false in AuthCookieService (default behavior) - Update tests and upgrade documentation --- .../src/core/auth/AuthCookieService.ts | 3 +- .../clerk-js/src/core/resources/Session.ts | 8 ++-- .../core/resources/__tests__/Session.test.ts | 25 +++++----- packages/clerk-js/src/core/tokenCache.ts | 12 +++-- packages/shared/src/types/session.ts | 14 +++--- .../core-3/changes/gettoken-leeway-minimum.md | 48 ++++++++++++------- .../gettoken-stale-while-revalidate.md | 6 +-- 7 files changed, 66 insertions(+), 50 deletions(-) diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 8c747871408..51268dc6bcd 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -162,8 +162,7 @@ export class AuthCookieService { } try { - // Use refreshIfStale to fetch fresh token when cached token is within leeway period - const token = await this.clerk.session.getToken({ refreshIfStale: true }); + const token = await this.clerk.session.getToken(); if (updateCookieImmediately) { this.updateSessionCookie(token); } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index eada9c72172..95b042c28d0 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -356,20 +356,20 @@ export class Session extends BaseResource implements SessionResource { return null; } - const { leewayInSeconds, refreshIfStale = false, skipCache = false, template } = options || {}; + const { backgroundRefreshThreshold, refreshIfStale = false, skipCache = false, template } = options || {}; // If no organization ID is provided, default to the selected organization in memory // Note: this explicitly allows passing `null` or `""`, which should select the personal workspace. const organizationId = typeof options?.organizationId === 'undefined' ? this.lastActiveOrganizationId : options?.organizationId; - if (!template && Number(leewayInSeconds) >= 60) { - throw new Error('Leeway can not exceed the token lifespan (60 seconds)'); + if (!template && Number(backgroundRefreshThreshold) >= 60) { + throw new Error('backgroundRefreshThreshold cannot exceed the token lifespan (60 seconds)'); } const tokenId = this.#getCacheId(template, organizationId); - const cacheResult = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); + const cacheResult = skipCache ? undefined : SessionTokenCache.get({ tokenId }, backgroundRefreshThreshold); // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 8f4575ce6e0..1a52b03b997 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -641,7 +641,7 @@ describe('Session', () => { expect(requestSpy).not.toHaveBeenCalled(); }); - it('respects leewayInSeconds for earlier background refresh (minimum 15s)', async () => { + it('respects backgroundRefreshThreshold for earlier background refresh', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; @@ -659,21 +659,21 @@ describe('Session', () => { await Promise.resolve(); - // With 30s leeway and 25s remaining (< 30), token should trigger background refresh + // With 30s threshold and 25s remaining (< 30), token should trigger background refresh vi.advanceTimersByTime(35 * 1000); // 25s remaining requestSpy.mockClear(); requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); - const token = await session.getToken({ leewayInSeconds: 30 }); + const token = await session.getToken({ backgroundRefreshThreshold: 30 }); // Should return stale token immediately (SWR behavior) expect(token).toEqual(mockJwt); - // Should trigger background refresh because 25s < 30s leeway + // Should trigger background refresh because 25s < 30s threshold expect(requestSpy).toHaveBeenCalledTimes(1); }); - it('enforces minimum 15s leeway (ignores lower values)', async () => { + it('allows backgroundRefreshThreshold below 15s (minimum is 5s poller interval)', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; @@ -691,24 +691,23 @@ describe('Session', () => { await Promise.resolve(); - // With 10s remaining, regardless of leewayInSeconds: 5 being passed, - // the minimum 15s threshold should apply and trigger background refresh - vi.advanceTimersByTime(50 * 1000); // 10s remaining (within 15s minimum threshold) + // With 8s remaining and backgroundRefreshThreshold: 10, should trigger refresh + vi.advanceTimersByTime(52 * 1000); // 8s remaining requestSpy.mockClear(); requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); - const token = await session.getToken({ leewayInSeconds: 5 }); + const token = await session.getToken({ backgroundRefreshThreshold: 10 }); // Should return cached token immediately (SWR behavior) expect(token).toEqual(mockJwt); - // Background refresh should be triggered because 10s < 15s minimum - // (leewayInSeconds: 5 is ignored, minimum 15s is enforced) + // Background refresh should be triggered because 8s < 10s threshold + // (threshold of 10s is respected, not floored to 15s) expect(requestSpy).toHaveBeenCalledTimes(1); }); - it('does not trigger background refresh when token has more than leeway remaining', async () => { + it('does not trigger background refresh when token has more than threshold remaining', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; @@ -726,7 +725,7 @@ describe('Session', () => { await Promise.resolve(); - // With 40s remaining and default 15s leeway, token is fresh + // With 40s remaining and default 15s threshold, token is fresh vi.advanceTimersByTime(20 * 1000); // 40s remaining requestSpy.mockClear(); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 270313f859f..7220938af66 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -102,12 +102,13 @@ const KEY_PREFIX = 'clerk'; const DELIMITER = '::'; /** - * Minimum seconds before token expiration to trigger background refresh via the poller. + * Default seconds before token expiration to trigger background refresh. * This threshold accounts for timer jitter, SafeLock contention (~5s), network latency, * and tolerance for missed poller ticks. * - * Users can increase this value to trigger background refresh earlier, but setting it - * too high may cause excessive token refresh requests and trip rate limiting rules. + * Users can customize this value: + * - Lower values (min: 5s) delay background refresh until closer to expiration + * - Higher values trigger earlier background refresh but may cause more frequent requests */ const BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS = 15; @@ -226,8 +227,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { return; } - // Ensure threshold is at least the minimum to account for timer jitter, network latency, etc. - const effectiveThreshold = Math.max(refreshThreshold, BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS); + // Ensure threshold is at least the poller interval (values below this have no effect + // since tokens with less than POLLER_INTERVAL remaining force a synchronous refresh) + const effectiveThreshold = Math.max(refreshThreshold, POLLER_INTERVAL_IN_MS / 1000); // Token is valid but expiring soon - signal that background refresh is needed const needsRefresh = remainingTtl < effectiveThreshold; diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index baf45bb3db7..fdb074ad681 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -340,18 +340,18 @@ export interface SessionTask { export type GetTokenOptions = { /** - * Seconds before token expiration to trigger background refresh via the session poller. - * When a token's remaining TTL falls below this threshold, the cache signals that a - * background refresh should occur while still returning the current valid token. + * Seconds before token expiration to trigger background refresh. + * When a token's remaining TTL falls below this threshold, `getToken()` returns the + * cached token immediately while triggering a background refresh. * - * - Minimum value: 15 seconds (values below this are ignored) + * - Minimum value: 5 seconds (the poller interval) * - Default value: 15 seconds * + * Lower values delay background refresh until closer to expiration. * Higher values trigger earlier background refresh, which can reduce latency for - * time-sensitive operations. However, setting this too high may cause excessive - * token refresh requests and potentially trip rate limiting rules. + * time-sensitive operations but may cause more frequent token refresh requests. */ - leewayInSeconds?: number; + backgroundRefreshThreshold?: number; organizationId?: string; /** * @internal diff --git a/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md b/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md index 98103fa3ba6..3cb28cb4830 100644 --- a/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md +++ b/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md @@ -1,38 +1,54 @@ --- -title: '`getToken` `leewayInSeconds` has 15s minimum' +title: '`getToken` `leewayInSeconds` renamed to `backgroundRefreshThreshold`' matcher: - 'leewayInSeconds' + - 'backgroundRefreshThreshold' - 'getToken' category: 'behavior-change' warning: true --- -The `leewayInSeconds` option in `session.getToken()` now has a minimum enforced value of 15 seconds. Values below this threshold are ignored and the minimum is used instead. +The `leewayInSeconds` option in `session.getToken()` has been renamed to `backgroundRefreshThreshold` for clarity. This option controls when background token refresh is triggered before expiration. -This threshold ensures reliable token refresh by accounting for timer jitter, network latency, and background task contention. +### What changed? -### Why this change? +1. **Renamed option**: `leewayInSeconds` → `backgroundRefreshThreshold` +2. **Lower minimum**: The minimum value is now 5 seconds (the poller interval) instead of 15 seconds +3. **Clearer semantics**: The new name better describes what the option does -Setting `leewayInSeconds` too low could result in tokens expiring before background refresh completes, causing authentication failures. The 15-second minimum provides a safety buffer. +### Migration -### Impact +```js +// Before +const token = await session.getToken({ leewayInSeconds: 30 }); -If you were using `leewayInSeconds` with a value less than 15 seconds: +// After +const token = await session.getToken({ backgroundRefreshThreshold: 30 }); +``` -```js -// Before: 5s leeway (now enforced as 15s minimum) -const token = await session.getToken({ leewayInSeconds: 5 }); +### How it works + +When a token's remaining TTL falls below the `backgroundRefreshThreshold`, `getToken()` returns the cached token immediately while triggering a background refresh. + +- **Minimum value**: 5 seconds (the poller interval) +- **Default value**: 15 seconds -// After: Behaves as if leewayInSeconds: 15 was passed +``` +Token TTL Timeline +──────────────────────────────────────────────────────► + expires + +│←── Fresh zone ──→│←── Background refresh ──→│←─ Sync ─→│ + (no refresh) (SWR: return + refresh) (force) + + > threshold 5s - threshold < 5s ``` ### Rate Limiting Warning -Setting `leewayInSeconds` higher than the default (15s) triggers earlier background token refresh. While this can reduce latency for time-sensitive operations, values that are too high may cause excessive token refresh requests and potentially trigger rate limiting. +Setting `backgroundRefreshThreshold` higher than the default triggers earlier background refresh. While this can reduce latency for time-sensitive operations, values that are too high may cause excessive token refresh requests and potentially trigger rate limiting. ```js -// Use with caution - triggers refresh 60s before expiration -const token = await session.getToken({ leewayInSeconds: 60 }); +// Use with caution - triggers refresh 30s before expiration +const token = await session.getToken({ backgroundRefreshThreshold: 30 }); ``` - -No code changes are required unless you want to adjust the refresh timing. diff --git a/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md b/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md index 6b0661c5454..f105b671f66 100644 --- a/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md +++ b/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md @@ -11,7 +11,7 @@ warning: true ### How it works -1. When a token is within 15 seconds of expiration (configurable via `leewayInSeconds`), `getToken()` returns the valid cached token immediately +1. When a token is within 15 seconds of expiration (configurable via `backgroundRefreshThreshold`), `getToken()` returns the valid cached token immediately 2. A background refresh is triggered automatically to fetch a fresh token 3. Subsequent calls receive the new token once the background refresh completes @@ -40,9 +40,9 @@ This is a transparent improvement - no code changes are required. Your existing ### Customizing refresh timing -Use `leewayInSeconds` to trigger background refresh earlier (minimum 15 seconds): +Use `backgroundRefreshThreshold` to customize when background refresh is triggered (minimum 5 seconds, default 15 seconds): ```js // Start background refresh 30 seconds before expiration -const token = await session.getToken({ leewayInSeconds: 30 }); +const token = await session.getToken({ backgroundRefreshThreshold: 30 }); ``` From d5075a71dd3432075ca791a38ed57774abcee687 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 15 Jan 2026 07:47:49 -0600 Subject: [PATCH 13/25] fix(clerk-js): use JWT iat for token cache timing to fix SWR threshold accuracy --- .../clerk-js/src/core/resources/__tests__/Session.test.ts | 4 ++-- packages/clerk-js/src/core/tokenCache.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 1a52b03b997..ddf7220f3bb 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -21,9 +21,9 @@ describe('Session', () => { beforeEach(() => { // Mock Date.now() to make the test tokens appear valid // mockJwt has iat: 1666648250, exp: 1666648310 - // Set current time to 1666648260 (10 seconds after iat, 50 seconds before exp) + // Set current time to iat so token appears freshly issued (60 seconds before exp) vi.useFakeTimers(); - vi.setSystemTime(new Date(1666648260 * 1000)); + vi.setSystemTime(new Date(1666648250 * 1000)); }); afterEach(() => { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 7220938af66..cc8665dc79b 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -377,6 +377,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const issuedAt = claims.iat; const expiresIn: Seconds = expiresAt - issuedAt; + value.createdAt = issuedAt; value.expiresIn = expiresIn; const timeoutId = setTimeout(deleteKey, expiresIn * 1000); From ca53830e4098d8307db42d8ca67c3795f0d14cf6 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 15 Jan 2026 07:58:20 -0600 Subject: [PATCH 14/25] chore(clerk-js): add SWR debug logging for threshold investigation --- packages/clerk-js/src/core/resources/Session.ts | 3 ++- packages/clerk-js/src/core/tokenCache.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 95b042c28d0..03a009d1c19 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -384,10 +384,11 @@ export class Session extends BaseResource implements SessionResource { // Trigger background refresh if token is expiring soon // This guarantees cache revalidation without relying solely on the poller if (cacheResult.needsRefresh) { + debugLogger.debug('SWR: triggering background refresh', { backgroundRefreshThreshold, tokenId }, 'session'); this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate); } - debugLogger.debug('Using cached token', { tokenId, needsRefresh: cacheResult.needsRefresh }, 'session'); + debugLogger.debug('Using cached token', { backgroundRefreshThreshold, needsRefresh: cacheResult.needsRefresh, tokenId }, 'session'); // Prefer synchronous read to avoid microtask overhead when token is already resolved const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index cc8665dc79b..a688a866f48 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -234,6 +234,21 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { // Token is valid but expiring soon - signal that background refresh is needed const needsRefresh = remainingTtl < effectiveThreshold; + debugLogger.debug( + 'SWR cache check', + { + createdAt: value.createdAt, + effectiveThreshold, + elapsed, + expiresIn: value.expiresIn, + needsRefresh, + nowSeconds, + refreshThreshold, + remainingTtl: Math.floor(remainingTtl), + }, + 'tokenCache', + ); + // Return the valid token immediately, caller decides whether to refresh return { entry: value.entry, needsRefresh }; }; From e8b375fff9ad93953803465f3f07b9061fcc4c42 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 15 Jan 2026 08:07:58 -0600 Subject: [PATCH 15/25] chore(clerk-js): enhance SWR debug logging for duplicate refresh investigation --- packages/clerk-js/src/core/resources/Session.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 03a009d1c19..2ec24e90b4e 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -467,12 +467,12 @@ export class Session extends BaseResource implements SessionResource { ): void { // Prevent multiple concurrent background refreshes for the same token if (Session.#backgroundRefreshInProgress.has(tokenId)) { - debugLogger.debug('Background refresh already in progress', { tokenId }, 'session'); + debugLogger.debug('SWR: skipped - refresh already in progress', { tokenId }, 'session'); return; } Session.#backgroundRefreshInProgress.add(tokenId); - debugLogger.info('Refreshing token in background', { organizationId, template, tokenId }, 'session'); + debugLogger.info('SWR: starting background refresh', { inProgressCount: Session.#backgroundRefreshInProgress.size, organizationId, template, tokenId }, 'session'); const tokenResolver = this.#createTokenResolver(template, organizationId, false); @@ -483,13 +483,15 @@ export class Session extends BaseResource implements SessionResource { // Cache the resolved token for future calls SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token) }); this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); + debugLogger.info('SWR: background refresh completed', { tokenId }, 'session'); }) .catch(error => { // Log but don't propagate - callers already have stale token - debugLogger.warn('Background token refresh failed', { error, tokenId }, 'session'); + debugLogger.warn('SWR: background refresh failed', { error, tokenId }, 'session'); }) .finally(() => { Session.#backgroundRefreshInProgress.delete(tokenId); + debugLogger.debug('SWR: cleared from in-progress set', { inProgressCount: Session.#backgroundRefreshInProgress.size, tokenId }, 'session'); }); } From c58d1f4a1d5f50587a939bdce76e453f5f3f0a84 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 15 Jan 2026 10:12:02 -0600 Subject: [PATCH 16/25] chore(clerk-js): remove SWR investigation debug logging --- packages/clerk-js/src/core/resources/Session.ts | 10 +--------- packages/clerk-js/src/core/tokenCache.ts | 15 --------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 2ec24e90b4e..65186e36544 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -377,19 +377,15 @@ export class Session extends BaseResource implements SessionResource { if (cacheResult) { // If caller requests refresh when stale (e.g., poller), fetch fresh token instead of returning cached if (cacheResult.needsRefresh && refreshIfStale) { - debugLogger.debug('Token is stale, refreshing as requested', { tokenId }, 'session'); return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); } // Trigger background refresh if token is expiring soon // This guarantees cache revalidation without relying solely on the poller if (cacheResult.needsRefresh) { - debugLogger.debug('SWR: triggering background refresh', { backgroundRefreshThreshold, tokenId }, 'session'); this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate); } - debugLogger.debug('Using cached token', { backgroundRefreshThreshold, needsRefresh: cacheResult.needsRefresh, tokenId }, 'session'); - // Prefer synchronous read to avoid microtask overhead when token is already resolved const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); if (shouldDispatchTokenUpdate) { @@ -467,12 +463,10 @@ export class Session extends BaseResource implements SessionResource { ): void { // Prevent multiple concurrent background refreshes for the same token if (Session.#backgroundRefreshInProgress.has(tokenId)) { - debugLogger.debug('SWR: skipped - refresh already in progress', { tokenId }, 'session'); return; } Session.#backgroundRefreshInProgress.add(tokenId); - debugLogger.info('SWR: starting background refresh', { inProgressCount: Session.#backgroundRefreshInProgress.size, organizationId, template, tokenId }, 'session'); const tokenResolver = this.#createTokenResolver(template, organizationId, false); @@ -483,15 +477,13 @@ export class Session extends BaseResource implements SessionResource { // Cache the resolved token for future calls SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token) }); this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); - debugLogger.info('SWR: background refresh completed', { tokenId }, 'session'); }) .catch(error => { // Log but don't propagate - callers already have stale token - debugLogger.warn('SWR: background refresh failed', { error, tokenId }, 'session'); + debugLogger.warn('Background token refresh failed', { error, tokenId }, 'session'); }) .finally(() => { Session.#backgroundRefreshInProgress.delete(tokenId); - debugLogger.debug('SWR: cleared from in-progress set', { inProgressCount: Session.#backgroundRefreshInProgress.size, tokenId }, 'session'); }); } diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index a688a866f48..cc8665dc79b 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -234,21 +234,6 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { // Token is valid but expiring soon - signal that background refresh is needed const needsRefresh = remainingTtl < effectiveThreshold; - debugLogger.debug( - 'SWR cache check', - { - createdAt: value.createdAt, - effectiveThreshold, - elapsed, - expiresIn: value.expiresIn, - needsRefresh, - nowSeconds, - refreshThreshold, - remainingTtl: Math.floor(remainingTtl), - }, - 'tokenCache', - ); - // Return the valid token immediately, caller decides whether to refresh return { entry: value.entry, needsRefresh }; }; From cb291e728b1b95dbb3eba15bd0648b6d664a0a56 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 26 Jan 2026 19:53:12 -0600 Subject: [PATCH 17/25] feat(clerk-js): Replace SWR with event-driven token refresh 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 --- .../src/core/__tests__/tokenCache.test.ts | 249 +++++------------- .../clerk-js/src/core/resources/Session.ts | 39 ++- .../core/resources/__tests__/Session.test.ts | 240 ++++------------- packages/clerk-js/src/core/tokenCache.ts | 65 +++-- packages/shared/src/types/session.ts | 20 -- 5 files changed, 184 insertions(+), 429 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index eec3d16ce04..14174c218af 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -334,7 +334,6 @@ describe('SessionTokenCache', () => { const result = SessionTokenCache.get({ tokenId: 'future_token' }); expect(result).toBeDefined(); expect(result?.entry.tokenId).toBe('future_token'); - expect(result?.needsRefresh).toBe(false); }); it('removes token when it has already expired based on duration', async () => { @@ -356,7 +355,7 @@ describe('SessionTokenCache', () => { expect(result).toBeUndefined(); }); - it('returns token with needsRefresh when remaining TTL is less than leeway (SWR)', async () => { + it('returns token when remaining TTL is above poller interval', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const iat = nowSeconds; const exp = iat + 20; @@ -367,16 +366,15 @@ describe('SessionTokenCache', () => { jwt: { claims: { exp, iat } }, } as any); - // Token has 20s TTL, created 11s ago = 9s remaining (< 10s default leeway) + // Token has 20s TTL, created 11s ago = 9s remaining (> 5s poller interval) SessionTokenCache.set({ createdAt: nowSeconds - 11, tokenId: 'soon_expired_token', tokenResolver }); await tokenResolver; - // SWR: Token is still valid (9s > 0), so it should be returned with needsRefresh=true + // Token is still valid (9s > 5s poller interval), so it should be returned const result = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); expect(result).toBeDefined(); expect(result?.entry.tokenId).toBe('soon_expired_token'); - expect(result?.needsRefresh).toBe(true); }); it('returns token when expiresAt is undefined (promise not yet resolved)', () => { @@ -537,270 +535,170 @@ describe('SessionTokenCache', () => { }); }); - describe('SWR leeway behavior', () => { - it('returns needsRefresh=false when token has plenty of time remaining', async () => { + describe('proactive refresh timer', () => { + it('calls onRefresh callback when refresh timer fires', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'fresh-token', + id: 'refresh-timer-token', jwt, object: 'token', }); + const onRefresh = vi.fn(); const tokenResolver = Promise.resolve(token); - const key = { audience: 'fresh-test', tokenId: 'fresh-token' }; + const key = { audience: 'refresh-test', tokenId: 'refresh-timer-token' }; - SessionTokenCache.set({ ...key, tokenResolver }); + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); await tokenResolver; - // Token just created, 60s remaining - should return needsRefresh=false - const result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('fresh-token'); - expect(result?.needsRefresh).toBe(false); - }); - - it('returns needsRefresh=true when token is within default leeway (SWR)', async () => { - const nowSeconds = Math.floor(Date.now() / 1000); - const jwt = createJwtWithTtl(nowSeconds, 60); - - const token = new Token({ - id: 'expiring-token', - jwt, - object: 'token', - }); - - const tokenResolver = Promise.resolve(token); - const key = { audience: 'expiring-test', tokenId: 'expiring-token' }; - - SessionTokenCache.set({ ...key, tokenResolver }); - await tokenResolver; + // Timer should fire at: 60s - 15s (leeway) - 2s (lead time) = 43s + expect(onRefresh).not.toHaveBeenCalled(); - // At 44s elapsed, 16s remaining - fresh, no refresh needed (> 15s MIN_REMAINING_TTL) - vi.advanceTimersByTime(44 * 1000); - let result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('expiring-token'); - expect(result?.needsRefresh).toBe(false); + // Advance to just before timer should fire + vi.advanceTimersByTime(42 * 1000); + expect(onRefresh).not.toHaveBeenCalled(); - // At 46s elapsed, 14s remaining (< 15s MIN_REMAINING_TTL) - SWR: return token with needsRefresh=true + // Advance past timer fire time vi.advanceTimersByTime(2 * 1000); - result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('expiring-token'); - expect(result?.needsRefresh).toBe(true); - - // At 60s elapsed, 0s remaining - token actually expired, return undefined - vi.advanceTimersByTime(14 * 1000); - result = SessionTokenCache.get(key); - expect(result).toBeUndefined(); + expect(onRefresh).toHaveBeenCalledTimes(1); }); - it('returns needsRefresh=true consistently while token is within leeway', async () => { + it('does not schedule refresh timer when onRefresh is not provided', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'dedupe-token', + id: 'no-callback-token', jwt, object: 'token', }); const tokenResolver = Promise.resolve(token); - const key = { audience: 'dedupe-test', tokenId: 'dedupe-token' }; + const key = { tokenId: 'no-callback-token' }; + // Set without onRefresh (like broadcast-received tokens) SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - // Advance to within leeway - vi.advanceTimersByTime(51 * 1000); // 9s remaining + // Token should still be cached and retrievable + const result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('no-callback-token'); - // First call: needsRefresh=true - let result = SessionTokenCache.get(key); - expect(result?.needsRefresh).toBe(true); + // Advance past when timer would fire - nothing should happen + vi.advanceTimersByTime(50 * 1000); - // Second call: needsRefresh=true (consistently signals refresh needed) - result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('dedupe-token'); - expect(result?.needsRefresh).toBe(true); + // Token should still be valid (10s remaining) + const stillCached = SessionTokenCache.get(key); + expect(stillCached?.entry.tokenId).toBe('no-callback-token'); }); - it('honors larger custom leeway values', async () => { + it('clears refresh timer when entry is deleted', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'custom-leeway-token', + id: 'clear-timer-token', jwt, object: 'token', }); + const onRefresh = vi.fn(); const tokenResolver = Promise.resolve(token); - const key = { audience: 'custom-leeway-test', tokenId: 'custom-leeway-token' }; + const key = { tokenId: 'clear-timer-token' }; - SessionTokenCache.set({ ...key, tokenResolver }); + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); await tokenResolver; - // At 29s elapsed, 31s remaining - fresh with 30s leeway - vi.advanceTimersByTime(29 * 1000); - let result = SessionTokenCache.get(key, 30); - expect(result?.entry.tokenId).toBe('custom-leeway-token'); - expect(result?.needsRefresh).toBe(false); + // Clear the cache before timer fires + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.clear(); - // At 31s elapsed, 29s remaining (< 30s leeway) - needs refresh - vi.advanceTimersByTime(2 * 1000); - result = SessionTokenCache.get(key, 30); - expect(result?.entry.tokenId).toBe('custom-leeway-token'); - expect(result?.needsRefresh).toBe(true); + // Advance past when timer would have fired + vi.advanceTimersByTime(20 * 1000); + + // onRefresh should not have been called since entry was cleared + expect(onRefresh).not.toHaveBeenCalled(); }); - it('enforces minimum 15 second threshold even when leeway is set to 0', async () => { + it('does not schedule refresh timer for tokens with very short TTL', async () => { const nowSeconds = Math.floor(Date.now() / 1000); - const jwt = createJwtWithTtl(nowSeconds, 60); + // Token with 10s TTL - refreshFireTime would be 10 - 15 - 2 = -7 (negative) + const jwt = createJwtWithTtl(nowSeconds, 10); const token = new Token({ - id: 'zero-leeway-token', + id: 'short-ttl-token', jwt, object: 'token', }); + const onRefresh = vi.fn(); const tokenResolver = Promise.resolve(token); - const key = { audience: 'zero-leeway-test', tokenId: 'zero-leeway-token' }; + const key = { tokenId: 'short-ttl-token' }; - SessionTokenCache.set({ ...key, tokenResolver }); + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); await tokenResolver; - // 16s remaining (above 15s threshold) - vi.advanceTimersByTime(44 * 1000); - let result = SessionTokenCache.get(key, 0); - expect(result?.entry.tokenId).toBe('zero-leeway-token'); - expect(result?.needsRefresh).toBe(false); - - // 14s remaining (below 15s threshold) - vi.advanceTimersByTime(2 * 1000); - result = SessionTokenCache.get(key, 0); - expect(result?.entry.tokenId).toBe('zero-leeway-token'); - expect(result?.needsRefresh).toBe(true); + // Advance past token expiration + vi.advanceTimersByTime(15 * 1000); - // 0s remaining (expired) - vi.advanceTimersByTime(14 * 1000); - result = SessionTokenCache.get(key, 0); - expect(result).toBeUndefined(); + // onRefresh should not have been called - no timer was scheduled + expect(onRefresh).not.toHaveBeenCalled(); }); - }); - describe('conservative threshold behavior', () => { - it('returns needsRefresh=false when TTL is comfortably above 15s threshold', async () => { + it('returns token until expiration even after refresh timer fires', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'above-threshold-token', + id: 'still-valid-token', jwt, object: 'token', }); + const onRefresh = vi.fn(); const tokenResolver = Promise.resolve(token); - const key = { tokenId: 'above-threshold-token' }; + const key = { tokenId: 'still-valid-token' }; - SessionTokenCache.set({ ...key, tokenResolver }); + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); await tokenResolver; - // 60s remaining - let result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('above-threshold-token'); - expect(result?.needsRefresh).toBe(false); + // Advance past refresh timer (43s) + vi.advanceTimersByTime(50 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); - // 20s remaining - vi.advanceTimersByTime(40 * 1000); - result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('above-threshold-token'); - expect(result?.needsRefresh).toBe(false); + // Token should still be retrievable (10s remaining, > 5s poller interval) + const result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('still-valid-token'); }); + }); - it('returns needsRefresh=true when TTL drops just below 15s threshold', async () => { + describe('hard cutoff behavior', () => { + it('returns token when TTL is above poller interval', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'below-threshold-token', + id: 'above-cutoff-token', jwt, object: 'token', }); const tokenResolver = Promise.resolve(token); - const key = { tokenId: 'below-threshold-token' }; + const key = { tokenId: 'above-cutoff-token' }; SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - // 16s remaining - vi.advanceTimersByTime(44 * 1000); + // 60s remaining let result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('below-threshold-token'); - expect(result?.needsRefresh).toBe(false); + expect(result?.entry.tokenId).toBe('above-cutoff-token'); - // 14s remaining - vi.advanceTimersByTime(2 * 1000); + // 10s remaining (above 5s cutoff) + vi.advanceTimersByTime(50 * 1000); result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('below-threshold-token'); - expect(result?.needsRefresh).toBe(true); - }); - - it('uses caller leeway when larger than 15s threshold', async () => { - const nowSeconds = Math.floor(Date.now() / 1000); - const jwt = createJwtWithTtl(nowSeconds, 60); - - const token = new Token({ - id: 'large-leeway-token', - jwt, - object: 'token', - }); - - const tokenResolver = Promise.resolve(token); - const key = { tokenId: 'large-leeway-token' }; - - SessionTokenCache.set({ ...key, tokenResolver }); - await tokenResolver; - - // 21s remaining (leeway=20s) - vi.advanceTimersByTime(39 * 1000); - let result = SessionTokenCache.get(key, 20); - expect(result?.entry.tokenId).toBe('large-leeway-token'); - expect(result?.needsRefresh).toBe(false); - - // 19s remaining (leeway=20s) - vi.advanceTimersByTime(2 * 1000); - result = SessionTokenCache.get(key, 20); - expect(result?.entry.tokenId).toBe('large-leeway-token'); - expect(result?.needsRefresh).toBe(true); - }); - - it('ignores caller leeway when smaller than 15s threshold', async () => { - const nowSeconds = Math.floor(Date.now() / 1000); - const jwt = createJwtWithTtl(nowSeconds, 60); - - const token = new Token({ - id: 'small-leeway-token', - jwt, - object: 'token', - }); - - const tokenResolver = Promise.resolve(token); - const key = { tokenId: 'small-leeway-token' }; - - SessionTokenCache.set({ ...key, tokenResolver }); - await tokenResolver; - - // 16s remaining (leeway=5s, but min threshold is 15s) - vi.advanceTimersByTime(44 * 1000); - let result = SessionTokenCache.get(key, 5); - expect(result?.entry.tokenId).toBe('small-leeway-token'); - expect(result?.needsRefresh).toBe(false); - - // 14s remaining - vi.advanceTimersByTime(2 * 1000); - result = SessionTokenCache.get(key, 5); - expect(result?.entry.tokenId).toBe('small-leeway-token'); - expect(result?.needsRefresh).toBe(true); + expect(result?.entry.tokenId).toBe('above-cutoff-token'); }); it('forces synchronous refresh when token has less than poller interval remaining', async () => { @@ -823,7 +721,6 @@ describe('SessionTokenCache', () => { vi.advanceTimersByTime(54 * 1000); let result = SessionTokenCache.get(key); expect(result?.entry.tokenId).toBe('hard-cutoff-token'); - expect(result?.needsRefresh).toBe(true); // 4s remaining (below 5s cutoff) - forces sync refresh vi.advanceTimersByTime(2 * 1000); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 65186e36544..8bc9adb3221 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -138,9 +138,14 @@ export class Session extends BaseResource implements SessionResource { #hydrateCache = (token: TokenResource | null) => { if (token) { + const tokenId = this.#getCacheId(); + // Dispatch tokenUpdate for __session tokens with the session's active organization ID + const shouldDispatchTokenUpdate = true; SessionTokenCache.set({ - tokenId: this.#getCacheId(), + tokenId, tokenResolver: Promise.resolve(token), + onRefresh: () => + this.#refreshTokenInBackground(undefined, this.lastActiveOrganizationId, tokenId, shouldDispatchTokenUpdate), }); } }; @@ -356,36 +361,22 @@ export class Session extends BaseResource implements SessionResource { return null; } - const { backgroundRefreshThreshold, refreshIfStale = false, skipCache = false, template } = options || {}; + const { skipCache = false, template } = options || {}; // If no organization ID is provided, default to the selected organization in memory // Note: this explicitly allows passing `null` or `""`, which should select the personal workspace. const organizationId = typeof options?.organizationId === 'undefined' ? this.lastActiveOrganizationId : options?.organizationId; - if (!template && Number(backgroundRefreshThreshold) >= 60) { - throw new Error('backgroundRefreshThreshold cannot exceed the token lifespan (60 seconds)'); - } - const tokenId = this.#getCacheId(template, organizationId); - const cacheResult = skipCache ? undefined : SessionTokenCache.get({ tokenId }, backgroundRefreshThreshold); + const cacheResult = skipCache ? undefined : SessionTokenCache.get({ tokenId }); // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; if (cacheResult) { - // If caller requests refresh when stale (e.g., poller), fetch fresh token instead of returning cached - if (cacheResult.needsRefresh && refreshIfStale) { - return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); - } - - // Trigger background refresh if token is expiring soon - // This guarantees cache revalidation without relying solely on the poller - if (cacheResult.needsRefresh) { - this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate); - } - + // Proactive refresh is handled by timers scheduled in the cache // Prefer synchronous read to avoid microtask overhead when token is already resolved const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); if (shouldDispatchTokenUpdate) { @@ -439,7 +430,11 @@ export class Session extends BaseResource implements SessionResource { debugLogger.info('Fetching new token from API', { organizationId, template, tokenId }, 'session'); const tokenResolver = this.#createTokenResolver(template, organizationId, skipCache); - SessionTokenCache.set({ tokenId, tokenResolver }); + SessionTokenCache.set({ + tokenId, + tokenResolver, + onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + }); return tokenResolver.then(token => { this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); @@ -475,7 +470,11 @@ export class Session extends BaseResource implements SessionResource { tokenResolver .then(token => { // Cache the resolved token for future calls - SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token) }); + SessionTokenCache.set({ + tokenId, + tokenResolver: Promise.resolve(token), + onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + }); this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); }) .catch(error => { diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index ddf7220f3bb..e838ed0bf63 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -419,8 +419,8 @@ describe('Session', () => { expect(requestSpy).toHaveBeenCalledTimes(2); }); - describe('stale-while-revalidate (SWR) behavior', () => { - it('returns stale token immediately while refreshing in background', async () => { + describe('timer-based proactive refresh', () => { + it('triggers background refresh via timer before leeway period', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; @@ -437,37 +437,24 @@ describe('Session', () => { } as SessionJSON); await Promise.resolve(); + requestSpy.mockClear(); - // Advance time so token needs refresh (< 15s remaining of 60s TTL) - vi.advanceTimersByTime(46 * 1000); + // Timer fires at 60s - 15s (leeway) - 2s (lead time) = 43s + // Advance to just before timer fires + vi.advanceTimersByTime(42 * 1000); + expect(requestSpy).not.toHaveBeenCalled(); - // Hold the network request pending - let resolveNetworkRequest!: (value: any) => void; - requestSpy.mockClear(); - requestSpy.mockReturnValueOnce( - new Promise(resolve => { - resolveNetworkRequest = resolve; - }), - ); + // Set up the mock for the refresh + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); - // Concurrent calls should all return immediately with stale token - const [token1, token2, token3] = await Promise.all([ - session.getToken(), - session.getToken(), - session.getToken(), - ]); + // Advance past timer fire time + await vi.advanceTimersByTimeAsync(2 * 1000); - expect(token1).toEqual(mockJwt); - expect(token2).toEqual(mockJwt); - expect(token3).toEqual(mockJwt); + // Background refresh should have been triggered by the timer expect(requestSpy).toHaveBeenCalledTimes(1); - - // Cleanup: resolve the pending request - resolveNetworkRequest({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); - await vi.advanceTimersByTimeAsync(0); }); - it('continues returning tokens after background refresh failure', async () => { + it('continues returning cached token while timer-triggered refresh is pending', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; @@ -484,24 +471,36 @@ describe('Session', () => { } as SessionJSON); await Promise.resolve(); - vi.advanceTimersByTime(46 * 1000); - - // Background refresh fails requestSpy.mockClear(); - requestSpy.mockRejectedValueOnce(new Error('Network error')); - const token = await session.getToken(); - expect(token).toEqual(mockJwt); + // Hold the network request pending + let resolveNetworkRequest!: (value: any) => void; + requestSpy.mockReturnValueOnce( + new Promise(resolve => { + resolveNetworkRequest = resolve; + }), + ); - // Wait for background failure to complete - await vi.advanceTimersByTimeAsync(100); + // Advance to trigger the timer (43s) + await vi.advanceTimersByTimeAsync(44 * 1000); + + // Concurrent calls should all return cached token + const [token1, token2, token3] = await Promise.all([ + session.getToken(), + session.getToken(), + session.getToken(), + ]); - // Subsequent call should still return token (stale is preserved) - const token2 = await session.getToken(); + expect(token1).toEqual(mockJwt); expect(token2).toEqual(mockJwt); + expect(token3).toEqual(mockJwt); + + // Cleanup: resolve the pending request + resolveNetworkRequest({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + await vi.advanceTimersByTimeAsync(0); }); - it('retries background refresh after previous failure', async () => { + it('continues returning tokens after timer-triggered refresh failure', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; @@ -518,26 +517,20 @@ describe('Session', () => { } as SessionJSON); await Promise.resolve(); - vi.advanceTimersByTime(46 * 1000); - - // First call triggers background refresh that fails requestSpy.mockClear(); - requestSpy.mockRejectedValueOnce(new Error('Network error')); - await session.getToken(); - expect(requestSpy).toHaveBeenCalledTimes(1); + // Timer-triggered refresh fails + requestSpy.mockRejectedValueOnce(new Error('Network error')); - await vi.advanceTimersByTimeAsync(100); + // Advance to trigger timer (43s) and wait for it to complete + await vi.advanceTimersByTimeAsync(44 * 1000); - // Second call should trigger another refresh attempt - requestSpy.mockClear(); - requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); - - await session.getToken(); - expect(requestSpy).toHaveBeenCalledTimes(1); + // getToken should still return the cached token + const token = await session.getToken(); + expect(token).toEqual(mockJwt); }); - it('uses refreshed token for subsequent calls after background refresh succeeds', async () => { + it('uses refreshed token after timer-triggered refresh succeeds', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; @@ -557,18 +550,13 @@ describe('Session', () => { } as SessionJSON); await Promise.resolve(); - vi.advanceTimersByTime(46 * 1000); - - // Background refresh returns new token requestSpy.mockClear(); - requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: newMockJwt }, status: 200 }); - // First call returns stale token - const staleToken = await session.getToken(); - expect(staleToken).toEqual(mockJwt); + // Timer-triggered refresh returns new token + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: newMockJwt }, status: 200 }); - // Wait for background refresh to complete - await vi.advanceTimersByTimeAsync(100); + // Advance to trigger timer and wait for refresh to complete + await vi.advanceTimersByTimeAsync(44 * 1000); // Subsequent call returns refreshed token (no new API call needed) requestSpy.mockClear(); @@ -577,137 +565,7 @@ describe('Session', () => { expect(requestSpy).not.toHaveBeenCalled(); }); - it('forces synchronous refresh when refreshIfStale is true and token is stale', async () => { - BaseResource.clerk = clerkMock(); - const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; - - const newMockJwt = - 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg0MDAsImlhdCI6MTY2NjY0ODM0MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5leGFtcGxlLmNvbSIsImp0aSI6Im5ld3Rva2VuIiwibmJmIjoxNjY2NjQ4MzQwLCJzaWQiOiJzZXNzXzFxcTlveTVHaU5IeGRSMlhXVTZnRzZtSWNCWCIsInN1YiI6InVzZXJfMXFxOW95NUdpTkh4ZFIyWFdVNmdHNm1JY0JYIn0.mock'; - - const session = new Session({ - status: 'active', - id: 'session_1', - object: 'session', - user: createUser({}), - last_active_organization_id: null, - last_active_token: { object: 'token', jwt: mockJwt }, - actor: null, - created_at: new Date().getTime(), - updated_at: new Date().getTime(), - } as SessionJSON); - - await Promise.resolve(); - - // Advance time so token needs refresh (< 15s remaining of 60s TTL) - vi.advanceTimersByTime(46 * 1000); - - // With refreshIfStale: true, should fetch fresh token synchronously - requestSpy.mockClear(); - requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: newMockJwt }, status: 200 }); - - const token = await session.getToken({ refreshIfStale: true }); - - // Should return the NEW token (synchronous refresh), not the stale one - expect(token).toEqual(newMockJwt); - expect(requestSpy).toHaveBeenCalledTimes(1); - }); - - it('returns cached token without refresh when refreshIfStale is true but token is fresh', async () => { - BaseResource.clerk = clerkMock(); - const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; - - const session = new Session({ - status: 'active', - id: 'session_1', - object: 'session', - user: createUser({}), - last_active_organization_id: null, - last_active_token: { object: 'token', jwt: mockJwt }, - actor: null, - created_at: new Date().getTime(), - updated_at: new Date().getTime(), - } as SessionJSON); - - await Promise.resolve(); - - // Advance only 10s - token still has 50s remaining, not stale yet - vi.advanceTimersByTime(10 * 1000); - - requestSpy.mockClear(); - const token = await session.getToken({ refreshIfStale: true }); - - // Should return cached token, no API call - expect(token).toEqual(mockJwt); - expect(requestSpy).not.toHaveBeenCalled(); - }); - - it('respects backgroundRefreshThreshold for earlier background refresh', async () => { - BaseResource.clerk = clerkMock(); - const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; - - const session = new Session({ - status: 'active', - id: 'session_1', - object: 'session', - user: createUser({}), - last_active_organization_id: null, - last_active_token: { object: 'token', jwt: mockJwt }, - actor: null, - created_at: new Date().getTime(), - updated_at: new Date().getTime(), - } as SessionJSON); - - await Promise.resolve(); - - // With 30s threshold and 25s remaining (< 30), token should trigger background refresh - vi.advanceTimersByTime(35 * 1000); // 25s remaining - - requestSpy.mockClear(); - requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); - - const token = await session.getToken({ backgroundRefreshThreshold: 30 }); - - // Should return stale token immediately (SWR behavior) - expect(token).toEqual(mockJwt); - // Should trigger background refresh because 25s < 30s threshold - expect(requestSpy).toHaveBeenCalledTimes(1); - }); - - it('allows backgroundRefreshThreshold below 15s (minimum is 5s poller interval)', async () => { - BaseResource.clerk = clerkMock(); - const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; - - const session = new Session({ - status: 'active', - id: 'session_1', - object: 'session', - user: createUser({}), - last_active_organization_id: null, - last_active_token: { object: 'token', jwt: mockJwt }, - actor: null, - created_at: new Date().getTime(), - updated_at: new Date().getTime(), - } as SessionJSON); - - await Promise.resolve(); - - // With 8s remaining and backgroundRefreshThreshold: 10, should trigger refresh - vi.advanceTimersByTime(52 * 1000); // 8s remaining - - requestSpy.mockClear(); - requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); - - const token = await session.getToken({ backgroundRefreshThreshold: 10 }); - - // Should return cached token immediately (SWR behavior) - expect(token).toEqual(mockJwt); - - // Background refresh should be triggered because 8s < 10s threshold - // (threshold of 10s is respected, not floored to 15s) - expect(requestSpy).toHaveBeenCalledTimes(1); - }); - - it('does not trigger background refresh when token has more than threshold remaining', async () => { + it('does not make API call when token has plenty of time remaining', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index cc8665dc79b..976fba0dcf6 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -24,6 +24,12 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { * Used for expiration and cleanup scheduling. */ createdAt?: Seconds; + /** + * Callback to refresh this token before it expires. + * Called by the proactive refresh timer to trigger background refresh. + * If not provided, no refresh timer will be scheduled (e.g., for broadcast-received tokens). + */ + onRefresh?: () => void; /** * The resolved token value for synchronous reads. * Populated after tokenResolver resolves. Check this first to avoid microtask overhead. @@ -39,22 +45,23 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { type Seconds = number; /** - * Internal cache value containing the entry, expiration metadata, and cleanup timer. + * Internal cache value containing the entry, expiration metadata, and timers. */ interface TokenCacheValue { createdAt: Seconds; entry: TokenCacheEntry; expiresIn?: Seconds; + /** Timer for automatic cache cleanup when token expires */ timeoutId?: ReturnType; + /** Timer for proactive refresh before token enters leeway period */ + refreshTimeoutId?: ReturnType; } /** - * Result from cache lookup containing the entry and refresh status. + * Result from cache lookup containing the entry. */ export interface TokenCacheGetResult { entry: TokenCacheEntry; - /** Indicates the token is valid but expiring soon and should be refreshed in the background */ - needsRefresh: boolean; } export interface TokenCache { @@ -72,15 +79,13 @@ export interface TokenCache { /** * Retrieves a cached token entry if it exists and is safe to use. - * Implements stale-while-revalidate: returns valid tokens immediately and signals when background refresh is needed. * Forces synchronous refresh if token has less than one poller interval remaining. + * Proactive refresh is handled by timers scheduled when tokens are cached. * * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry - * @param refreshThreshold - Seconds before expiration to trigger background refresh (default: 15s, minimum: 15s). - * Higher values trigger earlier background refresh but may cause excessive requests and trip rate limiting. - * @returns Result with entry and refresh flag, or undefined if token is missing/expired/too close to expiration + * @returns Result with entry, or undefined if token is missing/expired/too close to expiration */ - get(cacheKeyJSON: TokenCacheKeyJSON, refreshThreshold?: number): TokenCacheGetResult | undefined; + get(cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined; /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. @@ -196,14 +201,14 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } + if (value.refreshTimeoutId !== undefined) { + clearTimeout(value.refreshTimeoutId); + } }); cache.clear(); }; - const get = ( - cacheKeyJSON: TokenCacheKeyJSON, - refreshThreshold = BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, - ): TokenCacheGetResult | undefined => { + const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined => { ensureBroadcastChannel(); const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); @@ -223,19 +228,15 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } + if (value.refreshTimeoutId !== undefined) { + clearTimeout(value.refreshTimeoutId); + } cache.delete(cacheKey.toKey()); return; } - // Ensure threshold is at least the poller interval (values below this have no effect - // since tokens with less than POLLER_INTERVAL remaining force a synchronous refresh) - const effectiveThreshold = Math.max(refreshThreshold, POLLER_INTERVAL_IN_MS / 1000); - - // Token is valid but expiring soon - signal that background refresh is needed - const needsRefresh = remainingTtl < effectiveThreshold; - - // Return the valid token immediately, caller decides whether to refresh - return { entry: value.entry, needsRefresh }; + // Proactive refresh is handled by timers scheduled in setInternal() + return { entry: value.entry }; }; /** @@ -359,6 +360,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (cachedValue.timeoutId !== undefined) { clearTimeout(cachedValue.timeoutId); } + if (cachedValue.refreshTimeoutId !== undefined) { + clearTimeout(cachedValue.refreshTimeoutId); + } cache.delete(key); } }; @@ -389,6 +393,23 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { (timeoutId as any).unref(); } + // Schedule proactive refresh timer to fire before token enters leeway period + // This ensures new tokens are ready before the old one expires + const refreshLeadTime = 2; // Fire 2s before leeway starts + const refreshFireTime = expiresIn - BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS - refreshLeadTime; + + if (refreshFireTime > 0 && entry.onRefresh) { + const refreshTimeoutId = setTimeout(() => { + entry.onRefresh?.(); + }, refreshFireTime * 1000); + + value.refreshTimeoutId = refreshTimeoutId; + + if (typeof (refreshTimeoutId as any).unref === 'function') { + (refreshTimeoutId as any).unref(); + } + } + const channel = broadcastChannel; if (channel && options.broadcast) { const tokenRaw = newToken.getRawString(); diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index fdb074ad681..597611d0380 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -339,27 +339,7 @@ export interface SessionTask { } export type GetTokenOptions = { - /** - * Seconds before token expiration to trigger background refresh. - * When a token's remaining TTL falls below this threshold, `getToken()` returns the - * cached token immediately while triggering a background refresh. - * - * - Minimum value: 5 seconds (the poller interval) - * - Default value: 15 seconds - * - * Lower values delay background refresh until closer to expiration. - * Higher values trigger earlier background refresh, which can reduce latency for - * time-sensitive operations but may cause more frequent token refresh requests. - */ - backgroundRefreshThreshold?: number; organizationId?: string; - /** - * @internal - * When true, triggers a background token refresh if the cached token is within the - * refresh threshold period, while still returning the current valid token immediately. - * Used by the session poller to proactively refresh tokens before they expire. - */ - refreshIfStale?: boolean; skipCache?: boolean; template?: string; }; From e426411d160588ddd3b53dc289c62c15f3f95e79 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 26 Jan 2026 20:12:47 -0600 Subject: [PATCH 18/25] fix: remove unused variable in Session tests --- packages/clerk-js/src/core/resources/__tests__/Session.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index e838ed0bf63..28f904abe53 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -424,7 +424,8 @@ describe('Session', () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; - const session = new Session({ + // Create session with last_active_token to trigger cache hydration and timer scheduling + new Session({ status: 'active', id: 'session_1', object: 'session', From 8c5c6e5c0d31712ffa55f37e290678f87097ac50 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 27 Jan 2026 10:09:20 -0600 Subject: [PATCH 19/25] chore: update changeset for proactive token refresh --- .changeset/fresh-tigers-hunt.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/fresh-tigers-hunt.md b/.changeset/fresh-tigers-hunt.md index 0e799b98cc4..21f3e91c8b1 100644 --- a/.changeset/fresh-tigers-hunt.md +++ b/.changeset/fresh-tigers-hunt.md @@ -3,4 +3,6 @@ '@clerk/shared': minor --- -Add stale-while-revalidate support for session tokens +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. From 9ce6dd35b680b66c2944075ff0d10e1dccafd6fa Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 27 Jan 2026 10:21:09 -0600 Subject: [PATCH 20/25] feat(clerk-js): Make leewayInSeconds configurable for proactive refresh --- .changeset/fresh-tigers-hunt.md | 5 +---- packages/clerk-js/src/core/resources/Session.ts | 14 ++++++++++---- packages/clerk-js/src/core/tokenCache.ts | 9 ++++++++- packages/shared/src/types/session.ts | 6 ++++++ 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.changeset/fresh-tigers-hunt.md b/.changeset/fresh-tigers-hunt.md index 21f3e91c8b1..f684c62e178 100644 --- a/.changeset/fresh-tigers-hunt.md +++ b/.changeset/fresh-tigers-hunt.md @@ -1,8 +1,5 @@ --- '@clerk/clerk-js': minor -'@clerk/shared': minor --- -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. +Add proactive session token refresh. Tokens are now automatically refreshed in the background before they expire. The `leewayInSeconds` option controls how far in advance refresh is triggered (default: 15 seconds, minimum: 5 seconds). diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 8bc9adb3221..519cf47a32e 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -361,7 +361,7 @@ export class Session extends BaseResource implements SessionResource { return null; } - const { skipCache = false, template } = options || {}; + const { leewayInSeconds, skipCache = false, template } = options || {}; // If no organization ID is provided, default to the selected organization in memory // Note: this explicitly allows passing `null` or `""`, which should select the personal workspace. @@ -386,7 +386,7 @@ export class Session extends BaseResource implements SessionResource { return cachedToken.getRawString() || null; } - return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); + return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache, leewayInSeconds); } #createTokenResolver( @@ -426,14 +426,17 @@ export class Session extends BaseResource implements SessionResource { tokenId: string, shouldDispatchTokenUpdate: boolean, skipCache: boolean, + leewayInSeconds?: number, ): Promise { debugLogger.info('Fetching new token from API', { organizationId, template, tokenId }, 'session'); const tokenResolver = this.#createTokenResolver(template, organizationId, skipCache); SessionTokenCache.set({ + leewayInSeconds, tokenId, tokenResolver, - onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + onRefresh: () => + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate, leewayInSeconds), }); return tokenResolver.then(token => { @@ -455,6 +458,7 @@ export class Session extends BaseResource implements SessionResource { organizationId: string | undefined | null, tokenId: string, shouldDispatchTokenUpdate: boolean, + leewayInSeconds?: number, ): void { // Prevent multiple concurrent background refreshes for the same token if (Session.#backgroundRefreshInProgress.has(tokenId)) { @@ -471,9 +475,11 @@ export class Session extends BaseResource implements SessionResource { .then(token => { // Cache the resolved token for future calls SessionTokenCache.set({ + leewayInSeconds, tokenId, tokenResolver: Promise.resolve(token), - onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + onRefresh: () => + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate, leewayInSeconds), }); this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); }) diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 976fba0dcf6..e2e4d403733 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -24,6 +24,11 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { * Used for expiration and cleanup scheduling. */ createdAt?: Seconds; + /** + * Seconds before token expiration when background refresh should be triggered. + * Defaults to BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS if not provided. + */ + leewayInSeconds?: Seconds; /** * Callback to refresh this token before it expires. * Called by the proactive refresh timer to trigger background refresh. @@ -396,7 +401,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { // Schedule proactive refresh timer to fire before token enters leeway period // This ensures new tokens are ready before the old one expires const refreshLeadTime = 2; // Fire 2s before leeway starts - const refreshFireTime = expiresIn - BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS - refreshLeadTime; + const minLeeway = POLLER_INTERVAL_IN_MS / 1000; // Minimum is poller interval (5s) + const leeway = Math.max(entry.leewayInSeconds ?? BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, minLeeway); + const refreshFireTime = expiresIn - leeway - refreshLeadTime; if (refreshFireTime > 0 && entry.onRefresh) { const refreshTimeoutId = setTimeout(() => { diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 597611d0380..4834e666465 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -339,6 +339,12 @@ export interface SessionTask { } export type GetTokenOptions = { + /** + * The number of seconds before token expiration when background refresh should be triggered. + * Must be at least 5 seconds (the poller interval). + * @default 15 + */ + leewayInSeconds?: number; organizationId?: string; skipCache?: boolean; template?: string; From 5d43ab38618dbaa6d25130edcc2e48ee5ad1b141 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 27 Jan 2026 10:25:30 -0600 Subject: [PATCH 21/25] chore: format --- packages/clerk-js/src/core/resources/Session.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 519cf47a32e..1b560cb2bfc 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -479,7 +479,13 @@ export class Session extends BaseResource implements SessionResource { tokenId, tokenResolver: Promise.resolve(token), onRefresh: () => - this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate, leewayInSeconds), + this.#refreshTokenInBackground( + template, + organizationId, + tokenId, + shouldDispatchTokenUpdate, + leewayInSeconds, + ), }); this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); }) From d3fa022f38d8ea3e50d79e27c086160a618c0ef5 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 28 Jan 2026 10:35:54 -0600 Subject: [PATCH 22/25] refactor(clerk-js): Remove leewayInSeconds from public API - 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 --- .changeset/fresh-tigers-hunt.md | 4 +- .../clerk-js/src/core/resources/Session.ts | 19 ++----- packages/clerk-js/src/core/tokenCache.ts | 11 ++-- packages/shared/src/getToken.ts | 1 - packages/shared/src/types/session.ts | 6 --- .../core-3/changes/gettoken-leeway-minimum.md | 54 ------------------- .../gettoken-stale-while-revalidate.md | 13 +---- 7 files changed, 13 insertions(+), 95 deletions(-) delete mode 100644 packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md diff --git a/.changeset/fresh-tigers-hunt.md b/.changeset/fresh-tigers-hunt.md index f684c62e178..0013de7acf6 100644 --- a/.changeset/fresh-tigers-hunt.md +++ b/.changeset/fresh-tigers-hunt.md @@ -1,5 +1,5 @@ --- -'@clerk/clerk-js': minor +'@clerk/clerk-js': major --- -Add proactive session token refresh. Tokens are now automatically refreshed in the background before they expire. The `leewayInSeconds` option controls how far in advance refresh is triggered (default: 15 seconds, minimum: 5 seconds). +Add proactive session token refresh. Tokens are now automatically refreshed in the background before they expire, reducing latency for API calls near token expiration. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 1b560cb2bfc..6fe24678821 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -361,7 +361,7 @@ export class Session extends BaseResource implements SessionResource { return null; } - const { leewayInSeconds, skipCache = false, template } = options || {}; + const { skipCache = false, template } = options || {}; // If no organization ID is provided, default to the selected organization in memory // Note: this explicitly allows passing `null` or `""`, which should select the personal workspace. @@ -386,7 +386,7 @@ export class Session extends BaseResource implements SessionResource { return cachedToken.getRawString() || null; } - return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache, leewayInSeconds); + return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); } #createTokenResolver( @@ -426,17 +426,15 @@ export class Session extends BaseResource implements SessionResource { tokenId: string, shouldDispatchTokenUpdate: boolean, skipCache: boolean, - leewayInSeconds?: number, ): Promise { debugLogger.info('Fetching new token from API', { organizationId, template, tokenId }, 'session'); const tokenResolver = this.#createTokenResolver(template, organizationId, skipCache); SessionTokenCache.set({ - leewayInSeconds, tokenId, tokenResolver, onRefresh: () => - this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate, leewayInSeconds), + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), }); return tokenResolver.then(token => { @@ -458,7 +456,6 @@ export class Session extends BaseResource implements SessionResource { organizationId: string | undefined | null, tokenId: string, shouldDispatchTokenUpdate: boolean, - leewayInSeconds?: number, ): void { // Prevent multiple concurrent background refreshes for the same token if (Session.#backgroundRefreshInProgress.has(tokenId)) { @@ -474,18 +471,12 @@ export class Session extends BaseResource implements SessionResource { tokenResolver .then(token => { // Cache the resolved token for future calls + // Re-register onRefresh to handle the next refresh cycle when this token approaches expiration SessionTokenCache.set({ - leewayInSeconds, tokenId, tokenResolver: Promise.resolve(token), onRefresh: () => - this.#refreshTokenInBackground( - template, - organizationId, - tokenId, - shouldDispatchTokenUpdate, - leewayInSeconds, - ), + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), }); this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); }) diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index e2e4d403733..98bfaa25fae 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -24,11 +24,6 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { * Used for expiration and cleanup scheduling. */ createdAt?: Seconds; - /** - * Seconds before token expiration when background refresh should be triggered. - * Defaults to BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS if not provided. - */ - leewayInSeconds?: Seconds; /** * Callback to refresh this token before it expires. * Called by the proactive refresh timer to trigger background refresh. @@ -400,9 +395,11 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { // Schedule proactive refresh timer to fire before token enters leeway period // This ensures new tokens are ready before the old one expires - const refreshLeadTime = 2; // Fire 2s before leeway starts + // refreshLeadTime: 2s buffer before leeway starts. Token fetches typically complete in ~100ms, + // so 2s provides ample margin for the refresh to complete before the token enters the leeway period. + const refreshLeadTime = 2; const minLeeway = POLLER_INTERVAL_IN_MS / 1000; // Minimum is poller interval (5s) - const leeway = Math.max(entry.leewayInSeconds ?? BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, minLeeway); + const leeway = Math.max(BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, minLeeway); const refreshFireTime = expiresIn - leeway - refreshLeadTime; if (refreshFireTime > 0 && entry.onRefresh) { diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts index b181df2ec74..14c98f834cd 100644 --- a/packages/shared/src/getToken.ts +++ b/packages/shared/src/getToken.ts @@ -96,7 +96,6 @@ async function waitForClerk(): Promise { * @param options - Optional configuration for token retrieval * @param options.template - The name of a JWT template to use * @param options.organizationId - Organization ID to include in the token - * @param options.leewayInSeconds - Number of seconds of leeway for token expiration * @param options.skipCache - Whether to skip the token cache * @returns A Promise that resolves to the session token, or `null` if the user is not signed in * diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 4834e666465..597611d0380 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -339,12 +339,6 @@ export interface SessionTask { } export type GetTokenOptions = { - /** - * The number of seconds before token expiration when background refresh should be triggered. - * Must be at least 5 seconds (the poller interval). - * @default 15 - */ - leewayInSeconds?: number; organizationId?: string; skipCache?: boolean; template?: string; diff --git a/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md b/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md deleted file mode 100644 index 3cb28cb4830..00000000000 --- a/packages/upgrade/src/versions/core-3/changes/gettoken-leeway-minimum.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: '`getToken` `leewayInSeconds` renamed to `backgroundRefreshThreshold`' -matcher: - - 'leewayInSeconds' - - 'backgroundRefreshThreshold' - - 'getToken' -category: 'behavior-change' -warning: true ---- - -The `leewayInSeconds` option in `session.getToken()` has been renamed to `backgroundRefreshThreshold` for clarity. This option controls when background token refresh is triggered before expiration. - -### What changed? - -1. **Renamed option**: `leewayInSeconds` → `backgroundRefreshThreshold` -2. **Lower minimum**: The minimum value is now 5 seconds (the poller interval) instead of 15 seconds -3. **Clearer semantics**: The new name better describes what the option does - -### Migration - -```js -// Before -const token = await session.getToken({ leewayInSeconds: 30 }); - -// After -const token = await session.getToken({ backgroundRefreshThreshold: 30 }); -``` - -### How it works - -When a token's remaining TTL falls below the `backgroundRefreshThreshold`, `getToken()` returns the cached token immediately while triggering a background refresh. - -- **Minimum value**: 5 seconds (the poller interval) -- **Default value**: 15 seconds - -``` -Token TTL Timeline -──────────────────────────────────────────────────────► - expires - -│←── Fresh zone ──→│←── Background refresh ──→│←─ Sync ─→│ - (no refresh) (SWR: return + refresh) (force) - - > threshold 5s - threshold < 5s -``` - -### Rate Limiting Warning - -Setting `backgroundRefreshThreshold` higher than the default triggers earlier background refresh. While this can reduce latency for time-sensitive operations, values that are too high may cause excessive token refresh requests and potentially trigger rate limiting. - -```js -// Use with caution - triggers refresh 30s before expiration -const token = await session.getToken({ backgroundRefreshThreshold: 30 }); -``` diff --git a/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md b/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md index f105b671f66..fd1171d55a5 100644 --- a/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md +++ b/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md @@ -1,5 +1,5 @@ --- -title: '`getToken` now uses stale-while-revalidate pattern' +title: '`getToken` now uses proactive background refresh' matcher: - 'getToken' - 'session\.getToken' @@ -11,7 +11,7 @@ warning: true ### How it works -1. When a token is within 15 seconds of expiration (configurable via `backgroundRefreshThreshold`), `getToken()` returns the valid cached token immediately +1. When a token is within 15 seconds of expiration, `getToken()` returns the valid cached token immediately 2. A background refresh is triggered automatically to fetch a fresh token 3. Subsequent calls receive the new token once the background refresh completes @@ -37,12 +37,3 @@ const token = await session.getToken(); ### Compatibility This is a transparent improvement - no code changes are required. Your existing `getToken()` calls benefit automatically. - -### Customizing refresh timing - -Use `backgroundRefreshThreshold` to customize when background refresh is triggered (minimum 5 seconds, default 15 seconds): - -```js -// Start background refresh 30 seconds before expiration -const token = await session.getToken({ backgroundRefreshThreshold: 30 }); -``` From 7430053e8afb8e8471d4f65e133fa5b98504dd3c Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 28 Jan 2026 10:39:24 -0600 Subject: [PATCH 23/25] feat(upgrade): Add codemod to remove leewayInSeconds from getToken calls --- ...nsform-remove-deprecated-props.fixtures.js | 49 +++++++++++++++++ packages/upgrade/src/codemods/index.js | 8 ++- .../transform-remove-deprecated-props.cjs | 53 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-props.fixtures.js b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-props.fixtures.js index 3eaab4af5af..cea05fd0202 100644 --- a/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-props.fixtures.js +++ b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-props.fixtures.js @@ -208,4 +208,53 @@ export const Provider = ({ children }) => ( ); `, }, + { + name: 'getToken leewayInSeconds removal - direct call', + source: ` +const token = await getToken({ leewayInSeconds: 30 }); + `, + output: ` +const token = await getToken(); + `, + }, + { + name: 'getToken leewayInSeconds removal - member expression', + source: ` +const token = await session.getToken({ leewayInSeconds: 30, template: 'custom' }); + `, + output: ` +const token = await session.getToken({ + template: 'custom' +}); + `, + }, + { + name: 'getToken leewayInSeconds removal - optional chaining', + source: ` +const token = await session?.getToken({ leewayInSeconds: 15, organizationId: 'org_123' }); + `, + output: ` +const token = await session?.getToken({ + organizationId: 'org_123' +}); + `, + }, + { + name: 'getToken leewayInSeconds removal - multiple options', + source: ` +const token = await getToken({ + template: 'my-template', + leewayInSeconds: 20, + organizationId: 'org_abc', + skipCache: true +}); + `, + output: ` +const token = await getToken({ + template: 'my-template', + organizationId: 'org_abc', + skipCache: true +}); + `, + }, ]; diff --git a/packages/upgrade/src/codemods/index.js b/packages/upgrade/src/codemods/index.js index 36be9a1b7a1..ac455f89ac2 100644 --- a/packages/upgrade/src/codemods/index.js +++ b/packages/upgrade/src/codemods/index.js @@ -91,8 +91,9 @@ function renderDeprecatedPropsSummary(stats) { const userButtonCount = stats.userbuttonAfterSignOutPropsRemoved || 0; const hideSlugCount = stats.hideSlugRemoved || 0; const beforeEmitCount = stats.beforeEmitTransformed || 0; + const leewayCount = stats.leewayInSecondsRemoved || 0; - if (!userButtonCount && !hideSlugCount && !beforeEmitCount) { + if (!userButtonCount && !hideSlugCount && !beforeEmitCount && !leewayCount) { return; } @@ -116,5 +117,10 @@ function renderDeprecatedPropsSummary(stats) { console.log(chalk.gray(' The callback now receives an object with session property.')); } + if (leewayCount > 0) { + console.log(chalk.yellow(`• Removed ${leewayCount} leewayInSeconds option(s) from getToken() calls`)); + console.log(chalk.gray(' Tokens are now automatically refreshed in the background with a fixed threshold.')); + } + console.log(''); } diff --git a/packages/upgrade/src/codemods/transform-remove-deprecated-props.cjs b/packages/upgrade/src/codemods/transform-remove-deprecated-props.cjs index 30909cdcf9f..1bfe6b08a8a 100644 --- a/packages/upgrade/src/codemods/transform-remove-deprecated-props.cjs +++ b/packages/upgrade/src/codemods/transform-remove-deprecated-props.cjs @@ -109,6 +109,10 @@ module.exports = function transformDeprecatedProps({ source }, { jscodeshift: j, dirty = true; } + if (removeGetTokenLeewayInSeconds(root, j, stats)) { + dirty = true; + } + return dirty ? root.toSource() : undefined; }; @@ -501,3 +505,52 @@ function renameEntityName(entity, oldName, newName) { return false; } + +/** + * Removes leewayInSeconds from getToken() calls. + * The leewayInSeconds option has been removed from the public API in favor of + * automatic background token refresh with a fixed threshold. + */ +function removeGetTokenLeewayInSeconds(root, j, stats) { + let changed = false; + + root + .find(j.CallExpression) + .filter(path => isGetTokenCall(path.node.callee)) + .forEach(path => { + const [args0] = path.node.arguments; + if (!args0 || args0.type !== 'ObjectExpression') { + return; + } + + const leewayIndex = args0.properties.findIndex(prop => isPropertyNamed(prop, 'leewayInSeconds')); + if (leewayIndex === -1) { + return; + } + + args0.properties.splice(leewayIndex, 1); + changed = true; + stats('leewayInSecondsRemoved'); + + // If the object is now empty, remove the entire argument + if (args0.properties.length === 0) { + path.node.arguments = []; + } + }); + + return changed; +} + +function isGetTokenCall(callee) { + if (!callee) { + return false; + } + if (callee.type === 'Identifier') { + return callee.name === 'getToken'; + } + if (callee.type === 'MemberExpression' || callee.type === 'OptionalMemberExpression') { + const property = callee.property; + return property && property.type === 'Identifier' && property.name === 'getToken'; + } + return false; +} From 6c3a5bf0aa22b2e60f2bb3fae5f55146d08dc861 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 28 Jan 2026 21:03:00 -0600 Subject: [PATCH 24/25] format --- packages/clerk-js/src/core/resources/Session.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 6fe24678821..842230da8f5 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -433,8 +433,7 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver, - onRefresh: () => - this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), }); return tokenResolver.then(token => { @@ -475,8 +474,7 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token), - onRefresh: () => - this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), }); this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); }) From 6da6883f98ddfcec439e7c40fa4c40205bfe583a Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 29 Jan 2026 19:48:46 -0600 Subject: [PATCH 25/25] chore: bump legacy browser bundle limit to 106KB --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 4f78f5c0554..3bd20ec6fee 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -3,7 +3,7 @@ { "path": "./dist/clerk.js", "maxSize": "538KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "105KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "106KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "305KB" }, { "path": "./dist/clerk.native.js", "maxSize": "65KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" },