Featured image of post The SvelteKit Auth Race: When Your Login Works But Your App Doesn't Think So

The SvelteKit Auth Race: When Your Login Works But Your App Doesn't Think So

Ever logged in successfully only to find your app still thinks you're a stranger? Here's how I hunted down a sneaky race condition that was making authenticated users disappear into thin air.

You know that feeling when you’re sure you’ve built something correctly, but it keeps misbehaving in the most infuriating way?

I’d just spent weeks crafting what I thought was the perfect authentication system. Modern, secure, using httpOnly cookies—the whole nine yards. I could log in, the backend would celebrate with a 200 response, and the browser would dutifully store the session cookie. Everything looked perfect.

But then I’d land on my dashboard, and it would greet me like a stranger. No user data. No cases. Nothing. Just a barren wasteland where my authenticated content should be.

The truly maddening part? Sometimes hitting refresh would magically fix it. Other times, it wouldn’t. My app had become a slot machine, and I was losing my sanity pulling the refresh lever.

This is the story of hunting down that ghost—a race condition so subtle that it took me deep into the heart of SvelteKit’s lifecycle to understand what was really happening.

The Setup: When Perfect On Paper Meets Reality

Let me paint you a picture of what should have been working. Our authentication flow was textbook perfect:

Step 1: User fills out login form
Step 2: SvelteKit server sends credentials to backend
Step 3: Backend validates and returns a session token
Step 4: SvelteKit server sets an httpOnly cookie (secure, JavaScript can’t touch it)
Step 5: Every subsequent request goes through a server hook that validates the session
Step 6: User data flows down to components via the layout

Think of it like a secure building with a smart doorman. You show your ID at the front desk, they mail a keycard to the doorman, and the doorman is supposed to let you into any room you need.

But here’s where things went sideways: I kept arriving at rooms before the keycard had been properly processed. The doorman would look at me confused and say, “Sorry, no keycard on file.”

Round 1: “It Must Be a Client-Side Problem”

My first instinct was to blame the client. It’s usually the client, right? The dashboard page must be trying to fetch data before the auth system was ready.

So I built what I thought was a clever solution—an “auth ready” gate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// My first attempted fix in src/lib/utils/auth-client.ts
let authReadyPromise: Promise<void>;
let resolveAuthReady: () => void;

// Create a promise that waits for auth to be ready
authReadyPromise = new Promise((resolve) => {
    resolveAuthReady = resolve;
});

export function awaitAuthReady(): Promise<void> {
    return authReadyPromise;
}

export function initializeApiAuth(sessionToken: string | null) {
    // Set up the API client with the token
    if (sessionToken) {
        apiClient.setAuthToken(sessionToken);
    }
    resolveAuthReady(); // Signal that auth is ready
}

And in my dashboard:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- The "wait for auth" pattern in +page.svelte -->
<script>
    import { awaitAuthReady } from '$lib/utils/auth-client';
    import { onMount } from 'svelte';

    onMount(async () => {
        // Wait for the auth system to be ready
        await awaitAuthReady();
        await loadCases();
    });
</script>

This felt so right. Textbook race condition solution. But it didn’t work.

The logs revealed the cruel truth: the client was patiently waiting for awaitAuthReady(), but the promise was resolving with a session token of null. The problem wasn’t that the client was too eager—it was that the keycard was never making it to the doorman in the first place.

Round 2: “Maybe It’s the SvelteKit Router”

Okay, so the client isn’t getting updated data after login. This screams “SvelteKit router caching issue.” The enhance function tries to be smart about what to update, but sometimes it’s too smart for its own good.

The official docs suggest invalidateAll() for exactly this scenario. It’s like telling SvelteKit, “Forget being clever—just reload everything.”

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<!-- In auth/+page.svelte -->
<script>
    import { invalidateAll } from '$app/navigation';
    import { enhance } from '$app/forms';

    const loginEnhance = enhance(() => {
        return async ({ result }) => {
            if (result.type === 'redirect') {
                // Force a complete data refresh for the next page
                await invalidateAll();
            }
        };
    });
</script>

<form method="POST" use:loginEnhance>
    <!-- form fields -->
</form>

This actually worked! Sort of. The login flow was now successful, but that mysterious refresh issue persisted. I’d still get the occasional “ghost logout” where the app would forget who I was until I manually refreshed.

This was a crucial clue. It proved the cookie was being set correctly, but there was something deeper going wrong with how the server was handling authentication state.

Time to stop guessing and start detective work. I littered the entire auth flow with console logs, from the server hook all the way down to the client components. I needed to see exactly where my session token was vanishing.

The smoking gun appeared in the server logs right after a successful login:

1
2
3
4
🔵 [Hook] Session cookie found: abc123...
🔵 [Hook] Validating session with backend...
🔵 [Hook] Session validation FAILED. User logged out.
🔵 [Hook] Session cookie deleted.

And then from the browser:

