Back to articles
Frontend

The Art of Resilient Frontends, Part 2: Containing Failure in the Browser

How boundaries, feature-oriented architecture, explicit state ownership, and graceful degradation keep browser failures from spreading.

In this essay 7 sections
  1. Draw the watertight compartments
  2. Organize by what code is for, not what it is
  3. Give every concern its own seam
  4. Every piece of state has a rightful owner
  5. Every state deserves a face
  6. When the ideal fails, serve the useful
  7. Next: the unreliable network

Part 1 built the mental model. The browser is a node in a distributed system that happens to have a screen attached, and a resilient frontend runs on a five-part habit called ECORE: expect failure, contain it, observe it, recover from it, and evolve. Part 1 lived in the first letter.

Expecting failure changes nothing on its own. This part is where containment starts, and it does so in the browser.

Draw the watertight compartments

In a ship, if one section has a leak, does it make sense to let the whole ship sink because of it? The smart answer is no. You build the ship with watertight compartments so that a leak in compartment A is contained within it and does not cause the ship to sink.

One open hull One leak spreads everywhere

A local failure becomes a system-wide failure.

Watertight compartments The damage stays local

Independent boundaries keep the rest of the system useful.

A useful boundary limits the blast radius without fragmenting the system into needless pieces.

The same principle applies to your UI. Let’s dive deeper with an example. Consider a dashboard with several independently useful regions:

DashboardFour independently useful regions
Shared shellHeaderAvailable
Chart boundaryRevenue chartChartError fallback
Table boundaryInventory tableAvailable
Independent featureNotificationsAvailable
Independent featureAccount menuAvailable
The revenue chart fails inside its own boundary while the rest of the dashboard remains available.

If the Revenue Chart feature throws an error, should the whole screen go blank? Absolutely not. The chart is an independent feature, and the other features can function without it. So it gets its own watertight compartment, also known as an error boundary.

function Dashboard() {
  return (
    <>
      <Header />
      <ErrorBoundary fallback={<ChartError />}>
        <RevenueChart />
      </ErrorBoundary>
      <ErrorBoundary fallback={<TableError />}>
        <InventoryTable />
      </ErrorBoundary>
      <Notifications />
      <AccountMenu />
    </>
  );
}

Now the Revenue Chart shows ChartError, while the Inventory Table, Notifications, and Account Menu remain available.

Error boundaries are the watertight compartments of your UI. There is a tradeoff, however: boundary size. One giant boundary is simple and useless:

<ErrorBoundary>
  <EntireApplication /> {/* One error blanks everything */}
</ErrorBoundary>

Too many boundaries create the opposite failure: noise and complexity. The rough rule of thumb is coupling. If two elements always succeed or fail together, they should share a boundary.

Organize by what code is for, not what it is

A frontend gets hard to change when it is organized only by what things are instead of what they are for.

Organizing by technical type looks tidy on day one. By month seven, unrelated features are tangled together inside every folder, and a change to billing risks breaking reporting. Organize by business capability instead:

Organized by technical typeOne change crosses every folder
componentshooksservicestypes
AuthenticationBillingReporting
Organized by capabilityChanges stay inside a feature
authentication/UI · API · hooks · types
billing/UI · API · hooks · types
reporting/UI · API · hooks · types

Shared deliberately UI · network · analytics · errors

Business boundaries reduce the number of unrelated places an engineer must touch to change one capability.

Now, when billing changes, an engineer reasons mostly within the billing folder, and accidental coupling drops.

Conway’s law tells us that system structure tends to reflect an organization’s communication paths. The inverse Conway maneuver uses that relationship deliberately: shape team and software boundaries so they reinforce the architecture you want. Do not ask, “Which components look similar?” Ask, “Which code changes together for the same business reason?” Code that changes together should live together.

Give every concern its own seam

Here is a component that is doing far too much:

function OrdersPage() {
  const [orders, setOrders] = useState<Order[]>([]);

  useEffect(() => {
    fetch("/api/orders")
      .then((response) => response.json())
      .then(setOrders);
  }, []);

  return orders.map((order) => (
    <button
      key={order.id}
      type="button"
      onClick={() => fetch(`/api/orders/${order.id}/approve`)}
    >
      {order.name}
    </button>
  ));
}

That one function handles fetching, serialization, state, a business action, rendering, and user interaction. It is hard to test and harder to evolve because there is no seam where one concern can change without touching the rest. Split it into layers, each with a single job.

