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.
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
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}Deeper fixes when the quick fix fails
01 · Parallel data fetching with Promise.all
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}02 · Independent Suspense per widget
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.03 · Playwright regression test for stuck routes
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}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.
| Builder | Frequency | Pattern |
|---|---|---|
| Lovable | Every server component fetch | fetch() with no signal, no timeout — hangs on slow downstream |
| Bolt.new | Common | One <Suspense> wrapping the entire page — one stuck widget freezes everything |
| Cursor | Common | No error.tsx — users see loading forever instead of a recoverable error |
| Base44 | Sometimes | useEffect in client component triggers router.push on searchParams read — loop |
| Replit Agent | Rare | Sequential 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
- →Add AbortSignal.timeout to every fetch in a server component — no exceptions.
- →Wrap each data-dependent section in its own Suspense boundary, not one large one.
- →Always ship an error.tsx at every route segment that does data fetching.
- →Use Promise.all to run parallel fetches instead of awaiting sequentially.
- →Add a Playwright CI test that fails if any route takes longer than 10 seconds to load.
Still stuck with App Router stuck loading?
App Router stuck loading questions
Why does loading.tsx show forever on one page?+
How do I add a timeout to a server component fetch?+
Why does Suspense make debugging stuck loads harder?+
What does error.tsx do and when does it fire?+
How long does an Afterbuild Labs App Router audit take?+
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.
Hyder Shah leads Afterbuild Labs, shipping production rescues for apps built in Lovable, Bolt.new, Cursor, Replit, v0, and Base44. our rescue methodology.