Streaming + Suspense cheatsheet.

What it does

Server starts sending HTML before all data is ready. Browser shows shell + fallbacks; data fills in as it arrives.

Result: lower TTFB, faster FCP, better perceived speed.

loading.tsx (route-level)

// app/posts/loading.tsx
export default function Loading() {
  return <Skeleton />;
}

Wraps the segment in Suspense automatically. Shows while server-side data fetches.

Manual Suspense

// app/page.tsx
import { Suspense } from "react";

export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<UserSkel />}>
        <User />
      </Suspense>
      <Suspense fallback={<FeedSkel />}>
        <Feed />
      </Suspense>
    </>
  );
}

async function User() {
  const u = await fetchUser();
  return <p>{u.name}</p>;
}

async function Feed() {
  const f = await fetchFeed();
  return <ul>{f.map(...)}</ul>;
}

<Header> flushes immediately. User and Feed stream independently.

Parallel fetching

// BAD: sequential
async function Page() {
  const user = await fetchUser();
  const posts = await fetchPosts();
  ...
}

// GOOD: parallel
async function Page() {
  const userP = fetchUser();
  const postsP = fetchPosts();
  const [user, posts] = await Promise.all([userP, postsP]);
  ...
}

Even better: separate async components in separate Suspense boundaries.

Streaming with separate components

export default function Page() {
  return (
    <>
      <Suspense fallback={<UserSkel />}>
        <User />          {/* fetches user */}
      </Suspense>
      <Suspense fallback={<PostsSkel />}>
        <Posts />         {/* fetches posts */}
      </Suspense>
    </>
  );
}

Each component starts its fetch immediately, in parallel, and streams when ready.

Avoiding waterfall

// BAD: child waits for parent's fetch
async function Parent() {
  const data = await fetchParent();
  return <Child data={data} />;
}

async function Child({ data }) {
  const more = await fetchChild(data.id);   // sequential
}

// GOOD: kick off both fetches early
async function Page() {
  const parentP = fetchParent();
  const parent = await parentP;
  const childP = fetchChild(parent.id);     // can start here
  const child = await childP;
}

If you can’t parallelize (child depends on parent), at least stream independently.

error.tsx + Suspense

// app/posts/error.tsx
"use client";
export default function Error({ error, reset }) {
  return <p>error: {error.message}</p>;
}

Catches errors thrown from suspended children.

Nested Suspense

<Suspense fallback={<PageSkel />}>
  <Layout>
    <Suspense fallback={<SidebarSkel />}>
      <Sidebar />
    </Suspense>
    <Suspense fallback={<MainSkel />}>
      <Main />
    </Suspense>
  </Layout>
</Suspense>

Outer shell renders first, then nested fall back to skeletons, then content.

Streaming with Server Actions

async function action() {
  "use server";
  await saveData();
  revalidatePath("/");
}

Stream + actions compose: page renders with old data, action triggers, revalidate streams new HTML.

Skeleton design

function UserSkel() {
  return (
    <div className="space-y-2">
      <div className="h-4 w-32 bg-gray-200 animate-pulse rounded" />
      <div className="h-3 w-48 bg-gray-200 animate-pulse rounded" />
    </div>
  );
}

Match dimensions of real content to avoid layout shift.

When Suspense doesn’t help

  • Sequential data dependency (waterfall) — refactor first.
  • Fast fetches (< 100ms) — shell+fallback flicker isn’t worth it.
  • Static pages — no benefit; content is ready immediately.

Suspense + client components

"use client";
import { useSuspenseQuery } from "@tanstack/react-query";

function User({ id }: { id: number }) {
  const { data } = useSuspenseQuery({
    queryKey: ["user", id],
    queryFn: () => fetchUser(id),
  });
  return <p>{data.name}</p>;
}

Wrap in <Suspense> higher up. Throws-on-pending integrates with React’s mechanism.

Streaming and OG/Twitter

Crawlers usually wait for the full response. Streaming doesn’t affect SEO since servers still send 200 OK + full HTML eventually.

Common mistakes

  • One giant Suspense at root — all-or-nothing loading.
  • Skeleton with different dimensions than content → CLS.
  • Forgetting error.tsx — uncaught suspense errors crash everything.
  • Sequential await chain → no streaming benefit.
  • Async client component (not allowed; use useSuspenseQuery or use()).

Read this next

If you want my streaming + suspense templates, they’re at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .