afterbuild/ops
ERR-571/Next.js · App Router
ERR-571
Page stuck on loading.tsx — awaited promise never resolves

appears when:When a server component awaits a fetch or database query that never resolves, and no AbortSignal timeout forces it to fail

Next.js App Router stuck loading

Next.js suspends the page while any awaited promise is pending. If the promise never resolves, loading.tsx shows forever — even if the user gives up and reloads.

Last updated 17 April 2026 · 7 min read · By Hyder Shah
Direct answer
Stuck loading means an async operation in your server component never resolves. Pass AbortSignal.timeout(5000) into every fetch so the request fails after 5 seconds, wrap each data-dependent section in its own Suspense boundary so one stuck fetch does not freeze the whole page, and add error.tsx to turn the timeout into a visible error with a retry button.

Quick fix for App Router stuck loading

app/dashboard/page.tsx
typescript
01// app/dashboard/page.tsx — server component with timeout and error handling02import { notFound } from "next/navigation";03 04async function getStats() {05  const res = await fetch("https://api.example.com/stats", {06    signal: AbortSignal.timeout(5000), // 5-second hard timeout07    cache: "no-store",08  });09  if (!res.ok) {10    if (res.status === 404) notFound();11    throw new Error(`stats fetch failed: ${res.status}`);12  }13  return res.json() as Promise<Stats>;14}15 16export default async function DashboardPage() {17  const stats = await getStats(); // timeout error bubbles to error.tsx18  return <StatsView stats={stats} />;19}20 21// app/dashboard/error.tsx — catches the AbortError22"use client";23export default function Error({24  error,25  reset,26}: {27  error: Error & { digest?: string };28  reset: () => void;29}) {30  return (31    <div>32      <p>Dashboard could not load. {error.message}</p>33      <button onClick={reset}>Try again</button>34    </div>35  );36}
Server component + timeout + error.tsx — the three pieces that turn a stuck page into a recoverable error

Deeper fixes when the quick fix fails

01 · Parallel data fetching with Promise.all

app/dashboard/page.tsx
typescript
01// app/dashboard/page.tsx — fetch in parallel, timeout each independently02async function fetchWithTimeout<T>(url: string, ms = 5000): Promise<T> {03  const res = await fetch(url, { signal: AbortSignal.timeout(ms), cache: "no-store" });04  if (!res.ok) throw new Error(`${url} -> ${res.status}`);05  return res.json() as Promise<T>;06}07 08export default async function DashboardPage() {09  const [stats, users, invoices] = await Promise.all([10    fetchWithTimeout<Stats>("https://api/stats"),11    fetchWithTimeout<User[]>("https://api/users"),12    fetchWithTimeout<Invoice[]>("https://api/invoices"),13  ]);14 15  return <Dashboard stats={stats} users={users} invoices={invoices} />;16}
Parallel fetches cut page load from sum-of-waits to max-of-waits

02 · Independent Suspense per widget

app/dashboard/page.tsx
typescript
01// app/dashboard/page.tsx — each widget loads independently02import { Suspense } from "react";03 04export default function DashboardPage() {05  return (06    <div className="grid gap-4">07      <Suspense fallback={<StatsSkeleton />}>08        <StatsWidget />09      </Suspense>10      <Suspense fallback={<UsersSkeleton />}>11        <UsersWidget />12      </Suspense>13      <Suspense fallback={<InvoicesSkeleton />}>14        <InvoicesWidget />15      </Suspense>16    </div>17  );18}19 20// Each widget is its own async server component with its own fetch + timeout.21// A stuck StatsWidget does not block UsersWidget from rendering.
Progressive reveal — user sees the working widgets immediately, broken ones fall to error state

03 · Playwright regression test for stuck routes

tests/route-loading.spec.ts
typescript
01// tests/route-loading.spec.ts02import { test, expect } from "@playwright/test";03 04const ROUTES = ["/", "/dashboard", "/settings", "/users", "/billing"];05 06for (const route of ROUTES) {07  test(`${route} loads within 10 seconds`, async ({ page }) => {08    const start = Date.now();09    await page.goto(route, { waitUntil: "networkidle", timeout: 10_000 });10    const elapsed = Date.now() - start;11    expect(elapsed).toBeLessThan(10_000);12 13    // No stuck loader14    const loader = page.locator('[aria-busy="true"]');15    await expect(loader).toHaveCount(0, { timeout: 15_000 });16  });17}
CI gate — any route that stops loading catches before users see it

Why AI-built apps hit App Router stuck loading

Next.js App Router renders server components by awaiting every async expression in the component body. The HTTP response is streamed to the client as each subtree resolves. While any await is pending, the subtree shows the nearest loading.tsx fallback. This is normal behavior for a few hundred milliseconds. It becomes a bug when the awaited promise never resolves — the fallback renders indefinitely, the user waits, and the only recovery is a platform-level timeout killing the request 10 or 300 seconds in.

