50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
best-practicesMay 24, 2026·9 min read

Graceful Degradation: Building Apps That Don't Fall Apart When Things Break

Third-party API down? Database slow? Here's how to build apps that limp along gracefully instead of dying spectacularly.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Graceful Degradation: Building Apps That Don't Fall Apart When Things Break

It was 11pm on a Friday when our payment provider started returning 503s. Not our server — theirs. We couldn't charge anyone. And because we'd built our checkout flow to throw a hard error the moment Stripe's API hiccuped, the entire purchase flow was just... dead. Error page. Full stop. No explanation, no fallback, nothing. We lost a few sales that night that we'll never recover, and we learned something that every developer eventually learns the hard way: the question isn't *if* things will fail. It's *when*, and *how badly* you've designed for it.

Graceful degradation is the practice of building systems that keep working — maybe not perfectly, but *usefully* — when parts of them break. It's not about being pessimistic. It's about being honest with yourself that the internet is held together with duct tape and prayers, third-party services have incidents, databases get slow, and your code has bugs you haven't found yet.

The Difference Between Failing and Failing Gracefully

There's a spectrum here. On one end: your entire app throws a 500 because a sidebar widget couldn't fetch some non-critical data. On the other end: your app detects the failure, shows something useful, logs the error, and keeps everything else working. Most apps live somewhere closer to the first end than developers would like to admit.

A concrete example: you have a dashboard that shows a user's subscription status, their recent activity, and a recommendations widget powered by an external ML service. If that recommendations service goes down, what should happen? Option A: the whole dashboard 500s. Option B: the recommendations section shows 'Suggestions unavailable right now' and everything else works fine. Option B isn't hard to build. It's just something you have to decide to build.

Graceful degradation isn't a feature you add later. It's a decision you make upfront about which parts of your app are load-bearing and which ones can limp.

Isolating Failures with Error Boundaries

In React, error boundaries are your first line of defense for UI-level failures. Without them, a single component throwing an unhandled error will unmount your entire app. With them, you can contain damage to a specific section. The trick is wrapping the *right* granularity — too coarse and you're back to the whole page dying, too fine and you're writing error boundaries for every button.

// components/safe-section.tsx
'use client'

import { Component, ReactNode } from 'react'

interface Props {
  children: ReactNode
  fallback?: ReactNode
  name?: string // for logging
}

interface State {
  hasError: boolean
  error?: Error
}

export class SafeSection extends Component<Props, State> {
  state: State = { hasError: false }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, info: { componentStack: string }) {
    // Log to your error tracking (Sentry, etc.)
    console.error(`[SafeSection:${this.props.name}]`, error, info)
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? (
        <div className="rounded-md bg-muted p-4 text-sm text-muted-foreground">
          This section is temporarily unavailable.
        </div>
      )
    }
    return this.props.children
  }
}

// Usage:
// <SafeSection name="recommendations" fallback={<RecommendationsSkeleton />}>
//   <RecommendationsWidget userId={user.id} />
// </SafeSection>

Notice the `name` prop — that's not just for debugging. When you have 15 SafeSections across your app and one starts firing in production, you want to know *which one* without playing detective through minified stack traces.

Defensive Data Fetching: Timeouts, Fallbacks, and Stale Data

The sneakiest failure mode isn't an error — it's a *slow* response. An external API that normally responds in 200ms starts taking 8 seconds. Your server is technically 'working', but every request is hanging and you're accumulating open connections. Users see a loading spinner forever. You need timeouts, and you need to decide what to show when you hit them.

// lib/fetch-with-timeout.ts
export async function fetchWithTimeout<T>(
  fetcher: () => Promise<T>,
  options: {
    timeoutMs?: number
    fallback: T
    onTimeout?: () => void
  }
): Promise<T> {
  const { timeoutMs = 3000, fallback, onTimeout } = options

  const timeoutPromise = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
  )

  try {
    return await Promise.race([fetcher(), timeoutPromise])
  } catch (err) {
    if (err instanceof Error && err.message === 'Request timed out') {
      onTimeout?.()
    }
    // Log the error regardless
    console.error('fetchWithTimeout failed:', err)
    return fallback
  }
}