1
2
🔵 [Layout] Session token received: null
🔵 [Dashboard] No session token, showing login prompt

There it was! The server hook was finding the cookie, but the validation call to the backend was failing. My code was being way too aggressive—any validation hiccup would nuke the entire session.

Here’s what my server hook looked like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// The overly aggressive version in hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
    const sessionId = event.cookies.get('session');

    if (sessionId) {
        const user = await validateSession(sessionId);
        
        if (user) {
            event.locals.user = user;
            event.locals.isAuthenticated = true;
        } else {
            // 💥 THE PROBLEM: Too aggressive!
            // Any validation failure = session destroyed
            event.cookies.delete('session');
            event.locals.user = null;
            event.locals.isAuthenticated = false;
        }
    }

    return resolve(event);
};

The doorman wasn’t just failing to process the keycard—he was throwing it in the trash at the first sign of trouble. A momentary network hiccup between the SvelteKit server and the backend would cause a permanent logout.

Round 4: Making the Server More Forgiving

The fix was obvious once I saw it: stop being so trigger-happy with session deletion.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// The more resilient version in hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
    const sessionId = event.cookies.get('session');

    if (sessionId) {
        const user = await validateSession(sessionId);
        
        if (user) {
            event.locals.user = user;
            event.locals.isAuthenticated = true;
        } else {
            // 💡 THE FIX: Don't delete the cookie on validation failure
            // Just mark this request as unauthenticated
            event.locals.user = null;
            event.locals.isAuthenticated = false;
        }
    } else {
        event.locals.user = null;
        event.locals.isAuthenticated = false;
    }

    return resolve(event);
};

This was a huge improvement. The session cookie now survived transient network issues. But I still had one more ghost to hunt down—the app would sometimes show “Not Authenticated” right after login, even though a manual refresh would always fix it.

Round 5: The Final Boss—Understanding Hydration Timing

This was the most subtle bug of all. Here’s what I knew:

✅ Server was setting the cookie correctly
✅ Server hook was validating it properly
✅ Layout was getting the user data from the server
✅ Manual refresh always worked

So why was the dashboard still acting like I wasn’t logged in?

The answer lay in understanding the difference between imperative and reactive code. My dashboard was using onMount to decide whether to load data:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- The timing-dependent approach -->
<script>
    import { onMount } from 'svelte';
    import { sessionStore } from '$lib/stores/auth';

    onMount(() => {
        // This runs once when the component mounts
        if ($sessionStore) {
            loadCases();
        }
    });
</script>

But wait—you might be thinking, “What’s wrong with that? It checks if the user is authenticated before loading data.”

Here’s the timeline that was breaking everything:

0ms: User clicks login, SvelteKit starts navigating to dashboard
1ms: Dashboard component is created
2ms: Dashboard’s onMount runs, checks $sessionStore (still null), decides not to load data
5ms: Layout component receives fresh user data from server
6ms: Layout updates $sessionStore with real user data
7ms: UI shows user’s name in header, but dashboard’s onMount has already made its decision

It’s like a new employee showing up for their first day. Their manager has their ID badge ready on the desk, but the employee immediately runs to the secure lab door before getting properly checked in. The door rejects them, they give up, and even when the manager comes out with their badge, it’s too late—they’ve already concluded they don’t have access.

The Solution: Embrace Reactivity

The fix was to stop thinking imperatively (“do this once when X happens”) and start thinking reactively (“do this whenever the state changes”).

With SvelteKit’s reactive system, this becomes elegant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!-- The reactive approach -->
<script>
    import { sessionStore } from '$lib/stores/auth';
    
    let hasLoadedCases = $state(false);

    // This isn't a one-time function—it's a reaction to state changes
    $effect(() => {
        if ($sessionStore && !hasLoadedCases) {
            hasLoadedCases = true;
            loadCases();
        } else if (!$sessionStore) {
            // Reset when user logs out
            hasLoadedCases = false;
        }
    });
</script>

This code tells a completely different story: “I don’t care about mount timing. I care about the session state. The moment $sessionStore has a user, load the data.”

The Takeaways

This debugging journey taught me three crucial lessons:

1. Server Resilience is Everything
Your server hooks are the guardians of your application. They should be resilient to transient errors. A momentary network hiccup shouldn’t result in nuking a user’s session.

2. Race Conditions Love Imperative Code
The moment you write “do this once when X happens,” you’re setting yourself up for timing issues. Modern frameworks give us reactive tools for a reason—use them.

3. Think in State, Not Events
Instead of “when the component mounts, check if authenticated,” think “whenever authentication state changes, update the UI accordingly.” This eliminates entire classes of timing bugs.

The ghost is gone now. Users can log in and immediately see their data, regardless of network conditions or hydration timing. The authentication flow is both secure and robust.

Have you encountered similar timing issues in your SvelteKit apps? I’d love to hear about your debugging adventures in the comments below. Sometimes the most subtle bugs teach us the most about how our tools really work.