The top cause is fetch without a timeout. By default, fetch in Node waits for the OS to give up on the TCP connection. If the downstream service is slow, overloaded, or partially disconnected (happens in multi-AZ deployments when one zone has network issues), the fetch can hang for minutes. Every Next.js server component that does network I/O needs signal: AbortSignal.timeout(N) in the fetch options. Pick N based on how long you are willing to make the user wait — usually 3-10 seconds.

The second cause is database connection leaks. Prisma, Drizzle, and node-postgres all maintain a pool. If the pool is exhausted — too many concurrent requests, slow queries holding connections, a killed-but-still-registered connection — new queries wait for a slot. Without a query timeout, they wait forever. Prisma has a connection_timeout URL parameter and a per-query timeout option; use them. Postgres has a statement_timeout server-side setting — 30 seconds is a safe default for interactive pages.

The third cause is Suspense scope. If you have one Suspense boundary wrapping the whole page body, any stuck fetch anywhere in the tree shows the fallback for the entire page. You cannot tell which section is broken, and you cannot render the working sections while the broken one hangs. Tight Suspense — one per data-dependent section — turns the stuck-page problem into a stuck-widget problem, which is far easier to debug and less user-hostile.

The fourth cause, Next.js-specific, is a searchParams dependency that causes infinite re-renders. If a client component reads useSearchParams and triggers a navigation that updates those same params, the render loop never settles and the route stays in the loading state. Audit client components for router.push calls inside effects — they are usually the trigger.

App Router stuck loading by AI builder

How often each AI builder ships this error and the pattern that produces it.

AI builder × App Router stuck loading
BuilderFrequencyPattern
LovableEvery server component fetchfetch() with no signal, no timeout — hangs on slow downstream
Bolt.newCommonOne <Suspense> wrapping the entire page — one stuck widget freezes everything
CursorCommonNo error.tsx — users see loading forever instead of a recoverable error
Base44SometimesuseEffect in client component triggers router.push on searchParams read — loop
Replit AgentRareSequential awaits where Promise.all would suffice — page takes sum of all fetches

Related errors we fix

Stop App Router stuck loading recurring in AI-built apps

Still stuck with App Router stuck loading?

Emergency triage · $299 · 48h turnaround
We restore service and write the root-cause report.
start the triage →

App Router stuck loading questions

Why does loading.tsx show forever on one page?+
A Next.js App Router page suspends while any awaited promise in its server component is pending. The loading.tsx file shows as the fallback during that suspense. If any promise never resolves — a fetch to a dead endpoint, a database query against a broken connection, or a wait on an external service that no longer exists — the page stays on loading.tsx indefinitely. The browser network tab shows the request still pending 30, 60, 300 seconds in.
How do I add a timeout to a server component fetch?+
Use AbortController with AbortSignal.timeout(5000). Pass the signal into fetch. If the request does not complete in 5 seconds, the fetch throws AbortError and the page can render an error state via error.tsx or a try/catch inside the server component. Without a timeout, fetch waits up to the platform's hard limit (Vercel serverless: 10 seconds default, up to 300 seconds configured). A stuck downstream service freezes every request to the page.
Why does Suspense make debugging stuck loads harder?+
Suspense boundaries isolate loading states per subtree. A page with three sections and one Suspense around each will show three loaders, each independent. A page with one Suspense wrapping the entire body shows one loader for the whole page — and you cannot tell which section is stuck. The fix is to scope Suspense boundaries tightly: wrap each async data fetch in its own Suspense, so the stuck section is visually obvious and the rest of the page renders normally.
What does error.tsx do and when does it fire?+
error.tsx is a React error boundary scoped to the route segment. It catches exceptions thrown during render or data fetching in that segment. It does NOT fire for stuck promises that never reject — a fetch waiting on a dead server will sit in loading.tsx until the platform kills it. Combine error.tsx with AbortSignal.timeout so stuck fetches become AbortError rejections that error.tsx can render. Without a timeout, error.tsx cannot help you.
How long does an Afterbuild Labs App Router audit take?+
For a Next.js App Router app with 20-50 pages, auditing every server component for timeout, Suspense scoping, and error handling takes 2-3 hours. Our Break the Fix Loop service includes the audit, AbortSignal.timeout added to every fetch, scoped Suspense boundaries, error.tsx files at every segment, and a Playwright test that asserts every route loads within 10 seconds.
Next step

Ship the fix. Keep the fix.

Emergency Triage restores service in 48 hours. Break the Fix Loop rebuilds CI so this error cannot ship again.

About the author

Hyder Shah leads Afterbuild Labs, shipping production rescues for apps built in Lovable, Bolt.new, Cursor, Replit, v0, and Base44. our rescue methodology.

Sources