---
title: "Why your TanStack Start app feels slow on navigation"
description: "Every link click blocks for ~1 second because beforeLoad re-fetches auth on every navigation. Here's the fix — and why pendingComponent won't help."
date: "2026-04-19"
slug: "tanstack-start-beforeload-blocking-fix"
tags: ["tanstack", "tanstack-start", "routing", "performance", "auth"]
---


I clicked a sidebar link. The URL updated. The page didn't.

For about a second, nothing happened. The old content sat there, stale, while the address bar already showed the new route. Then the page caught up.

This was happening on every single navigation inside the authenticated area of a TanStack Start app. Sidebar link, header link, programmatic navigate — didn't matter. The URL was instant. The page was not.

## What it looks like

Toggle between the two modes and click the button to simulate a navigation:

<BeforeLoadBlockingDemo />

On the left, the waterfall shows what's happening during the transition. On the right, what the user actually sees. The "no cache" version is what ships by default if your `beforeLoad` calls server functions.

## The blocking chain

The app had a standard auth setup. A root route that fetches an auth token, and an `_authed` pathless layout that fetches the user's role:

```ts title="__root.tsx"
beforeLoad: async (ctx) => {
  const token = await getAuth(); // server function — ~400ms
  if (token) {
    ctx.context.convexQueryClient.serverHttpClient?.setAuth(token);
  }
  return { isAuthenticated: !!token, token };
},
```

```ts title="_authed.tsx"
beforeLoad: async ({ context, location }) => {
  if (!context.isAuthenticated) {
    throw redirect({ to: "/login", search: { redirect: location.href } });
  }
  const result = await getUserRole(); // server function — ~450ms
  return {
    role: result.membership.role,
    organizationId: result.membership.organizationId,
    user: result.user,
  };
},
```

```ts title="dashboard.tsx"
beforeLoad: ({ context }) => {
  // Sync — reads context from _authed.tsx
  if (context.role !== "admin" && context.role !== "manager") {
    throw redirect({ to: "/" });
  }
},
```

TanStack Router runs `beforeLoad` on **all** matched routes for every navigation, top to bottom, sequentially. No route component renders until the entire chain resolves. The URL updates immediately — that's an optimistic behavior — but the actual page transition waits.

Two server round-trips at ~400ms each. Every click. That's the full explanation.

## Why `pendingComponent` doesn't help

The first instinct is to add a pending component or set `defaultPendingMs: 0` on the router. I tried both.

`pendingComponent` only activates while `loader` is running. `beforeLoad` is a different lifecycle — it runs before the loader, before any component, before pending UI. It blocks everything.

<Table>
  <TableHead>
    <TableRow>
      <TableCell header>Hook</TableCell>
      <TableCell header>Supports `pendingComponent`</TableCell>
      <TableCell header>Supports `staleTime`</TableCell>
      <TableCell header>Blocks navigation</TableCell>
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow>
      <TableCell>`beforeLoad`</TableCell>
      <TableCell>No</TableCell>
      <TableCell>No</TableCell>
      <TableCell>Yes</TableCell>
    </TableRow>
    <TableRow>
      <TableCell>`loader`</TableCell>
      <TableCell>Yes</TableCell>
      <TableCell>Yes</TableCell>
      <TableCell>Yes, but with pending UI</TableCell>
    </TableRow>
  </TableBody>
</Table>

There's no built-in mechanism to cache, debounce, or show pending UI during `beforeLoad`. It either runs and blocks, or you make it synchronous.

## The fix: module-level cache

The auth token and user role don't change between navigations. Re-fetching them on every click is pure waste. The fix is a module-level variable that caches the result after the first fetch.

### Root route

```ts title="__root.tsx"
const getAuth = createServerFn({ method: "GET" }).handler(async () => {
  return await getToken();
});

let clientAuthCache: {
  isAuthenticated: boolean;
  token: Awaited<ReturnType<typeof getAuth>>;
} | null = null;

export function clearAuthCache() {
  clientAuthCache = null;
}

export const Route = createRootRouteWithContext<{ /* ... */ }>()({
  beforeLoad: async (ctx) => {
    // Client-side: return cache to skip server round-trip
    if (typeof window !== "undefined" && clientAuthCache) {
      return clientAuthCache;
    }

    const token = await getAuth();
    if (token) {
      ctx.context.convexQueryClient.serverHttpClient?.setAuth(token);
    }

    const result = { isAuthenticated: !!token, token };
    if (typeof window !== "undefined") {
      clientAuthCache = result;
    }
    return result;
  },
});
```

### Auth layout

```ts title="_authed.tsx"
let clientRoleCache: {
  role: string;
  organizationId: string;
  user: any;
} | null = null;

export function clearRoleCache() {
  clientRoleCache = null;
}

export const Route = createFileRoute("/_authed")({
  beforeLoad: async ({ context, location }) => {
    if (!context.isAuthenticated) {
      throw redirect({ to: "/login", search: { redirect: location.href } });
    }

    // Client-side: return cache to skip server round-trip
    if (typeof window !== "undefined" && clientRoleCache) {
      return clientRoleCache;
    }

    try {
      const result = await getUserRole();
      if (!result || !result.membership) {
        throw redirect({ to: "/" });
      }

      const data = {
        role: result.membership.role,
        organizationId: result.membership.organizationId,
        user: result.user,
      };

      if (typeof window !== "undefined") {
        clientRoleCache = data;
      }
      return data;
    } catch (error) {
      if (isRedirect(error)) throw error;
      throw redirect({ to: "/login", search: { redirect: location.href } });
    }
  },
});
```

SSR always fetches — the `typeof window` guard ensures the cache is client-only. The first client-side navigation still hits the server. Every navigation after that is synchronous.

