Next.js App Router in Production · Streaming, Security and Operations

Streaming, Parallel Data Loading and Error Reporting

Avoid request waterfalls with parallel Suspense boundaries, handle errors with error boundaries, and report server errors with onRequestError.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A dashboard whose revenue and orders widgets load in parallel in separate Suspense boundaries, compared with a waterfall version that awaits them one after the other; and a reports page whose failing widget is caught by an error boundary and reported through instrumentation.ts's onRequestError hook.

The idea

  • Each <Suspense> boundary streams independently; sibling async components start their work at the same time.
  • Awaiting data one call after another in one component creates a waterfall: total time is the sum of the calls. Start independent work together (separate components, or Promise.all).
  • error.tsx (or catchError from next/error) defines an error boundary for a segment; it receives error and retry. In production, server error messages aren't sent to the browser, only a digest.
  • onRequestError in instrumentation.ts runs for every server error, the place to send errors to your monitoring service.
  • After streaming has started the status code stays 200; the error UI replaces the fallback in the browser.

The code

Parallel boundaries, a waterfall, and a reported error
// file: next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true };
export default nextConfig;

// file: app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body><main>{children}</main></body>
    </html>
  );
}

// file: app/lib/data.ts
import { connection } from "next/server";

export async function load<T>(label: string, ms: number, value: T): Promise<T> {
  await connection();                                  // per-request data
  console.log(`[app] ${label} started`);
  await new Promise((resolve) => setTimeout(resolve, ms));
  console.log(`[app] ${label} done`);
  return value;
}

// file: app/dashboard/page.tsx
import { Suspense } from "react";
import { load } from "../lib/data";

async function Revenue() {
  return <p>Revenue: ${await load("revenue", 200, 12400)}</p>;
}
async function Orders() {
  return <p>Orders: {await load("orders", 400, 87)}</p>;
}

export default function Page() {
  return (
    <>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading revenue...</p>}><Revenue /></Suspense>
      <Suspense fallback={<p>Loading orders...</p>}><Orders /></Suspense>
    </>
  );
}

// file: app/waterfall/page.tsx
import { Suspense } from "react";
import { load } from "../lib/data";

async function Summary() {
  const revenue = await load("revenue", 200, 12400);   // orders waits for revenue to finish
  const orders = await load("orders", 400, 87);
  return <p>Revenue: ${revenue}, orders: {orders}</p>;
}

export default function Page() {
  return <Suspense fallback={<p>Loading...</p>}><Summary /></Suspense>;
}

// file: app/reports/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";

async function ReportTable(): Promise<React.ReactNode> {
  await connection();
  throw new Error("reports database timed out");
}

export default function Page() {
  return (
    <>
      <h1>Reports</h1>
      <Suspense fallback={<p>Loading report...</p>}><ReportTable /></Suspense>
    </>
  );
}

// file: app/reports/error.tsx
"use client";

export default function Error({ retry }: { error: unknown; retry: () => void }) {
  return (
    <>
      <h2>The report couldn't be loaded</h2>
      <button onClick={() => retry()}>Try again</button>
    </>
  );
}

// file: instrumentation.ts
import type { Instrumentation } from "next";

export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
  // Send to your error tracker here; this example logs one line.
  const message = error instanceof Error ? error.message : String(error);
  console.log(`[app] ${request.method} ${request.path} failed in ${context.routePath}: ${message}`);
};

// request: GET /dashboard
// request: GET /waterfall
// request: GET /reports

Output

GET /dashboard -> 200
[app] revenue started
[app] orders started
[app] revenue done
[app] orders done
Dashboard
Revenue: $12400
Orders: 87
GET /waterfall -> 200
[app] revenue started
[app] revenue done
[app] orders started
[app] orders done
Revenue: $12400, orders: 87
GET /reports -> 200
[app] GET /reports failed in /reports: reports database timed out
Reports
Loading report...

Run this on your computer — see “Get Started” for setup.

On /dashboard both loads start before either finishes (about 400 ms in total); on /waterfall orders only starts after revenue is done (about 600 ms). On /reports, the error is reported with its route; the HTML keeps the fallback and the browser renders error.tsx in its place.

Common mistakes

  • Sequential awaits for independent data, creating waterfalls.
  • One Suspense boundary around the whole page, so the fastest data waits for the slowest.
  • Catching every error in components and rendering nothing, hiding failures from monitoring.
  • Showing error.message to users: in production it's replaced by a generic message and a digest; log the details on the server instead.

Key points

  • Independent data loads in parallel: separate Suspense boundaries or Promise.all.
  • error.tsx and catchError provide error boundaries with retry.
  • Report server errors centrally with onRequestError.

Further reading: Streaming · Error handling · instrumentation.js

Practice questions

How do you avoid a data-fetching waterfall in Server Components?

Answer

Start independent requests at the same time: put them in separate components (each in its own Suspense boundary) or await them together with Promise.all.

What props does an error.tsx component receive?

Answer

The error (in production, server errors carry only a digest) and a retry function that re-renders the segment.

What is onRequestError for?

Answer

It's an instrumentation hook called for server errors with the request and route context, used to report errors to monitoring services.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?