// Usage in a Next.js server component:
async function UserDashboard({ userId }: { userId: string }) {
  const recommendations = await fetchWithTimeout(
    () => getRecommendations(userId),
    {
      timeoutMs: 2000,
      fallback: [],
      onTimeout: () => {
        // Could increment a metric here
        console.warn('Recommendations service slow — returning empty')
      }
    }
  )

  return <RecommendationsWidget items={recommendations} />
}

The `fallback` being part of the function signature forces you to think about what the default state is *when you write the code*, not when something breaks in production at 2am. That's the whole game: make failure a first-class consideration at design time.

Stale data is another underrated fallback. If your caching layer has a value from 5 minutes ago and the live fetch is failing, showing the stale data with a small 'data may be outdated' notice is almost always better than an error state. This is why stale-while-revalidate patterns exist and why you should use them more than you probably do.

Handling External Service Failures at the API Level

Your Next.js server actions and route handlers call third-party services all the time: Stripe, Resend, OpenAI, Cloudinary, whatever. Each one is a point of failure. The pattern we've settled on is a simple wrapper that standardizes error handling and prevents unhandled promise rejections from bubbling up to the user as a blank page.

// lib/safe-action.ts
type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string; code?: string }

export async function safeAction<T>(
  action: () => Promise<T>,
  context?: string
): Promise<ActionResult<T>> {
  try {
    const data = await action()
    return { success: true, data }
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error'
    const code = (err as any)?.code ?? (err as any)?.statusCode

    // Always log with context for debugging
    console.error(`[safeAction${context ? `:${context}` : ''}]`, {
      error: message,
      code,
      stack: err instanceof Error ? err.stack : undefined
    })

    return {
      success: false,
      error: message,
      code: String(code ?? 'UNKNOWN')
    }
  }
}

// In a server action:
export async function sendWelcomeEmail(userId: string) {
  const user = await db.query.users.findFirst({ where: eq(users.id, userId) })
  if (!user) return { success: false, error: 'User not found' }

  const result = await safeAction(
    () => resend.emails.send({
      from: 'hello@yourapp.com',
      to: user.email,
      subject: 'Welcome!',
      react: <WelcomeEmail name={user.name} />
    }),
    'sendWelcomeEmail'
  )

  if (!result.success) {
    // Email failed — but we don't crash the signup flow
    // Maybe queue for retry, maybe just log it
    await db.insert(emailRetryQueue).values({ userId, type: 'welcome' })
  }

  // Continue regardless — user is signed up, email is best-effort
  return { success: true }
}

The key decision here is: what's truly blocking versus what's best-effort? A welcome email failing should not prevent someone from signing up. A payment confirmation failing absolutely should surface to the user. Not every error deserves the same treatment, and that nuance is something you have to encode deliberately.

Feature-Level Degradation: When to Show Less, Not Nothing

There's a UX principle buried in here that's easy to miss: users can tolerate *limited* functionality much better than they can tolerate *broken* functionality. A search box that says 'Search is slower than usual right now' while still working is fine. A search box that returns a 500 page is not fine. Same failure, completely different user experience.

  • Read paths should degrade before write paths. Showing slightly stale data is almost always acceptable. Silently losing a user's write is never acceptable.
  • Non-critical widgets (recommendations, activity feeds, analytics dashboards) should have explicit fallback states — skeleton, empty state, or cached data.
  • For anything touching money, degrade by *blocking and explaining*, not by silently failing. 'Payment processing is experiencing issues, please try again in a few minutes' is better than a success screen that didn't actually charge.
  • Use feature flags to disable problematic features entirely when a third-party dependency is having an incident. Better to hide a feature than to show broken UI.

Database Failures: The One That Bites You When You Least Expect It

Connection pool exhausted. Replica lag. Query timeout on a table you forgot to index. Database failures are different from API failures because your whole app depends on them — you can't just 'skip' the database. But you can still degrade gracefully in certain scenarios.

