From b5f683de5f421697a4da91befce57093727a7a36 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 21:45:07 -0600 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 }); ```