A resilient web application is not one that never fails. It is one where a failure stays small: a broken widget instead of a blank page, a slower response instead of an outage, and a clear log entry instead of a mystery. As an independent developer I design, build and maintain what I ship, so resilience is not an abstract quality to me; it decides whether a third-party outage becomes a non-event or a lost weekend.
The four ways web apps actually fail
Most production incidents fall into four groups, and each needs a different defence:
- Bad input. Malformed requests, unexpected API responses, content that breaks an assumption.
- Slow or failing dependencies. A payment provider, email API, CMS or database that times out or returns errors.
- Bad deploys. A change that passed review but breaks in production.
- Abuse and spikes. Bots hammering a form, scrapers, or a sudden rush of real traffic.
Resilient architecture means deciding in advance what happens in each case.
Types at compile time, validation at runtime
TypeScript in strict mode removes a whole class of bugs before they ship. I also enable noUncheckedIndexedAccess, which forces you to handle the case where an array item or record key is missing. But types disappear at runtime. Anything that crosses a boundary into your code is untrusted until it has been parsed:
- request bodies, query strings and route params
- Server Action arguments (they are public HTTP endpoints, whatever the code looks like)
- webhook payloads and third-party API responses
- environment variables and CMS content
A schema library such as Zod or Valibot turns "I assume this shape" into "I have checked this shape". Validate environment variables at startup, so a missing key fails the deploy rather than the first customer who triggers that code path.
// app/api/contact/route.ts
import { after } from 'next/server';
import { z } from 'zod';
const Lead = z.object({
name: z.string().trim().min(1).max(120),
email: z.string().trim().email(),
message: z.string().trim().min(10).max(5000),
});
export async function POST(req: Request) {
const parsed = Lead.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return Response.json({ error: 'Invalid input' }, { status: 400 });
}
await saveLead(parsed.data); // store first: this is the part that must succeed
after(() => notifyByEmail(parsed.data)); // notify after responding; never block on it
return Response.json({ ok: true });
}
The ordering matters as much as the validation. If the email provider is down, the lead is still stored and the visitor still sees a success message. The fragile part runs after the response via Next.js's after function, so its failure never becomes the user's.
Every network call needs a timeout
fetch has no default timeout. One slow upstream can tie up your server and leave users staring at a spinner. Give every outbound call a deadline with AbortSignal.timeout, and retry only when it is safe:
- Retry only idempotent operations. Reading data is safe to repeat. Charging a card or creating an order is not, unless the provider supports idempotency keys, in which case always send one.
- Back off exponentially and add jitter, so a thousand clients do not retry in lockstep and flatten a service that is trying to recover.
- Cap the attempts. Two or three is usually enough; after that, fail fast and fall back.
export async function fetchJSON<T>(url: string, attempts = 3): Promise<T> {
for (let i = 0; ; i++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (res.status >= 500) throw new Error('Upstream error ' + res.status);
if (!res.ok) throw Object.assign(new Error('Request failed'), { fatal: true });
return (await res.json()) as T;
} catch (err) {
if ((err as { fatal?: boolean }).fatal || i === attempts - 1) throw err;
const delay = 2 ** i * 250 + Math.random() * 250; // exponential backoff + jitter
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
For a dependency that fails often, a circuit breaker goes further: after repeated failures it stops calling the service for a while and serves the fallback immediately.
Design for partial failure
The App Router gives you the building blocks for failing small:
- Segment-level error boundaries. An
error.tsxfile wraps a route segment, so an exception in the dashboard's activity feed shows a contained message with a retry, not a crashed app. Addglobal-error.tsxfor the root layout andnot-found.tsxfor missing records. - Suspense and streaming. Wrap slow, non-critical sections in a Suspense boundary. The rest of the page renders immediately, and the slow part streams in when it is ready instead of holding everything hostage.
- Isolate third parties. Reviews widgets, chat bubbles and embeds should live in their own boundaries so their bad day does not become yours.
- Prefer stale data to no data. Cached content that is a few minutes old beats an error page. In Next.js 16, Cache Components with the
use cachedirective,cacheLifeandcacheTaglet you cache at the component or function level and revalidate on demand when content changes.
The same principle applies to visuals. On my own site, the WebGL simulations are a layer on top of ordinary HTML. If a device cannot create a WebGL context, the heavy effects are switched off and the content is unaffected. An adaptive quality governor also lowers simulation quality, and then removes effects one at a time, when a device genuinely struggles. Nothing essential depends on the fanciest part of the stack.
Where code runs: Node.js, edge and data locality
"Move it to the edge" is not automatically faster. An edge function close to the user that makes three round trips to a database on another continent is slower than a regional server sitting next to that database. Put compute near your data, and get global speed from caching prerendered pages and static assets on a CDN.
The framework has moved the same way. In Next.js 16, Middleware was renamed Proxy and runs on the Node.js runtime by default, and recent 16.x releases deprecate the Edge runtime for routes. Keep Proxy light: redirects, headers and cheap auth-cookie checks, not database queries on every request.
Protect the front door
- Rate-limit forms, auth and expensive endpoints. In serverless environments, in-memory counters are not shared between instances, so use a shared store such as Redis.
- Filter bots cheaply with a honeypot field and a minimum time-to-submit, and add a challenge only if abuse continues.
- Keep secrets on the server. Only variables prefixed with
NEXT_PUBLIC_are exposed to the browser. Import theserver-onlypackage in modules that touch secrets, so accidentally importing them into client code becomes a build error. - Send security headers, including a Content Security Policy and HSTS.
- Patch quickly. In December 2025, a critical remote-code-execution flaw in React Server Components forced App Router sites to upgrade urgently. Commit your lockfile, automate dependency update pull requests, and know how fast you can ship a patch.
Observe, deploy, roll back
Next.js's instrumentation.ts file supports an onRequestError hook that reports server errors, with route context, to any monitoring service. Pair it with client-side error reporting, structured logs that carry a request ID, and uptime checks on critical journeys such as the contact form, not just the home page.
On the deploy side:
- Run type-checking, linting, tests and a production build on every push.
- Review every change on a preview deployment before it reaches production.
- Keep instant rollback available, and practise using it.
- Put risky features behind a flag you can switch off without a deploy.
- Make database migrations backward compatible: add the new column, deploy code that writes both, then remove the old one later.
Resilience checklist
- Strict TypeScript, plus runtime schemas at every boundary.
- Environment variables validated at startup.
- Timeouts on every outbound call; retries only for idempotent work.
- Store first, notify later.
- Error and Suspense boundaries around anything non-essential.
- Compute near the data; caching for global speed.
- Rate limits, bot filtering, server-only secrets and fast patching.
- Error reporting, preview deploys and a rehearsed rollback.
Key takeaways
- Resilience is about containing failure, not preventing all of it.
- TypeScript protects you at compile time; schema validation protects you at runtime.
- Give every network call a deadline, and never blindly retry non-idempotent operations.
- Use error boundaries, streaming and caching so one slow or broken part never takes down the whole page.
- Edge compute is a tool, not a default; data locality usually matters more.
- Fast detection and instant rollback are part of the architecture, not an afterthought.
If you are planning a new product or want an existing one hardened, this is the foundation I bring to every full-stack web development and cross-platform app project.
Planning a project? Book a free discovery call — I'll tell you honestly what it needs.

