router icon indicating copy to clipboard operation
router copied to clipboard

Performance is horrible when using recommended Authentication patterns

Open Mikephii opened this issue 8 months ago • 15 comments

Which project does this relate to?

Router

Describe the bug

When using the recommended patterns for authentication, particularily in tanstack start, the performance of the app is garbage.

because onBefore load for _root or _authed routes runs on every page navigation, even when you are clientside, it necessitates a sever round trip before being able to navigate resulting in incredibly unresponsive apps. (even if the auth service was processing in 1ms the server trip is usally about 200 -300 ms for most people)

With tanstack router there were some solutions to this by storing the authstate in a react context or hook outside of the inner app and so the state would persist as normal across transitions without a server trip and you could refresh you session tokens as normal when needed.

With tanstack start this is no longer an option as it does not appear as if its possible to have a global state that is persisted across route transitions, (ie client side behaviour that we love, that makes the apps fast).

This is quite obviously not in line with the promise of tanstack start from their very own landing page:

While other frameworks continue to compromise on the client-side application experience we've cultivated as a front-end community over the years, TanStack Start stays true to the client-side first developer experience, while providing a full-featured server-side capable system that won't make you compromise on user experience.

There should be some VERY clear documentation on how to avoid this performance issue, and patterns and practices on how to achieve client first performance.

Your Example Website or App

https://github.com/tanstack/router/tree/main/examples/react/start-clerk-basic

Steps to Reproduce the Bug or Issue

use any of the authenticated template starters and attempt to navigate

Expected behavior

should be an option to have state stored locally and persisted across page navigation, particularily for auth. if that already is an option then there should be docs showing how to use this to avoid the repeated server trips to re-authenticate on every link

Screenshots or Videos

No response

Platform

  • OS: [e.g. macOS, Windows, Linux]
  • Browser: [e.g. Chrome, Safari, Firefox]
  • Version: [e.g. 91.1]

Additional context

No response

Mikephii avatar Apr 15 '25 11:04 Mikephii

atleast for supabase currently im determining if someone is authenticated or not by using supabase getSession. im making sure im using the browser client on the browser and the serverClient on the server so there are no round trips required to atleast determine if a user has session cookies. that is in _root

then in _authed, if there is session cookies i make a call to supabase.auth.getUser() through a server fn which will validate the cookie and return user data. this fetch im caching using tanstack queries ensureQueryData queryClient.ensureQueryData(authQueries.user())

then ofcourse every request to supabase is happening server side and using the session cookies and if row level security is set correctly then this is safe.

So, cookies are always fetched locally and allow for instant navigation ✅ getuser is called once within _authed and cached afterwards for instant navigation ✅ security checks happen for all data access serverside and via RLS so no risk of data leaking

It is ofcourse possible for someone to manually set anyKind of session cookies and that will allow them to navigate to a _authed page, but then the first call to supabase.auth.getUser() will throw as they dont have the correct JWT and they will be redirected back to /signin or wherever

the trick is this isomorphic fn to use the supabase/ssr browserClient or serverClient depending on the runtime context.


export type SerializableSession = {
  access_token: string;
  refresh_token: string;
};

