Skip to content

Memory Problems with Tanstack Query and SSR #6051

@jofflin

Description

@jofflin

Which project does this relate to?

Start

Describe the bug

I have a kind of weird bug which is probably some misconfiguration on my side.
When running my Tanstack Start app the memory usage grows over time until it exceeds the limit, crashes and then restarts.
I did a heap snapshot and this is probably a issue with the queryClient.

I am not that used to bug/issue reporting. Please tell me which information you need and how I can make this as easy as possible for you

Your Example Website or App

https://github.com

Steps to Reproduce the Bug or Issue

I can try to provide a reproduction example if there is no initial dumb error in my code

Expected behavior

Memory should be more or less stable

Screenshots or Videos

I can provide a link to the heap snapshots if needed.

Image Image

Platform

  • Router / Start Version: 1.133.27
  • OS: not relevant
  • Browser: [e.g. Chrome, Safari, Firefox]
  • Browser Version: [e.g. 91.1]
  • Bundler: vite (deployed with nitro and coolify to a VPS
  • Bundler Version: 7.0.6

Additional context

My Setup:

import { QueryClient } from '@tanstack/react-query';
import { createRouter as createTanStackRouter } from '@tanstack/react-router';
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query';
import { queryClientConfig } from './context/tanstack-query-context';
import 'leaflet/dist/leaflet.css';
import { configureWebClient, getWebClient } from '@nono/api-client';
import * as Sentry from '@sentry/tanstackstart-react';
import { z } from 'zod';
import GeneralError from './features/errors/general-error';
import NotFoundError from './features/errors/not-found-error';
import { routeTree } from './routeTree.gen';
import { loadCookiesFn } from './utils/cookies';

z.config(z.locales.de());

configureWebClient({
  baseUrl: import.meta.env.VITE_API_URL,
  credentials: 'include',
});

// Global flag to prevent interceptor registration on every SSR request
let interceptorRegistered = false;

// Add cookies per-request (needed for SSR - server needs to forward cookies from incoming request)
// On the server, loadCookiesFn() reads from request headers; on client, it reads from browser cookies
if (!interceptorRegistered) {
  getWebClient().interceptors.request.use(async (request) => {
    const cookies = loadCookiesFn();
    if (cookies) {
      // Merge with existing Cookie header if present
      const existingCookie = request.headers.get('Cookie');
      if (existingCookie) {
        request.headers.set('Cookie', `${existingCookie}; ${cookies}`);
      } else {
        request.headers.set('Cookie', cookies);
      }
    }
    return request;
  });
  interceptorRegistered = true;
}

export function getRouter() {
  const queryClient = new QueryClient(queryClientConfig);

  const router = createTanStackRouter({
    routeTree,
    context: {
      queryClient,
      user: null,
      session: null,
      organizations: [],
      avatarUrl: null,
    },
    defaultPreload: 'intent',
    defaultPreloadStaleTime: 0,
    defaultErrorComponent: () => <GeneralError />,
    defaultNotFoundComponent: () => <NotFoundError />,
    scrollRestoration: true,
  });

  if (!router.isServer) {
    Sentry.init({
      ...
    });
  }
  setupRouterSsrQueryIntegration({
    router,
    queryClient,
  });
  return router;
}

export const Route = createRootRouteWithContext<RouterContext>()({
  validateSearch: z.object({
    preview: z.boolean().optional(),
  }),
  beforeLoad: async ({ context }) => {
    const session = await prefetchSession(context.queryClient);

    return {
      user: session.data?.user ?? null,
      session: session.data?.session ?? null,
    };
  },
  head: () => ({
...

// Detect if running on server (SSR) or client
const isServer = typeof window === 'undefined';

export const queryClientConfig: QueryClientConfig = {
  defaultOptions: {
    queries: {
      // dehydrate: { serializeData: superjson.serialize },
      // hydrate: { deserializeData: superjson.deserialize },
      staleTime: STALE_TIME,
      // Aggressive GC on server to prevent memory accumulation across SSR requests
      // Client can keep longer cache for better UX
      gcTime: isServer ? 1000 * 60 * 1 : 1000 * 60 * 10, // 1min SSR, 10min client
      placeholderData: keepPreviousData,

      retry: (failureCount, error) => {
        if (failureCount >= 2) return false;
        if (failureCount >= 0 && import.meta.env.DEV) return false;
        if (error.message?.includes('401') || error.message?.includes('403'))
          return false;
        return true;
      },
      // refetchOnWindowFocus: import.meta.env.PROD,
      // retry: 0,
      refetchOnWindowFocus: false,
    },
    mutations: {
      // Shorter GC time on server for mutations as well
      gcTime: isServer ? 1000 * 60 * 1 : 1000 * 60 * 5, // 1min SSR, 5min client
      onError: (error) => {
        const schemaCheck = ApiResponseFailureSchema.safeParse(error);
        if (schemaCheck.success) {
          if (schemaCheck.data.error instanceof ZodError) {
            toast.error(
              z.prettifyError(schemaCheck.data.error) ??
                'An unknown error occurred'
            );
            return;
          }
          toast.error(schemaCheck.data.error.title, {
            description: schemaCheck.data.error.detail,
          });
          return;
        }
        toast.error(error.message ?? 'An unknown error occurred');
      },
    },
  },
}

The way i use beforeLoad and loader (beforeLoad for guarding and loader for data prefetching)


export const Route = createFileRoute('/manage')({
  component: RouteComponent,
  beforeLoad: async ({ context }) => {
    if (!context.user || !context.session) {
      throw redirect({ to: '/auth/sign-in' });
    }
    const organizationId = context.session.activeOrganizationId;

    if (!organizationId) {
      throw redirect({ to: '/' });
    }

    // Pre-fetch organization and member in parallel
    const [
      organizationResponse,
      organizationSettingsResponse,
      venuesResponse,
      eventsResponse,
    ] = await Promise.all([
      context.queryClient.ensureQueryData(
        getOrganizationOptions({
          path: { organizationId },
        })
      ),
      context.queryClient.ensureQueryData(
        getOrganizationSettingsOptions({
          path: { organizationId },
        })
      ),
      context.queryClient.ensureQueryData(
        listVenuesOptions({
          query: { organizationId, pageIndex: 0, pageSize: 10 },
        })
      ),
      context.queryClient.ensureQueryData(
        listEventsOptions({
          query: { organizationId, pageIndex: 0, pageSize: 10 },
        })
      ),
    ]);

    const organization = {
      ...organizationResponse.data,
      metadata: organizationResponse.data.metadata,
    };

    return {
      user: context.user,
      session: context.session,
      organizationId,
      organization,
      organizationSettings: organizationSettingsResponse.data,
      venues: venuesResponse.data.items,
      events: eventsResponse.data.items,
      logoUrl: organization.imageUrl ?? null,
      mainEntry: {
        label: organization.name || m.organization_info_title(),
        href: '/manage/dashboard',
      } as HeaderBreadcrumbItem,
    };
  },
  loader: async () => {
    return {
      sidebarReferenceContext: {
        type: 'organization',
        id: null,
        name: null,
      } as SidebarReferenceContext,
    };
  },
});

export const Route = createFileRoute('/manage/venues/')({
  component: RouteComponent,
  validateSearch: zListVenuesData.shape.query
    .omit({ organizationId: true })
    .nonoptional(),
  loaderDeps: ({ search }) => search,
  loader: async ({ context, deps }) => {
    await context.queryClient.ensureQueryData(
      listVenuesOptions({
        query: {
          ...deps,
          organizationId: context.organizationId,
        },
      })
    );
    return {
      organizationId: context.organizationId,
      breadcrumb: [
        {
          label: m.venues_title(),
          href: '/manage/venues',
        },
      ] as HeaderBreadcrumbItem[],
    };
  },
  head: ({ match }) => ({
    meta: createMetadata({
      title: m.venues_title(),
      url: match.pathname.toString(),
    }),
  }),
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions