Load One Async Component in TanStack Start Without Blocking the Entire Page

Got a dashboard where one slow API call makes the whole page feel sluggish? Here's how to use Suspense and useSuspenseQuery in TanStack Start so only the slow part shows a loading skeleton, everything else renders instantly.

Load One Async Component in TanStack Start Without Blocking the Entire Page

I was building a dashboard and hit a familiar problem. The stats overview loaded in 2ms. The analytics chart took 300ms. But because I was fetching everything together, the entire page sat there showing a spinner until the slowest request finished. Users saw a blank screen for 300ms, which doesn’t sound like much until you realize they could already be reading the stats.

The fix turned out to be one of those things that’s obvious in hindsight but felt like a breakthrough when I figured it out: stop fetching everything together. Use component-level Suspense boundaries so only the slow component waits.


The Problem

You’ve got a dashboard like this:

  • A statistics overview loads in 2ms
  • A heavy analytics chart loads in 300ms

Fetch everything in one shot and users stare at a loading state until the 300ms request completes. Even though the stats were ready instantly.


The Fix: Suspense + useSuspenseQuery

TanStack Start supports streaming SSR and fine-grained loading states. The trick is wrapping only the slow component in a <Suspense> boundary.

The Page Component

// app/routes/dashboard.tsx
import { Suspense } from 'react';
import { FastStats } from './components/FastStats';
import { SlowAnalytics } from './components/SlowAnalytics';

export default function Dashboard() {
  return (
    <div className="space-y-8 p-6">
      <h1 className="text-3xl font-bold">Dashboard</h1>

      {/* Fast component renders immediately */}
      <FastStats />

      {/* Only this section waits */}
      <Suspense fallback={<AnalyticsSkeleton />}>
        <SlowAnalytics />
      </Suspense>

      {/* Other components continue rendering */}
      <QuickLinks />
    </div>
  );
}

The Slow Component

// components/SlowAnalytics.tsx
import { useSuspenseQuery } from '@tanstack/react-query';

async function fetchAnalytics() {
  // Simulate real API delay
  await new Promise((resolve) => setTimeout(resolve, 300));
  
  const res = await fetch('/api/analytics');
  return res.json();
}

export function SlowAnalytics() {
  const { data } = useSuspenseQuery({
    queryKey: ['analytics'],
    queryFn: fetchAnalytics,
  });

  return (
    <div className="bg-white rounded-xl shadow p-6">
      <h2 className="text-xl font-semibold mb-4">Analytics Overview</h2>
      {/* Render your charts, tables, etc. */}
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

function AnalyticsSkeleton() {
  return (
    <div className="bg-white rounded-xl shadow p-6 animate-pulse">
      <div className="h-6 bg-gray-200 rounded w-1/3 mb-6"></div>
      <div className="space-y-4">
        <div className="h-40 bg-gray-100 rounded"></div>
      </div>
    </div>
  );
}

The Fast Component

// components/FastStats.tsx
import { useQuery } from '@tanstack/react-query';

export function FastStats() {
  const { data } = useQuery({
    queryKey: ['stats'],
    queryFn: async () => {
      await new Promise((r) => setTimeout(r, 2));
      return { users: 1243, revenue: 45820 };
    },
  });

  return (
    <div className="grid grid-cols-3 gap-4">
      {/* Stats cards */}
    </div>
  );
}

Notice the difference: FastStats uses regular useQuery. SlowAnalytics uses useSuspenseQuery. That’s what tells React to suspend this component and show the fallback while data loads.

Why This Works So Well in TanStack Start

  • Streaming SSR: The server sends HTML for the fast parts immediately. The slow component streams in when its data is ready.
  • useSuspenseQuery: automatically suspends the component until data arrives, so no manual loading state management.
  • Independent Loading: Only the wrapped component shows a skeleton. The rest of the UI stays interactive.
  • Users don’t wait: They can start reading stats and clicking around while the analytics chart loads.

Things I Learned the Hard Way

Place <Suspense> boundaries as close to the slow component as possible. If you wrap your entire page in one Suspense: you’re back to square one: everything waits for the slowest thing.

Handle errors too. Suspense catches loading states: not errors. Wrap with <ErrorBoundary> or use TanStack Router’s errorComponent so a failed fetch doesn’t white-screen the whole page.

Multiple slow components? Multiple boundaries:

<Suspense fallback={<SkeletonA />}>
  <ComponentA />
</Suspense>
<Suspense fallback={<SkeletonB />}>
  <ComponentB />
</Suspense>

Server components can also return promises directly. You can use React.use() with Suspense for that pattern too.

Prefetching makes it even faster. Use queryClient.prefetchQuery() in route loaders so data starts loading before the component even mounts:

// In your route loader
queryClient.prefetchQuery({
  queryKey: ['analytics'],
  queryFn: fetchAnalytics,
});

By the time the component renders, the data might already be there.


When This Pattern Comes Up

This isn’t just for dashboards. Any page with mixed-speed data is a candidate, product pages where the price loads fast but reviews take a beat, profile pages where the user info is instant but activity history isn’t, settings pages where config loads quickly but a billing API call is slow.

The mental model is simple: instead of thinking of your page as one unit that either loads or doesn’t, think of it as independent async components. Each one handles its own loading state. The fast parts render immediately. The slow parts show skeletons. Nobody waits for anyone else.

Member discussion

0 comments

Start the conversation

Become a member of >hacksubset_ to start commenting.