export const isoMorphicGetSBSession = createIsomorphicFn()
  .client(async (): Promise<SerializableSession | null> => {
    const { createBrowserClient } = await import('@supabase/ssr');
    const url = import.meta.env.VITE_SUPABASE_URL;
    const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
    const supabase = createBrowserClient(url, anonKey);
    const {
      error,
      data: { session },
    } = await supabase.auth.getSession();
    if (!session) {
      return null;
    }
    return {
      access_token: session.access_token,
      refresh_token: session.refresh_token,
    };
  })
  .server(async (): Promise<SerializableSession | null> => {
    const { parseCookies, setCookie } = await import('vinxi/http');
    const { createServerClient } = await import('@supabase/ssr');
    const supabase = createServerClient(
      process.env.VITE_SUPABASE_URL!,
      process.env.VITE_SUPABASE_ANON_KEY!,
      {
        cookies: {
          getAll() {
            return Object.entries(parseCookies()).map(([name, value]) => ({
              name,
              value,
            }));
          },
          setAll(cookies: any) {
            cookies.forEach((cookie: any) => {
              setCookie(cookie.name, cookie.value);
            });
          },
        },
      }
    );
    const {
      error,
      data: { session },
    } = await supabase.auth.getSession();
    if (!session) {
      return null;
    }

    const {
      error: authErr,
      data: { user },
    } = await supabase.auth.getUser();
    if (authErr) {
      return null;
    }

    return {
      access_token: session.access_token,
      refresh_token: session.refresh_token,
    };
  });

this method is based off of the supabase/ssr auth for sveltkit docs : https://supabase.com/docs/guides/auth/server-side/sveltekit

I assume this method of checking cookies either from the browser or from the server depending on the runtime context, fetching userData once and caching it, and then ensuring that you validate the JWT on serverside actions should work for any auth system and allow for instant navigation.

just need to make sure that whatever api setup youre using you pass and validate your JWT for every data retrieval request and make sure you dont rely on simple userID params for accessing userdata but rather actually determine user access from the decoded JWT.

supabase does this under the hood with getUser() and also with row level security.

Mikephii avatar Apr 19 '25 07:04 Mikephii

Was facing the same issues, wanted to share my similar solution as @Mikephii stated, but a little more complete, just to have something to talk about if this is the way to go?

For context, using:

  • trpc
  • better-auth
  • react-query

Heavy caching seems nessesary to provide the "instant" client navigation feel. Using ensureQueryData with revalidateIfStale to allow refetches when stale to not block navigation

// src/routes/_root.tsx

export interface RouterContext {
    queryClient: QueryClient
    trpc: TRPCOptionsProxy<AppRouter>
    session: null
}

export const Route = createRootRouteWithContext<RouterContext>()({})
// src/functions.ts

import { getWebRequest } from "@tanstack/react-start/server"
import type { RouterContext } from "~/routes/__root"

export const $getSession =
    createIsomorphicFn()
        .client(async (queryClient: RouterContext["queryClient"]) => {
            const { data: session } = await queryClient.ensureQueryData({
                queryFn: () => authClient.getSession(),
                queryKey: ["auth", "getSession"],
                staleTime: 60_000, // cache for 1 minute
                revalidateIfStale: true, // fetch in background when stale
            })

            return {
                session,
            }
        })
        .server(async (_: RouterContext["queryClient"]) => {
            const request = getWebRequest()

            if (!request?.headers) {
                return { session: null }
            }

            const session = await auth.api.getSession({
                headers: request.headers,
            })

            return {
                session,
            }
        })

better-auth's auth.api.getSession is used in the isomorphicFn above and also in my protected trpc procedures.

// src/routes/_authed/route.tsx

import { $getSession } from "~/functions"

export const Route = createFileRoute("/_authed")({
    component: RouteComponent,
    beforeLoad: async ({ location, context, preload }) => {
        if (preload) {
            return
        }

        const { session } = await $getSession(context.queryClient)

        if (!session) {
            throw redirect({
                to: "/login",
                search: {
                    redirect: location.href,
                },
            })
        }

        return {
            session,
        }
    },
})

Consume the session:

// src/routes/_authed/private-route/index.tsx

const { session } = Route.useRouteContext()

jvandenaardweg avatar Apr 29 '25 09:04 jvandenaardweg

I've been running into a few similar surprises mostly related to the beforeLoad/loader behavior on a pathless _authed wrapper route.

