Error Handling and Loading States
- react
- nextjs
- typescript
- javascript
- nestjs
- webdev
- frontend
- errorhandling
In Core Web Vitals for React Apps, the focus was on making an app feel fast when everything goes right. This article is about the other half of that experience: what a user sees while something is happening, and what they see when it doesn't happen the way you expected. It's part of the React & Next.js Complete Guide series.
Most teams treat loading and error states as an afterthought, bolted onto a component after the happy path works. That ordering produces the exact symptom you'd expect: a dozen slightly different spinners across the app, error messages ranging from genuinely helpful to a raw stack trace leaking to production, and no consistent answer to what a user sees when the API is slow, down, or returns something unanticipated. It's a strategy problem. It just shows up in React, because that's where the user notices it.
This piece assumes you already know how error boundaries and Suspense work mechanically, since that's covered in Error Boundaries and Suspense. What I want to cover here is the layer above the mechanics: how the backend should shape errors so the frontend can act on them intelligently, how Next.js route conventions fit into the picture, and where loading state complexity is actually worth the effort versus where a plain spinner is the correct, boring choice.
Why a shared error contract matters more than any single component
The most common failure mode is more basic than a missing error boundary: a frontend with no reliable way to tell the difference between "the user's input was invalid," "the user isn't allowed to do this," and "something on the server broke." If every failure surfaces as the same generic 500-shaped blob, the frontend ends up parsing error message strings to decide what to render, which breaks the moment someone rewords a message on the backend.
The fix is deciding on an error shape once, at the API boundary, and enforcing it everywhere responses leave the backend. A NestJS exception filter is the natural place to do this, since it runs for every unhandled exception regardless of which controller threw it:
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from "@nestjs/common";
import { Response } from "express";
interface ApiErrorBody {
code: string;
message: string;
details?: Record<string, unknown>;
}
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const isHttpException = exception instanceof HttpException;
const status = isHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const body: ApiErrorBody = isHttpException
? this.shapeKnownError(exception)
: { code: "internal_error", message: "Something went wrong on our end." };
if (!isHttpException) {
// Log the real exception; never send its details to the client.
this.logger.error(exception instanceof Error ? exception.stack : exception);
}
response.status(status).json(body);
}
private shapeKnownError(exception: HttpException): ApiErrorBody {
const payload = exception.getResponse();
if (typeof payload === "object" && payload !== null && "code" in payload) {
return payload as ApiErrorBody;
}
return { code: "request_error", message: exception.message };
}
}
The code field is the part that matters. It's a stable, machine-readable identifier the frontend can switch on, independent of whatever human-readable message a copywriter changes next sprint. insufficient_permissions and validation_failed should render completely different UI: one is a dead end with an explanation, the other should point at the specific field that failed. Neither should look like the generic "Something went wrong" state reserved for the unexpected case.
The tradeoff worth naming: this pays off once you have more than one frontend client, or a frontend team that isn't the same person writing the backend. For a solo project hitting your own API, matching on HTTP status codes alone is a defensible shortcut. Once a second consumer shows up, or the API is public, the stable code field stops being optional.
Loading states: match the mechanism to what's actually happening
Next.js App Router gives you loading.tsx as a file-based Suspense fallback for an entire route segment, which streams in while the segment's data is being fetched on the server. That's the right tool for a full page or route transition, a case where there genuinely isn't anything meaningful to show yet.
// app/dashboard/invoices/loading.tsx
export default function InvoicesLoading() {
return (
<div className="invoice-list-skeleton" aria-busy="true">
<div className="skeleton-row" />
<div className="skeleton-row" />
<div className="skeleton-row" />
</div>
);
}
Where teams overcorrect is applying that same route-level thinking to every component that fetches something. A widget refreshing its own data after the initial page load doesn't need to blank itself out and show a skeleton again; it already has content on screen, and replacing it with a placeholder for a sub-second refetch reads as broken, not polished. That's a case for a local pending indicator, an opacity dim, a small inline spinner near the affected control, something that says "updating" without discarding what the user was already looking at.
The distinction to hold onto: a skeleton communicates "this doesn't exist yet, wait for it to appear." A subtle inline indicator communicates "this exists, it's just being refreshed." Using the first pattern for the second situation is the single biggest source of loading-state flicker I've seen in production dashboards, and it's almost always fixed by tracking isFetching separately from isLoading if you're on a library like React Query, rather than treating every request as a first-load event.
Route-level errors versus caught errors
error.tsx in the App Router is the framework's own error boundary for a route segment, distinct from a hand-rolled ErrorBoundary component. It catches errors thrown during rendering of that segment, including ones that happen during a server-to-client transition, and Next.js automatically wraps each segment with one:
// app/dashboard/invoices/error.tsx
"use client";
export default function InvoicesError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="route-error" role="alert">
<h2>Couldn't load invoices</h2>
<p>Try again, or contact support if this keeps happening.</p>
<button onClick={reset}>Retry</button>
</div>
);
}
The digest property is worth calling out specifically. Next.js strips the actual error message and stack from what reaches the client in production and replaces it with an opaque digest you can correlate against server logs. That's deliberate: an unhandled server exception can contain details you don't want a browser tab to see, and the framework isn't going to trust every catch block in your codebase to redact that correctly on its own.
The practical rule I use for choosing between a route-level error.tsx and a component-level error boundary from the previous article: route-level catches the case where the whole segment can't render at all, component-level catches a failure inside one widget on a page that otherwise renders fine. Both matter, and they're not substitutes for each other; a route-level catch that's too broad ends up blanking out an entire dashboard because one card's data fetch failed.
Retrying without making things worse
A naive retry, firing the same request again after a fixed delay, works fine for a GET that failed because of a transient network blip. It's dangerous for a POST that isn't idempotent: retrying a failed payment charge or a failed "send invoice" action can execute it twice if the first attempt actually succeeded server-side and only the response got lost.
The safer default for mutations is to make retries the exception, not the rule, and only automatic for requests that are genuinely safe to repeat:
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
},
mutations: {
// No automatic retry by default; opt in per mutation where the
// operation is known to be idempotent (e.g. an idempotency-key-backed POST).
retry: false,
},
},
});
If a mutation genuinely needs retry safety, the correct fix is an idempotency key generated on the client and enforced on the server, not a client-side retry loop. A duplicate request with the same key becomes a no-op instead of a second charge. That's more backend work upfront, but it makes the retry safe by construction instead of safe by accident.
Production pitfalls
A few patterns worth watching for once this ships to real users:
Swallowing errors silently to keep the UI clean is worse than showing a plain message. A catch block that only updates local state to hide the failure, without logging it anywhere, means your team finds out about a broken integration from a support ticket instead of monitoring, usually days later.
Generic "Something went wrong" messages for errors you actually know the cause of waste the one thing you have: a code from the backend that could have told the user exactly what to fix. Reserve the generic fallback for the case that's actually generic, not a validation error you have full information about.
Loading indicators that appear for requests fast enough to resolve before a human perceives them create more flicker than they prevent confusion. A short delay before showing a spinner, a couple hundred milliseconds, avoids the indicator flashing on screen for requests already fast enough not to need one.
Key takeaways
- Define a stable, machine-readable error shape (a `code` field, not just an HTTP status or a message string) at the API boundary, and enforce it with a single exception filter rather than per-endpoint formatting.
- Match the loading mechanism to the situation: route-level skeletons for content that doesn't exist yet, subtle inline indicators for content that's being refreshed.
- Use `error.tsx` for a route segment that can't render at all, and a component-level error boundary for a single widget failing inside a page that otherwise works.
- Retry idempotent `GET` requests automatically with backoff; treat mutation retries as opt-in and back them with an idempotency key rather than a blind resend.
- Don't swallow errors to keep a UI looking clean. Log every caught error the same way you'd log an uncaught one.
- Delay showing a loading indicator by a short, fixed amount so fast responses don't produce a visible flicker.
Frequently asked questions
What's the difference between error.tsx and an error boundary component?
`error.tsx` is a Next.js App Router convention that automatically wraps a route segment and catches rendering failures during the server-to-client transition for that segment, including a `digest` for correlating with server logs. A hand-rolled error boundary is a React-level construct you place explicitly around any subtree, typically a smaller unit like a widget, and it works the same regardless of framework.
Should every API error include a stack trace for debugging?
No, not in the response body a browser receives. Log the full stack trace server-side and return only a stable error code, a safe message, and (where relevant) a correlation id the client can send back to support. Leaking stack traces to the client both risks exposing internal details and gives the frontend nothing structured to act on.
How long should I wait before showing a loading spinner?
Long enough that a fast response never triggers it, short enough that a genuinely slow response doesn't leave the user staring at a static screen wondering if anything happened. A delay in the low hundreds of milliseconds before the indicator appears is a reasonable starting point; tune it against your actual API latency rather than guessing.
Is it safe to automatically retry a failed mutation?
Only if the mutation is idempotent, meaning running it twice has the same effect as running it once. For anything that isn't naturally idempotent, like charging a card or sending a notification, back it with an idempotency key on the server before enabling any kind of automatic retry on the client.
Why does my Next.js error page show a generic message instead of my actual error?
Next.js intentionally strips error details in production and replaces them with an opaque digest for security reasons, since an unhandled exception might contain data you don't want reaching the browser. Use the digest to look up the real error in your server logs rather than trying to surface the raw message to users.
Do I need both a backend error code system and Next.js error boundaries?
Yes, they solve different halves of the same problem. The backend error code tells the frontend what happened and what it can safely show the user; the error boundary or error.tsx file decides what part of the UI degrades when rendering that response fails.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.