diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts
index 5c87b72647c..3b1d3992175 100644
--- a/integration/presets/envs.ts
+++ b/integration/presets/envs.ts
@@ -112,6 +112,11 @@ const withEmailCodes_destroy_client = withEmailCodes
.clone()
.setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false');
+const withEmailCodes_swr = withEmailCodes
+ .clone()
+ .setId('withEmailCodes_swr')
+ .setEnvVariable('public', 'EXPERIMENTAL_SWR', 'true');
+
const withSharedUIVariant = withEmailCodes
.clone()
.setId('withSharedUIVariant')
@@ -257,6 +262,7 @@ export const envs = {
withDynamicKeys,
withEmailCodes,
withEmailCodes_destroy_client,
+ withEmailCodes_swr,
withEmailCodesProxy,
withEmailCodesQuickstart,
withEmailLinks,
diff --git a/integration/templates/react-vite/src/main.tsx b/integration/templates/react-vite/src/main.tsx
index b882cf75c81..fc88267813b 100644
--- a/integration/templates/react-vite/src/main.tsx
+++ b/integration/templates/react-vite/src/main.tsx
@@ -43,6 +43,7 @@ const Root = () => {
persistClient: import.meta.env.VITE_EXPERIMENTAL_PERSIST_CLIENT
? import.meta.env.VITE_EXPERIMENTAL_PERSIST_CLIENT === 'true'
: undefined,
+ swr: import.meta.env.VITE_EXPERIMENTAL_SWR ? import.meta.env.VITE_EXPERIMENTAL_SWR === 'true' : undefined,
}}
>
diff --git a/integration/tests/swr.test.ts b/integration/tests/swr.test.ts
new file mode 100644
index 00000000000..754c388d87a
--- /dev/null
+++ b/integration/tests/swr.test.ts
@@ -0,0 +1,90 @@
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import { appConfigs } from '../presets';
+import type { FakeUser } from '../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../testUtils';
+
+// SafeLocalStorage prepends '__clerk_' to all keys
+const SWR_CACHE_KEY_PREFIX = '__clerk_swr_client_';
+
+async function hasSWRCache(page: Page): Promise {
+ return page.evaluate((prefix: string) => {
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(prefix)) {
+ return true;
+ }
+ }
+ return false;
+ }, SWR_CACHE_KEY_PREFIX);
+}
+
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes_swr] })('swr initialization @generic', ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser();
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ await app.teardown();
+ });
+
+ test('signed-in user loads from cache on second visit (page reload)', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ // First visit: sign in normally
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ expect(await hasSWRCache(page)).toBe(true);
+
+ // Second visit: should load from cache
+ await page.reload();
+ await u.po.clerk.toBeLoaded();
+ await u.po.expect.toBeSignedIn();
+ });
+
+ test('cache is cleared on sign-out', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ expect(await hasSWRCache(page)).toBe(true);
+
+ await page.evaluate(async () => {
+ await window.Clerk.signOut();
+ });
+ await u.po.expect.toBeSignedOut();
+
+ expect(await hasSWRCache(page)).toBe(false);
+ });
+
+ test('revoked session falls back to normal flow', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ const sessionId = await page.evaluate(() => window.Clerk?.session?.id);
+ expect(sessionId).toBeTruthy();
+ expect(await hasSWRCache(page)).toBe(true);
+
+ // Revoke session server-side, then reload
+ await u.services.clerk.sessions.revokeSession(sessionId!);
+ await page.reload();
+ await u.po.clerk.toBeLoaded();
+
+ await u.po.expect.toBeSignedOut();
+ });
+});
diff --git a/packages/clerk-js/src/core/__tests__/clerk.swr.test.ts b/packages/clerk-js/src/core/__tests__/clerk.swr.test.ts
new file mode 100644
index 00000000000..a7fb66fd6c8
--- /dev/null
+++ b/packages/clerk-js/src/core/__tests__/clerk.swr.test.ts
@@ -0,0 +1,507 @@
+import type { ClientJSONSnapshot } from '@clerk/shared/types';
+import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { DevBrowser } from '../auth/devBrowser';
+import { Clerk } from '../clerk';
+import { eventBus, events } from '../events';
+import type { DisplayConfig } from '../resources/internal';
+import { Client, Environment } from '../resources/internal';
+
+// --- Module mocks ---
+
+const mockClientFetch = vi.fn();
+const mockEnvironmentFetch = vi.fn(() => Promise.resolve({}));
+
+vi.mock('../resources/Client');
+vi.mock('../resources/Environment');
+
+vi.mock('../auth/devBrowser', () => ({
+ createDevBrowser: (): DevBrowser => ({
+ clear: vi.fn(),
+ setup: vi.fn(),
+ getDevBrowser: vi.fn(() => 'deadbeef'),
+ setDevBrowser: vi.fn(),
+ removeDevBrowser: vi.fn(),
+ refreshCookies: vi.fn(),
+ }),
+}));
+
+// Mock SWRClientCache and isTokenExpiringSoon
+const mockSWRRead = vi.fn<() => ClientJSONSnapshot | null>().mockReturnValue(null);
+const mockSWRSave = vi.fn();
+const mockSWRClear = vi.fn();
+const mockIsTokenExpiringSoon = vi.fn().mockReturnValue(true);
+
+vi.mock('../swr-client-cache', () => ({
+ SWRClientCache: {
+ read: (...args: unknown[]) => mockSWRRead(...(args as [])),
+ save: (...args: unknown[]) => mockSWRSave(...(args as [])),
+ clear: (...args: unknown[]) => mockSWRClear(...(args as [])),
+ },
+ isTokenExpiringSoon: (...args: unknown[]) => mockIsTokenExpiringSoon(...(args as [])),
+}));
+
+Client.getOrCreateInstance = vi.fn().mockImplementation(() => {
+ return { fetch: mockClientFetch };
+});
+(Client as any).clearInstance = vi.fn();
+
+Environment.getInstance = vi.fn().mockImplementation(() => {
+ return { fetch: mockEnvironmentFetch };
+});
+
+// --- Helpers ---
+
+const productionPublishableKey = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k';
+
+const mockNavigate = vi.fn((to: string) => Promise.resolve(to));
+const mockedLoadOptions = { routerPush: mockNavigate, routerReplace: mockNavigate };
+
+const mockDisplayConfig = {
+ signInUrl: 'http://test.host/sign-in',
+ signUpUrl: 'http://test.host/sign-up',
+ userProfileUrl: 'http://test.host/user-profile',
+ homeUrl: 'http://test.host/home',
+ createOrganizationUrl: 'http://test.host/create-organization',
+ organizationProfileUrl: 'http://test.host/organization-profile',
+} as DisplayConfig;
+
+const mockUserSettings = {
+ signUp: {
+ captcha_enabled: false,
+ },
+};
+
+function makeMockSession(overrides: Record = {}) {
+ return {
+ id: 'sess_123',
+ status: 'active',
+ user: { id: 'user_123', first_name: 'Alice' },
+ remove: vi.fn(),
+ touch: vi.fn(() => Promise.resolve()),
+ __internal_touch: vi.fn(() => Promise.resolve()),
+ getToken: vi.fn(),
+ lastActiveToken: { getRawString: () => 'mocked-token' },
+ ...overrides,
+ };
+}
+
+function makeCachedSnapshot(sessions?: ClientJSONSnapshot['sessions']): ClientJSONSnapshot {
+ return {
+ object: 'client' as const,
+ id: 'client_cached',
+ sessions: sessions ?? [
+ {
+ object: 'session' as const,
+ id: 'sess_123',
+ status: 'active',
+ user: { object: 'user' as const, id: 'user_123', first_name: 'Alice' } as any,
+ last_active_token: null,
+ last_active_organization_id: null,
+ } as any,
+ ],
+ sign_in: null as any,
+ sign_up: null as any,
+ last_active_session_id: 'sess_123',
+ created_at: Date.now(),
+ updated_at: Date.now(),
+ } as ClientJSONSnapshot;
+}
+
+// --- Test suite ---
+
+const oldWindowLocation = window.location;
+
+describe('Clerk SWR initialization', () => {
+ let mockWindowLocation: any;
+ let mockHref: ReturnType;
+
+ afterAll(() => {
+ Object.defineProperty(global.window, 'location', {
+ value: oldWindowLocation,
+ });
+ });
+
+ beforeEach(() => {
+ mockHref = vi.fn();
+ mockWindowLocation = {
+ host: 'test.host',
+ hostname: 'test.host',
+ origin: 'http://test.host',
+ get href() {
+ return 'http://test.host';
+ },
+ set href(v: string) {
+ mockHref(v);
+ },
+ };
+
+ Object.defineProperty(global.window, 'location', { value: mockWindowLocation });
+
+ if (typeof globalThis.document !== 'undefined') {
+ Object.defineProperty(global.window.document, 'hasFocus', { value: () => true, configurable: true });
+ }
+
+ Object.defineProperty(global.window, 'addEventListener', {
+ value: vi.fn(),
+ });
+
+ mockEnvironmentFetch.mockReturnValue(
+ Promise.resolve({
+ userSettings: mockUserSettings,
+ displayConfig: mockDisplayConfig,
+ isSingleSession: () => false,
+ isProduction: () => true,
+ isDevelopmentOrStaging: () => false,
+ }),
+ );
+
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [],
+ }),
+ );
+
+ mockSWRRead.mockReturnValue(null);
+ mockSWRSave.mockReset();
+ mockSWRClear.mockReset();
+ mockIsTokenExpiringSoon.mockReturnValue(true);
+
+ eventBus.off(events.TokenUpdate);
+ });
+
+ afterEach(() => {
+ mockNavigate.mockReset();
+ vi.mocked(Client.getOrCreateInstance).mockClear();
+ (Client as any).clearInstance.mockClear();
+ });
+
+ // Test 1: SWR disabled (default) - normal flow, no cache read
+ it('does not read SWR cache when swr is disabled (default)', async () => {
+ mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [] }));
+
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load(mockedLoadOptions);
+
+ expect(mockSWRRead).not.toHaveBeenCalled();
+ });
+
+ // Test 2: SWR enabled, no cache - normal flow
+ it('proceeds with normal flow when SWR enabled but no cache exists', async () => {
+ mockSWRRead.mockReturnValue(null);
+ // The normal flow resolves to a client object. The SWR listener calls __internal_toSnapshot,
+ // so the mock must provide it.
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [],
+ __internal_toSnapshot: vi.fn(() => ({ object: 'client', id: 'client_1', sessions: [] })),
+ }),
+ );
+
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load({ ...mockedLoadOptions, experimental: { swr: true } });
+
+ expect(mockSWRRead).toHaveBeenCalledWith(productionPublishableKey);
+ // Normal flow: Client.getOrCreateInstance().fetch() should have been called
+ expect(mockClientFetch).toHaveBeenCalled();
+ });
+
+ // Test 3: SWR enabled, cache exists, cookie JWT valid - emit ready immediately
+ it('emits ready immediately when cache exists and cookie JWT is still valid', async () => {
+ const cachedSnapshot = makeCachedSnapshot();
+ mockSWRRead.mockReturnValue(cachedSnapshot);
+
+ // The cached client returned by Client.getOrCreateInstance(cachedSnapshot)
+ const mockSession = makeMockSession();
+ const mockCachedClient = {
+ signedInSessions: [mockSession],
+ sessions: [mockSession],
+ lastActiveSessionId: 'sess_123',
+ fetch: mockClientFetch,
+ __internal_toSnapshot: vi.fn(() => cachedSnapshot),
+ };
+
+ vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => {
+ if (data) {
+ // Called with cached snapshot
+ return mockCachedClient as any;
+ }
+ // Called for background refresh
+ return { fetch: mockClientFetch } as any;
+ });
+
+ // Token is still valid
+ mockIsTokenExpiringSoon.mockReturnValue(false);
+
+ // Background refresh resolves with updated client
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [mockSession],
+ __internal_toSnapshot: vi.fn(() => cachedSnapshot),
+ }),
+ );
+
+ const statusEvents: string[] = [];
+ const sut = new Clerk(productionPublishableKey);
+ sut.on('status', (status: string) => {
+ statusEvents.push(status);
+ });
+
+ await sut.load({ ...mockedLoadOptions, experimental: { swr: true } });
+
+ // Should have read the cache
+ expect(mockSWRRead).toHaveBeenCalledWith(productionPublishableKey);
+ // Token should not be expired
+ expect(mockIsTokenExpiringSoon).toHaveBeenCalled();
+ // Session should be set from cache
+ expect(sut.session?.id).toBe('sess_123');
+ // getToken should NOT have been called (token was still valid)
+ expect(mockSession.getToken).not.toHaveBeenCalled();
+ // Status should have been set to ready
+ expect(statusEvents).toContain('ready');
+ });
+
+ // Test 4: SWR enabled, cache exists, cookie JWT expired, getToken succeeds
+ it('emits ready after getToken succeeds when cookie JWT is expired', async () => {
+ const cachedSnapshot = makeCachedSnapshot();
+ mockSWRRead.mockReturnValue(cachedSnapshot);
+
+ const mockSession = makeMockSession({
+ getToken: vi.fn().mockResolvedValue({ getRawString: () => 'fresh-token' }),
+ });
+
+ const mockCachedClient = {
+ signedInSessions: [mockSession],
+ sessions: [mockSession],
+ lastActiveSessionId: 'sess_123',
+ fetch: mockClientFetch,
+ __internal_toSnapshot: vi.fn(() => cachedSnapshot),
+ };
+
+ vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => {
+ if (data) {
+ return mockCachedClient as any;
+ }
+ return { fetch: mockClientFetch } as any;
+ });
+
+ // Token is expired
+ mockIsTokenExpiringSoon.mockReturnValue(true);
+
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [mockSession],
+ __internal_toSnapshot: vi.fn(() => cachedSnapshot),
+ }),
+ );
+
+ const statusEvents: string[] = [];
+ const sut = new Clerk(productionPublishableKey);
+ sut.on('status', (status: string) => {
+ statusEvents.push(status);
+ });
+
+ await sut.load({ ...mockedLoadOptions, experimental: { swr: true } });
+
+ // getToken should have been called since the JWT was expired
+ expect(mockSession.getToken).toHaveBeenCalledWith({ skipCache: true });
+ // Session should be set from cache
+ expect(sut.session?.id).toBe('sess_123');
+ // Status should be ready
+ expect(statusEvents).toContain('ready');
+ });
+
+ // Test 5: SWR enabled, cache exists, cookie JWT expired, getToken 401 - discard cache, normal flow
+ it('discards cache and proceeds to normal flow when getToken returns 401', async () => {
+ const cachedSnapshot = makeCachedSnapshot();
+ mockSWRRead.mockReturnValue(cachedSnapshot);
+
+ // Create a 4xx error
+ const error401 = new Error('Unauthorized');
+ (error401 as any).status = 401;
+ // is4xxError checks for ClerkAPIResponseError with status 4xx
+ Object.defineProperty(error401, 'clerkError', { value: true });
+ (error401 as any).errors = [{ code: 'session_not_found' }];
+
+ const mockSession = makeMockSession({
+ getToken: vi.fn().mockRejectedValue(error401),
+ });
+
+ const mockCachedClient = {
+ signedInSessions: [mockSession],
+ sessions: [mockSession],
+ lastActiveSessionId: 'sess_123',
+ fetch: mockClientFetch,
+ __internal_toSnapshot: vi.fn(() => cachedSnapshot),
+ };
+
+ vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => {
+ if (data) {
+ return mockCachedClient as any;
+ }
+ return { fetch: mockClientFetch } as any;
+ });
+
+ // Token is expired
+ mockIsTokenExpiringSoon.mockReturnValue(true);
+
+ // Normal flow fetch returns a signed-out client
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [],
+ __internal_toSnapshot: vi.fn(() => ({ object: 'client', id: 'client_1', sessions: [] })),
+ }),
+ );
+
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load({ ...mockedLoadOptions, experimental: { swr: true } });
+
+ // Cache should have been cleared
+ expect(mockSWRClear).toHaveBeenCalledWith(productionPublishableKey);
+ // Client.clearInstance should have been called to discard cached client
+ expect((Client as any).clearInstance).toHaveBeenCalled();
+ // Normal flow: Client.getOrCreateInstance().fetch() runs
+ expect(mockClientFetch).toHaveBeenCalled();
+ });
+
+ // Test 6: SWR enabled, cache exists, cookie JWT expired, getToken network error - emit degraded
+ it('emits degraded with cached data when getToken has a transient network error', async () => {
+ const cachedSnapshot = makeCachedSnapshot();
+ mockSWRRead.mockReturnValue(cachedSnapshot);
+
+ // Network error (not a 4xx)
+ const networkError = new Error('Failed to fetch');
+
+ const mockSession = makeMockSession({
+ getToken: vi.fn().mockRejectedValue(networkError),
+ });
+
+ const mockCachedClient = {
+ signedInSessions: [mockSession],
+ sessions: [mockSession],
+ lastActiveSessionId: 'sess_123',
+ fetch: mockClientFetch,
+ __internal_toSnapshot: vi.fn(() => cachedSnapshot),
+ };
+
+ vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => {
+ if (data) {
+ return mockCachedClient as any;
+ }
+ return { fetch: mockClientFetch } as any;
+ });
+
+ // Token is expired
+ mockIsTokenExpiringSoon.mockReturnValue(true);
+
+ // Background refresh should also fail or never resolve in a network error scenario.
+ // Use a promise that never resolves to prevent the background refresh from
+ // overwriting the cached session.
+ mockClientFetch.mockReturnValue(new Promise(() => {}));
+
+ const statusEvents: string[] = [];
+ const sut = new Clerk(productionPublishableKey);
+ sut.on('status', (status: string) => {
+ statusEvents.push(status);
+ });
+
+ await sut.load({ ...mockedLoadOptions, experimental: { swr: true } });
+
+ // Session should still be set from cache (degraded mode)
+ expect(sut.session?.id).toBe('sess_123');
+ // Status should be degraded (transient error means token is unvalidated)
+ expect(statusEvents).toContain('degraded');
+ // Cache should NOT have been cleared (session might still be valid)
+ expect(mockSWRClear).not.toHaveBeenCalledWith(productionPublishableKey);
+ });
+
+ // Test 7: SWR enabled, corrupted cache - proceed to normal flow
+ it('proceeds to normal flow when cache is corrupted', async () => {
+ const corruptedSnapshot = makeCachedSnapshot();
+ mockSWRRead.mockReturnValue(corruptedSnapshot);
+
+ // Simulate corruption: only the first call with data (SWR cache) throws.
+ // Subsequent calls (from createClientFromJwt in the normal flow) succeed normally.
+ let thrownOnce = false;
+ vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => {
+ if (data && !thrownOnce) {
+ thrownOnce = true;
+ throw new Error('Failed to parse cached client data');
+ }
+ return { fetch: mockClientFetch } as any;
+ });
+
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [],
+ __internal_toSnapshot: vi.fn(() => ({ object: 'client', id: 'client_1', sessions: [] })),
+ }),
+ );
+
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load({ ...mockedLoadOptions, experimental: { swr: true } });
+
+ // Cache should be cleared on error
+ expect(mockSWRClear).toHaveBeenCalledWith(productionPublishableKey);
+ // Should fall through to normal flow
+ expect(mockClientFetch).toHaveBeenCalled();
+ });
+
+ // Test 8: Session fallback - cached session gone from fresh /client, falls back to defaultSession
+ it('falls back to defaultSession when cached session is gone from fresh client', async () => {
+ const cachedSnapshot = makeCachedSnapshot();
+ mockSWRRead.mockReturnValue(cachedSnapshot);
+
+ const mockSession = makeMockSession({ id: 'sess_123' });
+
+ const mockCachedClient = {
+ signedInSessions: [mockSession],
+ sessions: [mockSession],
+ lastActiveSessionId: 'sess_123',
+ fetch: mockClientFetch,
+ __internal_toSnapshot: vi.fn(() => cachedSnapshot),
+ };
+
+ vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => {
+ if (data) {
+ return mockCachedClient as any;
+ }
+ return { fetch: mockClientFetch } as any;
+ });
+
+ // Token is still valid
+ mockIsTokenExpiringSoon.mockReturnValue(false);
+
+ // Fresh /client returns a different session (original session is gone)
+ const freshSession = makeMockSession({ id: 'sess_456', user: { id: 'user_456', first_name: 'Bob' } });
+ const freshClient = {
+ signedInSessions: [freshSession],
+ sessions: [freshSession],
+ lastActiveSessionId: 'sess_456',
+ __internal_toSnapshot: vi.fn(() => ({
+ ...cachedSnapshot,
+ sessions: [{ ...cachedSnapshot.sessions[0], id: 'sess_456' }],
+ last_active_session_id: 'sess_456',
+ })),
+ };
+
+ mockClientFetch.mockReturnValue(Promise.resolve(freshClient));
+
+ const sut = new Clerk(productionPublishableKey);
+ await sut.load({ ...mockedLoadOptions, experimental: { swr: true } });
+
+ // Initially loaded with cached session
+ // After background refresh, session should be updated.
+ // The SWR background refresh detects the cached session is gone and
+ // falls back to defaultSession before calling updateClient.
+
+ // Wait for background refresh to complete
+ await vi.waitFor(() => {
+ expect(mockClientFetch).toHaveBeenCalled();
+ });
+
+ // The session should now be the fresh one (since the cached session was not in the fresh client,
+ // the SWR refresh falls back to defaultSession which picks the first signedInSession)
+ expect(sut.session?.id).toBe('sess_456');
+ });
+});
diff --git a/packages/clerk-js/src/core/__tests__/swr-client-cache.test.ts b/packages/clerk-js/src/core/__tests__/swr-client-cache.test.ts
new file mode 100644
index 00000000000..7f7df55c55a
--- /dev/null
+++ b/packages/clerk-js/src/core/__tests__/swr-client-cache.test.ts
@@ -0,0 +1,146 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { isTokenExpiringSoon, SWRClientCache } from '../swr-client-cache';
+
+// Mock SafeLocalStorage
+const mockStorage = new Map();
+vi.mock('../../utils/localStorage', () => ({
+ SafeLocalStorage: {
+ setItem: vi.fn((key: string, value: unknown, ttl?: number) => {
+ mockStorage.set(`__clerk_${key}`, JSON.stringify({ value, ...(ttl && { exp: Date.now() + ttl }) }));
+ }),
+ getItem: vi.fn((key: string, defaultValue: T) => {
+ const raw = mockStorage.get(`__clerk_${key}`);
+ if (!raw) return defaultValue;
+ const entry = JSON.parse(raw);
+ if (entry.exp && Date.now() > entry.exp) {
+ mockStorage.delete(`__clerk_${key}`);
+ return defaultValue;
+ }
+ return entry.value ?? defaultValue;
+ }),
+ removeItem: vi.fn((key: string) => {
+ mockStorage.delete(`__clerk_${key}`);
+ }),
+ },
+}));
+
+beforeEach(() => mockStorage.clear());
+
+describe('SWRClientCache', () => {
+ const publishableKey = 'pk_test_abc123';
+
+ describe('save', () => {
+ it('saves a client snapshot without lastActiveToken or signIn/signUp', () => {
+ const snapshot = {
+ object: 'client' as const,
+ id: 'client_123',
+ sessions: [
+ {
+ object: 'session' as const,
+ id: 'sess_123',
+ status: 'active',
+ last_active_token: { object: 'token', id: 'tok_1', jwt: 'eyJ...' },
+ user: { object: 'user', id: 'user_123', first_name: 'Alice' },
+ last_active_organization_id: 'org_123',
+ },
+ ],
+ sign_in: { object: 'sign_in', id: 'si_123', status: 'needs_second_factor' },
+ sign_up: { object: 'sign_up', id: 'su_123', status: 'missing_requirements' },
+ last_active_session_id: 'sess_123',
+ };
+
+ SWRClientCache.save(snapshot as any, publishableKey);
+
+ const saved = SWRClientCache.read(publishableKey);
+ expect(saved).not.toBeNull();
+ // JWT stripped
+ expect(saved!.sessions[0].last_active_token).toBeNull();
+ // signIn/signUp stripped
+ expect(saved!.sign_in).toBeNull();
+ expect(saved!.sign_up).toBeNull();
+ // Profile data preserved
+ expect(saved!.sessions[0].user.first_name).toBe('Alice');
+ });
+
+ it('does not save synthetic JWT-derived clients (id: client_init)', () => {
+ const snapshot = {
+ object: 'client' as const,
+ id: 'client_init',
+ sessions: [],
+ };
+
+ SWRClientCache.save(snapshot as any, publishableKey);
+ expect(SWRClientCache.read(publishableKey)).toBeNull();
+ });
+ });
+
+ describe('read', () => {
+ it('returns null when no cache exists', () => {
+ expect(SWRClientCache.read(publishableKey)).toBeNull();
+ });
+
+ it('scopes cache by publishable key', () => {
+ const snapshot = {
+ object: 'client',
+ id: 'client_1',
+ sessions: [{ object: 'session', id: 'sess_1', status: 'active', user: { object: 'user', id: 'user_1' } }],
+ sign_in: null,
+ sign_up: null,
+ last_active_session_id: 'sess_1',
+ };
+ SWRClientCache.save(snapshot as any, publishableKey);
+ expect(SWRClientCache.read('pk_test_other')).toBeNull();
+ expect(SWRClientCache.read(publishableKey)).not.toBeNull();
+ });
+ });
+
+ describe('clear', () => {
+ it('removes cached client', () => {
+ const snapshot = {
+ object: 'client',
+ id: 'client_1',
+ sessions: [{ object: 'session', id: 'sess_1', status: 'active', user: { object: 'user', id: 'user_1' } }],
+ sign_in: null,
+ sign_up: null,
+ last_active_session_id: 'sess_1',
+ };
+ SWRClientCache.save(snapshot as any, publishableKey);
+ SWRClientCache.clear(publishableKey);
+ expect(SWRClientCache.read(publishableKey)).toBeNull();
+ });
+ });
+});
+
+describe('isTokenExpiringSoon', () => {
+ function makeJwt(exp: number): string {
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
+ const payload = btoa(JSON.stringify({ exp }));
+ return `${header}.${payload}.fake-signature`;
+ }
+
+ it('returns false for a token with plenty of time left', () => {
+ const exp = Math.floor(Date.now() / 1000) + 60; // 60s from now
+ expect(isTokenExpiringSoon(makeJwt(exp))).toBe(false);
+ });
+
+ it('returns true for a token expiring within 5 seconds', () => {
+ const exp = Math.floor(Date.now() / 1000) + 3; // 3s from now
+ expect(isTokenExpiringSoon(makeJwt(exp))).toBe(true);
+ });
+
+ it('returns true for an already expired token', () => {
+ const exp = Math.floor(Date.now() / 1000) - 10; // 10s ago
+ expect(isTokenExpiringSoon(makeJwt(exp))).toBe(true);
+ });
+
+ it('returns true for an unparseable token', () => {
+ expect(isTokenExpiringSoon('not-a-jwt')).toBe(true);
+ expect(isTokenExpiringSoon('')).toBe(true);
+ });
+
+ it('returns true for a token at exactly the 5s boundary', () => {
+ const exp = Math.floor(Date.now() / 1000) + 5; // exactly 5s from now
+ expect(isTokenExpiringSoon(makeJwt(exp))).toBe(true);
+ });
+});
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 431b3023d0b..b93441977c8 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -180,6 +180,7 @@ import { createCheckoutInstance } from './modules/checkout/instance';
import { Protect } from './protect';
import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal';
import { State } from './state';
+import { isTokenExpiringSoon, SWRClientCache } from './swr-client-cache';
type SetActiveHook = (intent?: 'sign-out') => void | Promise;
@@ -2975,6 +2976,135 @@ export class Clerk implements ClerkInterface {
const isInAccountsHostedPages = isDevAccountPortalOrigin(window?.location.hostname);
const shouldTouchEnv = this.#instanceType === 'development' && !isInAccountsHostedPages;
+ // SWR: attempt to initialize from cached client data
+ const swrEnabled = this.#options.experimental?.swr;
+ let swrStatus: 'ready' | 'degraded' | null = null;
+
+ if (swrEnabled) {
+ const cachedSnapshot = SWRClientCache.read(this.#publishableKey);
+
+ if (cachedSnapshot && cachedSnapshot.sessions?.length) {
+ try {
+ // Clear any existing Client instance to ensure the cached snapshot is used.
+ // getOrCreateInstance silently ignores the data param if an instance already exists.
+ Client.clearInstance();
+ const cachedClient = Client.getOrCreateInstance(cachedSnapshot);
+ this.updateClient(cachedClient);
+
+ if (this.session) {
+ // Check if the existing __session cookie has a valid (non-expired) JWT.
+ // SSR apps always have a fresh token (middleware refresh sets it).
+ // CSR apps have a valid token if the user returned within ~60s.
+ const existingJwt = this.#authService?.getSessionCookie();
+ const tokenStillValid = existingJwt ? !isTokenExpiringSoon(existingJwt) : false;
+
+ if (tokenStillValid) {
+ // Token is still valid - use cached client as-is, zero network wait.
+ // The poller will refresh the token before it expires.
+ swrStatus = 'ready';
+ } else {
+ // Token expired or missing - call getToken to validate session and get fresh token.
+ // This is edge-routed and fast (~50-100ms).
+ const tokenResult = await this.session.getToken({ skipCache: true }).catch((err: unknown) => {
+ // Distinguish auth errors (session dead) from transient errors
+ if (is4xxError(err)) {
+ return null; // session revoked
+ }
+ // Transient error: session may still be alive, proceed as degraded
+ return 'transient_error' as const;
+ });
+
+ if (tokenResult === null) {
+ // Session revoked: discard cache, clear client, proceed to normal flow
+ SWRClientCache.clear(this.#publishableKey);
+ Client.clearInstance();
+ this.client = undefined;
+ this.#updateAccessors(undefined);
+ } else if (tokenResult === 'transient_error') {
+ // Network/server issue, token unvalidated. Emit degraded with cached data.
+ swrStatus = 'degraded';
+ } else {
+ // Token is fresh, session is alive. SWR success.
+ swrStatus = 'ready';
+ }
+ }
+ }
+ } catch {
+ // Cache corrupted or other error, proceed to normal flow
+ SWRClientCache.clear(this.#publishableKey);
+ }
+ }
+ }
+
+ if (swrStatus) {
+ // Handle FAPI-initiated redirects (email link verification, etc.)
+ // This MUST run before emitting ready, otherwise the redirect never happens.
+ if (await this.#redirectFAPIInitiatedFlow()) {
+ return;
+ }
+
+ // Set client UAT cookie for development instances with custom domains
+ this.#authService?.setClientUatCookieForDevelopmentInstances();
+
+ // Restore cached environment before emitting ready so components
+ // that read displayConfig/authConfig have valid data
+ const envSnapshot = SafeLocalStorage.getItem(
+ CLERK_ENVIRONMENT_STORAGE_ENTRY,
+ null,
+ );
+ if (envSnapshot) {
+ this.updateEnvironment(new Environment(envSnapshot));
+ }
+
+ // Emit loaded with the appropriate status
+ this.#publicEventBus.emit(clerkEvents.Status, swrStatus);
+
+ // Continue fetching /env and /client in background (fire-and-forget)
+ // When they resolve, they silently update the state
+ const initEnvironmentPromise = Environment.getInstance()
+ .fetch({ touch: shouldTouchEnv })
+ .then(res => this.updateEnvironment(res))
+ .catch(() => {
+ // Fall back to cached env (same as existing behavior)
+ const environmentSnapshot = SafeLocalStorage.getItem(
+ CLERK_ENVIRONMENT_STORAGE_ENTRY,
+ null,
+ );
+ if (environmentSnapshot) {
+ this.updateEnvironment(new Environment(environmentSnapshot));
+ }
+ });
+
+ const refreshClient = Client.getOrCreateInstance()
+ .fetch()
+ .then(res => {
+ // If the cached session is no longer in the fresh client, select a new
+ // default session before calling updateClient. This handles the SWR
+ // stale-to-fresh swap without changing updateClient's semantics for
+ // all callers (a revoked session should sign out, not silently switch).
+ if (this.session) {
+ const stillExists = res.sessions?.some((s: { id: string }) => s.id === this.session?.id);
+ if (!stillExists) {
+ const fallback = this.#defaultSession(res);
+ this.#updateAccessors(fallback, { dangerouslySkipEmit: true });
+ }
+ }
+ // updateClient triggers #emit which fires the SWR save listener
+ this.updateClient(res);
+ })
+ .catch(() => {
+ // /client failed but we already have cached data, no action needed
+ });
+
+ // Don't await - let these run in the background
+ void allSettled([initEnvironmentPromise, refreshClient]);
+
+ this.#runPostInitSetup();
+
+ return; // Skip the normal flow below
+ }
+
+ // --- Normal flow (unchanged from here) ---
let initializationDegradedCounter = 0;
let retries = 0;
@@ -3070,11 +3200,7 @@ export class Clerk implements ClerkInterface {
}
}
- this.#captchaHeartbeat = new CaptchaHeartbeat(this);
- void this.#captchaHeartbeat.start();
- this.#clearClerkQueryParams();
- this.#handleImpersonationFab();
- this.#handleKeylessPrompt();
+ this.#runPostInitSetup();
this.#publicEventBus.emit(clerkEvents.Status, initializationDegradedCounter > 0 ? 'degraded' : 'ready');
};
@@ -3134,6 +3260,14 @@ export class Clerk implements ClerkInterface {
return session || null;
};
+ #runPostInitSetup = () => {
+ this.#captchaHeartbeat = new CaptchaHeartbeat(this);
+ void this.#captchaHeartbeat.start();
+ this.#clearClerkQueryParams();
+ this.#handleImpersonationFab();
+ this.#handleKeylessPrompt();
+ };
+
#setupBrowserListeners = (): void => {
if (!inClientSide()) {
return;
@@ -3161,6 +3295,9 @@ export class Clerk implements ClerkInterface {
*/
this.#broadcastChannel?.addEventListener('message', (event: MessageEvent) => {
if (event.data?.type === 'signout') {
+ if (this.#options.experimental?.swr) {
+ SWRClientCache.clear(this.#publishableKey);
+ }
void this.handleUnauthenticated({ broadcast: false });
}
});
@@ -3170,6 +3307,9 @@ export class Clerk implements ClerkInterface {
*/
eventBus.on(events.UserSignOut, () => {
this.#broadcastChannel?.postMessage({ type: 'signout' });
+ if (this.#options.experimental?.swr) {
+ SWRClientCache.clear(this.#publishableKey);
+ }
});
eventBus.on(events.EnvironmentUpdate, () => {
@@ -3180,6 +3320,20 @@ export class Clerk implements ClerkInterface {
24 * 60 * 60 * 1_000,
);
});
+
+ // Cache client snapshot for SWR initialization (only when SWR is enabled)
+ if (this.#options.experimental?.swr) {
+ let lastSavedUpdatedAt: number | undefined;
+ this.addListener(({ client }) => {
+ if (client) {
+ const updatedAt = client.updatedAt?.getTime();
+ if (updatedAt !== lastSavedUpdatedAt) {
+ lastSavedUpdatedAt = updatedAt;
+ SWRClientCache.save(client.__internal_toSnapshot(), this.#publishableKey);
+ }
+ }
+ });
+ }
};
// TODO: Be more conservative about touches. Throttle, don't touch when only one user, etc
diff --git a/packages/clerk-js/src/core/swr-client-cache.ts b/packages/clerk-js/src/core/swr-client-cache.ts
new file mode 100644
index 00000000000..0d2117ab801
--- /dev/null
+++ b/packages/clerk-js/src/core/swr-client-cache.ts
@@ -0,0 +1,82 @@
+import type { ClientJSONSnapshot } from '@clerk/shared/types';
+
+import { decode } from '../utils/jwt';
+import { SafeLocalStorage } from '../utils/localStorage';
+
+const CACHE_KEY_PREFIX = 'swr_client_';
+const CACHE_TTL_MS = 24 * 60 * 60 * 1_000; // 24 hours
+const CACHE_VERSION = 1;
+
+interface CacheEnvelope {
+ v: number;
+ data: ClientJSONSnapshot;
+}
+
+function cacheKey(publishableKey: string): string {
+ // Use last 8 chars of publishable key for scoping
+ const suffix = publishableKey.slice(-8);
+ return `${CACHE_KEY_PREFIX}${suffix}`;
+}
+
+/**
+ * Strip sensitive and ephemeral data from a client snapshot before caching.
+ * - lastActiveToken (JWT): expired quickly, looks like a credential, not needed for cache
+ * - signIn/signUp: ephemeral auth flow state, dangerous when stale
+ */
+function sanitizeSnapshot(snapshot: ClientJSONSnapshot): ClientJSONSnapshot {
+ return {
+ ...snapshot,
+ sign_in: null as any,
+ sign_up: null as any,
+ sessions: snapshot.sessions.map((session: ClientJSONSnapshot['sessions'][number]) => ({
+ ...session,
+ last_active_token: null,
+ })),
+ };
+}
+
+const TOKEN_SAFETY_MARGIN_S = 5; // match SessionCookiePoller interval
+
+/**
+ * Check if a raw JWT string is expiring within the safety margin.
+ * Parses the payload without verification (we only need the exp claim).
+ * Returns true if expired or expiring soon, false if still valid.
+ */
+export function isTokenExpiringSoon(jwt: string): boolean {
+ try {
+ const { claims } = decode(jwt);
+ const remainingSeconds = (claims.exp as number) - Math.floor(Date.now() / 1000);
+ return remainingSeconds <= TOKEN_SAFETY_MARGIN_S;
+ } catch {
+ return true; // unparseable = treat as expired
+ }
+}
+
+export const SWRClientCache = {
+ save(snapshot: ClientJSONSnapshot, publishableKey: string): void {
+ // Don't cache synthetic JWT-derived clients
+ if (snapshot.id === 'client_init') {
+ return;
+ }
+ // Don't cache empty clients (signed out)
+ if (!snapshot.sessions?.length) {
+ return;
+ }
+ const sanitized = sanitizeSnapshot(snapshot);
+ SafeLocalStorage.setItem(
+ cacheKey(publishableKey),
+ { v: CACHE_VERSION, data: sanitized } satisfies CacheEnvelope,
+ CACHE_TTL_MS,
+ );
+ },
+
+ read(publishableKey: string): ClientJSONSnapshot | null {
+ const entry = SafeLocalStorage.getItem(cacheKey(publishableKey), null);
+ if (!entry || entry.v !== CACHE_VERSION) return null;
+ return entry.data;
+ },
+
+ clear(publishableKey: string): void {
+ SafeLocalStorage.removeItem(cacheKey(publishableKey));
+ },
+};
diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts
index 670a1a21ba0..db76252e984 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -1259,6 +1259,17 @@ export type ClerkOptions = ClerkOptionsNavigation &
* directly with the provided Clerk instance. Used by React Native / Expo.
*/
runtimeEnvironment: 'headless';
+ /**
+ * Enable stale-while-revalidate initialization. When enabled, Clerk will
+ * initialize immediately using cached client data from the previous page load,
+ * then silently swap to fresh data when the server response arrives.
+ *
+ * Requires a previous successful load to populate the cache.
+ * First-time visitors and signed-out users see the normal loading flow.
+ *
+ * @default false
+ */
+ swr: boolean;
},
Record
>;