Observations:

  • beforeLoad and loader of the _authed wrapper route are triggered on every child route navigation.
  • beforeLoad and loader of the _authed wrapper route are triggered on every child route preload, with the preload param received as false, for some reason. This especially threw me off.
  • No amount of staleTime, preloadStaleTime, gcTime or preloadGcTime configuration seem to have any impact on how frequently the beforeLoad and loader trigger inside the _authed wrapper route.

What I ideally would like to achieve is a way to prevent rapid-firing of beforeLoad and loader methods on a wrapper route if that route is already active, when interacting (preload or navigation) with a child route. I assumed that a combination of preloadStaleTime and staleTime would be able to cache these in both the preload and normal scenarios and thus prevent auth from being hammered, but that doesn't appear to be working at all. Especially when combined with preload intent this leads to a lot of overhead when hovering over links. I'll see if I can leverage react-query for this and cache it that way, but I feel like there should be a route-first solution here as well.

edit: fwiw, I'm currently using a react-query workaround similar to this:

export const Route = createFileRoute('/_authed')({
  component: RouteComponent,
  context: ({ context: { authClient } }) => ({
    queryOptions: {
      session: queryOptions({
        staleTime: 1000 * 15,
        queryKey: queryKeys.core.session(),
        queryFn: async () => {
          const isAuthenticated = await authClient.isAuthenticated();

          return {
            isAuthenticated,
            sessionUser: isAuthenticated ? await getSessionUserData(authClient) : null
          };
        }
      })
    }
  }),
  loader: async ({ context: { queryClient, queryOptions } }) => {
    // We're using `fetchQuery` because `ensureQueryData` would ignore the
    // staleTime and never refresh the query, while we want to periodically
    // recheck the session so we can send the user to login if necessary.
    const session = await queryClient.fetchQuery(queryOptions.session);

    if (!session.isAuthenticated) {
      // Redirect login here
    }
  }
});

function RouteComponent() {
  const { queryOptions } = Route.useRouteContext();
  const queryResult = useSuspenseQuery(queryOptions.session);

  if (queryResult.data.isAuthenticated) {
    return (
      <SessionUserContext value={queryResult.data.sessionUser}>
        <Outlet />
      </SessionUserContext>
    );
  }

  return null;
}

pleunv avatar May 07 '25 15:05 pleunv

Not sure if I am missing something here but I just used react query to cal a server function to get the session from before load in __root. I set a stale time of 5 minutes and that was it.

SpeedOfSpin avatar May 24 '25 21:05 SpeedOfSpin

Not sure if I am missing something here but I just used react query to cal a server function to get the session from before load in __root. I set a stale time of 5 minutes and that was it.

The point is that you need react-query. Routes themselves have similar cache options as well but they work in unexpected ways.

pleunv avatar May 25 '25 08:05 pleunv

we are working on adding another lifecycle method that will be cached in the same way as the loader but will execute serially just like beforeLoad. stay tuned!

schiller-manuel avatar May 25 '25 09:05 schiller-manuel

any updates?

Firephoenix25 avatar Sep 10 '25 16:09 Firephoenix25

I did the following to address performance issue for clerk auth integration: fetch auth only on server.

beforeLoad: async ({ context }) => {
		if (context.convexQueryClient.serverHttpClient) {
			const auth = await fetchClerkAuth();
			const { token } = auth;
			// During SSR only (the only time serverHttpClient exists),
			// set the Clerk auth token to make HTTP queries with.
			if (token) {
				context.convexQueryClient.serverHttpClient?.setAuth(token);
			}
		}
	},

joycollector avatar Sep 21 '25 12:09 joycollector

bump

Christopher96u avatar Oct 13 '25 11:10 Christopher96u

This happens because you are trying to implement stateful authentication on an single page application. Just not how life works, learned the hard way. Use stateless auth if you want your application to behave like a SPA

Any updates? This is burning us at the moment. Is there anything in progress we can help finish?

willhoney7 avatar Nov 04 '25 19:11 willhoney7

Or workaround because im a bit confused on how to deal with this issue i went full SPA Auth for now but its a bit annoying with some UX