// ordersApi.ts: the only layer that speaks HTTP
export async function getOrders(): Promise<Order[]> {
  const response = await fetch("/api/orders");

  if (!response.ok) {
    throw new Error(`Failed to load orders: ${response.status}`);
  }

  return response.json();
}
// useOrders.ts: application logic for caching and refetching this data
export function useOrders() {
  return useQuery({ queryKey: ["orders"], queryFn: getOrders });
}
// OrdersPage.tsx: presentation, where every state has a face
export function OrdersPage() {
  const { isPending, isError, data, refetch } = useOrders();

  if (isPending) return <OrdersSkeleton />;
  if (isError) return <OrdersError onRetry={refetch} />;

  return <OrdersList orders={data} />;
}

The payoff shows up at migration time. When REST becomes GraphQL, only ordersApi.ts changes. When the design system changes, only OrdersPage.tsx changes. The seams are where the future gets to enter without a complete rewrite.

PresentationOrdersPage.tsxChanges with the design
Application logicuseOrders.tsChanges with caching rules
InfrastructureordersApi.tsChanges with the network
Each concern has a named seam, so one kind of change does not require rewriting every layer.

Every piece of state has a rightful owner

Most fragility traces back to one habit: dumping all state into one global store. Looking at you, Redux. Not all state is global. Give each piece the narrowest owner that works.

StateRightful owner
Button hover stateThe component
Modal open stateA nearby parent
Form draftThe form
Server dataA query cache
Authenticated userApplication scope
URL filtersThe URL
Cross-page workflowA shared store

State that lives close to where it is used is easier to understand and far less likely to create invisible dependencies across the app.

The distinction that fixes many bugs is this: server state and client state are not the same thing.

ConcernServer stateClient state
Owned byThe backendThe frontend
ExamplesUsers, orders, reports, notificationsSelected tab, open modal, draft text, collapsed sidebar
StalenessCan go staleCannot go stale relative to a remote source
HandlingMust be fetched, cached, invalidated, and synchronizedSet it and read it
Belongs inA query cacheLocal or component state

Treating server data as if it were plain client data is the root of “I updated my profile, and it still shows the old name.” Server data is a local view of something another system owns, so give it a tool that understands staleness, such as TanStack Query, rather than a plain store you have to synchronize by hand forever.

Every state deserves a face

Fragile apps model only the happy path: if data exists, render the page.

Real apps live in more states than that, and a blank area is ambiguous. Is it loading? Empty? Broken? The user cannot tell.

Give each meaningful state its own face:

Fragile modelData existsRender the page

Everything else becomes a blank, ambiguous space.

Explicit model
LoadingSkeleton
ErrorRetry path
EmptyNext action
ReadyCustomer table
Every meaningful application state gives the user an explanation and, where possible, a next step.
function CustomerList() {
  const { isPending, isError, data, refetch } = useCustomers();

  if (isPending) return <CustomerListSkeleton />;

  if (isError) {
    return (
      <ErrorState
        title="Customers could not be loaded"
        actionLabel="Try again"
        onAction={() => refetch()}
      />
    );
  }

  if (data.length === 0) return <EmptyCustomers />;
  return <CustomerTable customers={data} />;
}

When the ideal fails, serve the useful

Graceful degradation means that when the ideal experience is unavailable, you serve the best useful alternative instead of nothing.

When this failsServe this instead
A chartThe summarized numbers
An imageA placeholder
Live updatesA manual refresh button
SearchThe results already on screen
AnalyticsNothing visible; never block the user

This forces a ranking of what actually matters. Take a checkout feature:

PathPriority
Payment submissionCritical
Product image zoomNon-critical
Recommendation carouselNon-critical
Analytics trackingNon-critical

The critical path must not depend on non-critical paths. Fire and forget the telemetry so that a broken analytics call cannot break a purchase:

async function completePurchase(order: Order) {
  const result = await submitOrder(order);

  void trackPurchase(result).catch((error) => {
    // Analytics failing must never fail the sale.
    logNonCriticalFailure(error);
  });

  return result;
}

This connects directly to the failure boundaries discussed above. Partial success at the data layer, degradation at the UX layer, and boundaries at the component layer are the same instinct applied at three altitudes. Contain the damage and keep the core alive.

Next: the unreliable network

Everything we discussed so far assumed the failure was yours to catch inside the browser: a component that throws, a store that sprawls, or a state you forgot to render. The nastier failures come over the wire. Requests hang, fail halfway, return a shape you never agreed to, or arrive in the wrong order and overwrite fresh data with stale data.

Part 3 is about that boundary: treating the network as unreliable by default. We will cover timeouts and safe retries, validate what the network sends before trusting it, design for partial success when one of several services fails, protect against out-of-order responses, and observe all of it so that a failure on a stranger’s phone still leaves you something to investigate.

Join the discussion

Thoughts, questions, or a different perspective?

React to this essay or continue the conversation. Comments are powered by GitHub Discussions.