### Clearing on sign-out

Every sign-out handler must clear both caches:

```ts
import { clearAuthCache } from "@/routes/__root";
import { clearRoleCache } from "@/routes/_authed";

const signOut = async () => {
  await authClient.signOut();
  clearAuthCache();
  clearRoleCache();
  window.location.href = "/login";
};
```

The `window.location.href` assignment causes a full page load on login, which resets module state anyway. But clearing explicitly makes the contract obvious and covers edge cases like `router.navigate({ to: "/login" })` where no reload happens.

## The staleness tradeoff

The cache is a UX optimization, not a security boundary. The server is the authority.

If a user's role is revoked while they're actively navigating, the client cache won't reflect that. They'd see the UI for their old role — but any actual data operation would still fail server-side. Every Convex query and mutation validates auth independently. The cache controls what the user *sees*. The server controls what they can *do*.

For apps where this gap matters — frequent role changes, compliance requirements, large teams — two mitigations are available:

<Tabs defaultTab="ttl">
  <TabList>
    <TabTrigger value="ttl">TTL-based refresh</TabTrigger>
    <TabTrigger value="sub">Subscription invalidation</TabTrigger>
  </TabList>
  <TabContent value="ttl">
    Re-fetch after a time window. Most navigations still hit cache, but stale data has a ceiling:

    ```ts
    let clientRoleCache: { data: RoleData; timestamp: number } | null = null;
    const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes

    function getCachedRole() {
      if (!clientRoleCache) return null;
      if (Date.now() - clientRoleCache.timestamp > CACHE_TTL_MS) {
        clientRoleCache = null;
        return null;
      }
      return clientRoleCache.data;
    }
    ```
  </TabContent>
  <TabContent value="sub">
    If the app subscribes to user/membership data in a component, invalidate the cache when the subscription fires with changed data:

    ```ts
    const membership = useQuery(convexQuery(api.users.getMyRole, {}));

    useEffect(() => {
      if (membership && clientRoleCache) {
        if (membership.role !== clientRoleCache.role) {
          clearRoleCache();
          router.invalidate();
        }
      }
    }, [membership]);
    ```
  </TabContent>
</Tabs>

For early-stage apps with small teams, neither is necessary. A stale UI until refresh is a non-issue when the backend enforces real access control.

## What about moving it to `loader`?

If `beforeLoad` is the problem, why not move the async work to `loader` where `pendingComponent` and `staleTime` work?

You can — if child routes don't need the data in their own `beforeLoad`. Set `staleTime: Infinity` and the router handles caching for you:

```ts
export const Route = createFileRoute("/_authed")({
  beforeLoad: ({ context, location }) => {
    // Sync only
    if (!context.isAuthenticated) {
      throw redirect({ to: "/login", search: { redirect: location.href } });
    }
  },
  loader: async () => {
    const result = await getUserRole();
    return { role: result.membership.role, user: result.user };
  },
  staleTime: Infinity,
});
```

This doesn't work when child routes check `context.role` in their own `beforeLoad` — which is the common pattern for role-based redirects. `loader` runs after all `beforeLoad` calls, so its data isn't available to child `beforeLoad` context.

<Tabs defaultTab="works">
  <TabList>
    <TabTrigger value="works">Works (data in beforeLoad)</TabTrigger>
    <TabTrigger value="breaks">Breaks (data in loader)</TabTrigger>
  </TabList>
  <TabContent value="works">
    ```ts
    // _authed.tsx — role is returned into context
    beforeLoad: async ({ context }) => {
      const result = await getUserRole();
      return { role: result.membership.role };
    }

    // dashboard.tsx — context.role is available
    beforeLoad: ({ context }) => {
      if (context.role !== "admin") {
        throw redirect({ to: "/" });
      }
    }
    ```
  </TabContent>
  <TabContent value="breaks">
    ```ts
    // _authed.tsx — role is in loader, not context
    loader: async () => {
      const result = await getUserRole();
      return { role: result.membership.role };
    }

    // dashboard.tsx — context.role is undefined!
    // loader runs AFTER all beforeLoad calls
    beforeLoad: ({ context }) => {
      if (context.role !== "admin") {
        throw redirect({ to: "/" });
      }
    }
    ```
  </TabContent>
</Tabs>

If your auth data is only consumed in components (not child `beforeLoad`), the `loader` approach is cleaner. If child routes need it for redirects, use the module-level cache.

## Things to watch for

<Steps>
  <Step title="Return the same type from every branch">
    All branches of `beforeLoad` must return the same shape. If the cache returns `{ token: string | null }` but the fresh fetch returns `{ token: string | undefined }`, TanStack Router's generated types break and child routes lose access to context properties. Use `Awaited<ReturnType<typeof serverFn>>` to derive the type.
  </Step>
  <Step title="SSR always fetches">
    Never cache on the server. SSR needs fresh auth tokens for the initial render. The `typeof window !== "undefined"` guard handles this — it's a runtime check that works reliably in Vite SSR. Some environments polyfill `window`, so if you're not on Vite, verify this guard works in your SSR runtime.
  </Step>
  <Step title="Module state and HMR">
    Module-level variables survive hot module replacement in dev but reset on full refresh. During development you might not see the caching behavior unless you navigate within the same session without refreshing. This is expected — it matches production behavior where the module loads once per page session.
  </Step>
</Steps>

## The result

Before: every navigation blocked for ~800ms while two server functions resolved sequentially. The URL updated instantly but the page froze.

After: first navigation still fetches (unavoidable). Every navigation after that is synchronous. The page transitions as fast as the URL updates.

No framework change, no additional dependencies. Two module-level variables and a `typeof window` check.