JustKira avatar Nov 17 '25 07:11 JustKira

Or workaround because im a bit confused on how to deal with this issue i went full SPA Auth for now but its a bit annoying with some UX

Which UX issues are you facing? @JustKira

m4rvr avatar Nov 30 '25 21:11 m4rvr

Or workaround because im a bit confused on how to deal with this issue i went full SPA Auth for now but its a bit annoying with some UX

Which UX issues are you facing? @JustKira

The main issue when i use Search Params its Supper laggy because it seems to rerun beforload every single time Here my issue in details

JustKira avatar Dec 01 '25 15:12 JustKira

looking for solutions, super laggy

primerch avatar Dec 11 '25 12:12 primerch

I don't know if this helps some of you, but I setup Clerk like this with TanStack Router: It's loaded and prepared above the router, and then I store the important stuff in router context, which I invalidate on user/org change. Here's a rough idea:

import { createRouter, RouterProvider } from '@tanstack/react-router';
import { useUser, useOrganization, ClerkProvider } from '@clerk/clerk-react';
import queryClient from './queryClients.ts';
import trpc from './trpc.ts';

export router = createRouter({
  context: {
    auth: {
      isSignedIn: false,
      userId: undefined,
      orgId: undefined,
      orgRole: undefined,
    },
    queryClient,
    trpc,
  },
});

// Guarantee clerk is ready before mounting the router
function ClerkLoaded({ children }) {
  const { isLoaded: isUserLoaded } = useUser();
  const { isLoaded: isOrgLoaded } = useOrganization();

  if (!isUserLoaded || !isOrgLoaded) {
    return <BigLoadingScreenOrSkeleton />;
  }

  return children;
}

function InnerApp() {
  const { user, isSignedIn } = useUser();
  const { organization } = useOrganization();

  // Only put critical data here, the rest can be pulled in using useUser and useOrganization hooks in your components
  const auth = useMemo(() => ({
    isSignedIn,
    userId: user?.id,
    orgId: organization?.id,
    orgRole: organization?.publicMetadata?.role
  }), [isSignedIn, user?.id, organization?.id, organization?.publicMetadata?.role ]);

  useEffect(() => {
    // When one of these critical data points changes, just invalidate the whole router
    // You don't want to take the risk that something auth-related is stale in the router context
    void router.invalidate();
  }, [auth]);

  return <RouterProvider context={{ auth }} router={router} />;
}

function App() {
  return (
    <ClerkProvider
      publishableKey={env.VITE_CLERK_PUBLISHABLE_KEY}
      routerPush={(to) => router.navigate({ to })}
      routerReplace={(to) => router.navigate({ replace: true, to })}
    >
      <ClerkLoaded>
        {/* All my other providers here wrap InnerApp. They can now count on clerk being loaded */}
        <InnerApp />
      </ClerkLoaded>
    </ClerkProvider>
  );
}

export default App;

This is after a bunch of experimenting on patterns, maybe some of this is not ideal either, but it's the best I found so far. This guarantees that all my routes have accurate and live auth information that I can depend on in my beforeLoad, so I can then throw redirects, and make sure everything is correct.

Floriferous avatar Dec 12 '25 11:12 Floriferous

@Floriferous hey would you mind posting a full solution you have? Is that all in a single router.tsx file?

Zefty avatar Dec 15 '25 04:12 Zefty

What I've found is that the best solution is to just make sure your beforeLoad authentication check is using react query so that it's cached. It'll still trigger all the time, but react query's cache will prevent it from refetching every time.

 beforeLoad: async ({ context }) => {
    try {
        // use query client so that we cache it client side
        const session = await context.queryClient.ensureQueryData(getCurrentSessionOptions);
        return { session };
    } catch (e) {
        // do stuff with e
        throw redirectToLogin();
    }
},

willhoney7 avatar Dec 17 '25 16:12 willhoney7