router icon indicating copy to clipboard operation
router copied to clipboard

Memory Problems with Tanstack Query and SSR

Open jofflin opened this issue 1 month ago โ€ข 3 comments

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(),
    }),
  }),
});

jofflin avatar Dec 09 '25 10:12 jofflin

๐Ÿ“ CodeRabbit Plan Mode

Generate an implementation plan and agent prompts for this issue.

  • [ ] Create Plan
Examples

๐Ÿ”— Related PRs

TanStack/router#5215 - fix: various fixes [merged] TanStack/router#5363 - examples: Add a new TanStack Start example for typed readable streams [merged] TanStack/router#5665 - test(solid-start): fix solid-query when used in ssr [merged] TanStack/router#5837 - docs(solid-start): add start-streaming-data-from-server-functions example [merged] TanStack/router#5896 - fix: memory leaks [merged]

๐Ÿ‘ค Suggested Assignees

  • schiller-manuel
  • fulopkovacs
  • brenelz
  • birkskyum

๐Ÿงช Issue enrichment is currently in early access.

To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

issue_enrichment:
  auto_enrich:
    enabled: false

coderabbitai[bot] avatar Dec 09 '25 10:12 coderabbitai[bot]

I found the Update fix:memory leaks. I will update and see if that resolves the issue

jofflin avatar Dec 09 '25 10:12 jofflin

please let us know if the issue still persists. if yes, then please provide a complete reproducer project as a git repo.

schiller-manuel avatar Dec 09 '25 17:12 schiller-manuel

what was the result of this?

schiller-manuel avatar Dec 20 '25 21:12 schiller-manuel