One pattern we use: separate read-heavy, non-critical queries from critical path queries. For something like a 'trending posts' sidebar, if the query times out after 500ms, just return an empty array and hide the sidebar. The user gets the page they needed. For core data like 'what is this user's subscription status', that's blocking — you need it, and if it fails, you need to surface the error honestly rather than making assumptions.

// app/dashboard/page.tsx — server component
import { Suspense } from 'react'

export default async function DashboardPage() {
  // Critical: user must see this. Let it fail naturally and
  // Next.js error.tsx will catch it.
  const user = await getCurrentUser()

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      
      {/* Non-critical: wrapped in Suspense + SafeSection.
          If it fails or is slow, page still renders. */}
      <Suspense fallback={<ActivityFeedSkeleton />}>
        <SafeSection name="activity-feed" fallback={<ActivityFeedEmpty />}>
          <ActivityFeed userId={user.id} />
        </SafeSection>
      </Suspense>

      {/* Also non-critical */}
      <Suspense fallback={null}>
        <SafeSection name="recommendations" fallback={null}>
          <Recommendations userId={user.id} />
        </SafeSection>
      </Suspense>
    </div>
  )
}

// The ActivityFeed component fetches its own data
async function ActivityFeed({ userId }: { userId: string }) {
  const activity = await fetchWithTimeout(
    () => db.query.activityLog.findMany({
      where: eq(activityLog.userId, userId),
      limit: 10,
      orderBy: desc(activityLog.createdAt)
    }),
    { timeoutMs: 1500, fallback: [] }
  )

  if (!activity.length) return <ActivityFeedEmpty />
  return <ActivityFeedList items={activity} />
}

This pattern — Suspense boundaries wrapping SafeSections, with non-critical fetches isolated from the critical path — is what good architecture looks like in a Next.js App Router app. The critical data (user session, subscription status, whatever your app truly needs) is fetched at the top level and allowed to fail loudly. Everything else gets a safety net.

Observability: You Can't Degrade What You Can't See

Graceful degradation without observability is just hiding failures. Your app looks healthy in your browser but is silently returning empty arrays to everyone because some service is down and you swallowed the error. You need to know when your fallbacks are firing — frequently enough that you can tell the difference between 'this happens once a week, it's fine' and 'this has fired 400 times in the last hour, something is very wrong.'

At minimum: log every time a fallback fires with enough context to reproduce the issue. Ideally: track fallback firing as a metric and alert when it spikes. This is where Sentry's breadcrumbs, or even just a simple counter in a monitoring service, becomes essential. Our rule: if we're gracefully degrading, we're also logging it. Silently swallowed errors are bugs you won't find until a user complains.

Every fallback path should have a log statement. 'Silent failures' aren't graceful degradation — they're just failures you're pretending don't exist.

The Practical Checklist

Before shipping a new feature or integration, we run through a quick mental model: what happens when this fails? Not if — when. Here's the short version:

  • Is this feature on the critical path (user can't do the core thing without it) or non-critical? Non-critical features get fallback UIs.
  • Does this call an external service? It gets a timeout. What's the fallback when it times out?
  • Is this a read or a write? Reads can use stale/cached data. Writes need honest error surfacing.
  • Is there an error boundary around this UI? If it throws, does the whole page die?
  • When the fallback fires, will we know? Is there a log statement?
  • If this service has an outage for 2 hours, what's the worst-case user experience? Is that acceptable?

It's not a lot. Most of these become instinct after you've been burned a few times. The first time your analytics widget takes your entire dashboard down with it, you'll never forget to wrap external calls in a timeout again.

If you're starting a new project and want these patterns baked in from the start, the templates at peal.dev are built with this kind of defensive thinking already in place — error boundaries, safe action wrappers, and Suspense patterns that won't let a sidebar nuke your whole page.

The meta-lesson here is simple: build as if everything outside your own code is unreliable, because it is. Your database will have a slow query. Stripe will have a blip. Resend will queue your email. The npm package you depend on has a bug you haven't hit yet. Graceful degradation isn't pessimism — it's just being honest about the world your code lives in, and designing for it.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders