April 19, 2026

Why your TanStack Start app feels slow on navigation

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.

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:

beforeLoad waterfall

__root.tsx
380ms
getAuth()
_authed.tsx
450ms
getUserRole()
dashboard.tsx
sync
role check
Total blocking time: ~830ms

What the user sees

/dashboard/tracks
Menu
Tracks
Users
Settings
Tracks

Click to simulate a navigation

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:

__root.tsx
ts
beforeLoad: async (ctx) => {
  const token = await getAuth(); // server function — ~400ms
  if (token) {
    ctx.context.convexQueryClient.serverHttpClient?.setAuth(token);
  }
  return { isAuthenticated: !!token, token };
},
_authed.tsx
ts
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,
  };
},
dashboard.tsx
ts
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.

HookSupports pendingComponentSupports staleTimeBlocks navigation
beforeLoadNoNoYes
loaderYesYesYes, but with pending UI

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

__root.tsx
ts
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

_authed.tsx
ts
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:

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;
}

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.

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: "/" });
  }
}

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

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.

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.

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.

#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.