Skip to content

    Frontend System Design

    Failure Isolation and Observability in Micro-Frontends

    Contain a failing fragment with error boundaries, CSP and iframe sandboxes, then trace errors and requests across fragments with W3C Trace Context.

    By Published 10 min read

    • frontend-system-design
    • micro-frontends
    • error-boundaries
    • observability
    • trace-context
    Share

    Standard lessonadvancedFrontend System Design

    Before this: Micro-Frontend Composition: Build-Time, Runtime, Iframes and the Edge

    What you'll learn: By the end of this lesson you will be able to list the ways a fragment of a micro-frontend page can fail, say which mechanism catches each one (a React error boundary, a global error handler, a network timeout, an iframe sandbox), decide how much isolation each fragment deserves, and tag every error and request with a fragment name, a version and a trace identifier so that one on-call engineer can tell which team's code broke.

    A ship is divided into watertight compartments called bulkheads. A hole floods one compartment, not the hull. A page made of fragments owned by different teams is the same kind of structure, and it needs the same property: when the recommendations panel breaks, the buy button must keep working. The second half of the job is to find the hole afterwards, which means every report must say which compartment it came from.

    The running example is a product page with a shell and four fragments, header, product details, recommendations and reviews, owned by different teams. All names are illustrative. For the general resilience ideas (bulkheads, timeouts, fallbacks) see Resilience Patterns; this lesson is their browser form.

    The core idea

    1. Decide the blast radius of each fragment. A failure should end at the fragment's edge with a fallback, not take the page with it.
    2. Isolation comes in layers, and each catches different things. An error boundary catches render errors. A global handler catches what boundaries miss. A sandboxed iframe contains a whole document. None does the others' job.
    3. Isolation without attribution is half a fix. If a report does not say which fragment and which version failed, the page survived but you cannot repair it.

    How a fragment can fail

    Failure Example Caught by
    Code never arrives The fragment's bundle returns 404 or times out The rejection of the dynamic import (see below)
    Throws while rendering Reading a property of undefined in a component A React error boundary
    Throws in an event handler A click handler on the reviews panel Not a boundary; the window error event
    Rejected promise nobody handles A failed fetch inside an effect The window unhandledrejection event
    Slow or failing API The recommendations API takes too long Your own timeout and fallback
    Hogs the main thread A fragment's loop blocks input for the whole page Nothing at runtime; see the event loop lesson
    Hostile or compromised code A dependency exfiltrates data Only the origin boundary, a sandbox or a CSP, as below

    Figure 1. A page with one boundary per fragment

    A shell wraps each fragment in its own boundary and sends reports to one collectorHeader fragment to Shell (report); Product fragment to Shell (report); Recs fragment to Shell (fallback); Shell to Collector (tagged reports)BROWSER PAGEHeader fragmentHeaderfragmentProduct fragmentProductfragmentRecs fragment: own boundaryRecs fragmentown boundaryShell: boundaries, handlersShellboundaries,handlersCollector: fragment, versionCollectorfragment, versionreportreportfallbacktagged reports

    How to read this: the red edge is a failure that stops at its own boundary. Only the shell talks to the collector, so every report carries the same fields.

    Error boundaries, one per fragment

    An error boundary is a React class component that defines static getDerivedStateFromError and/or componentDidCatch. According to react.dev, React calls the first when a child throws during rendering so you can show a fallback, and the second so you can log to an error reporting service. componentDidCatch receives info.componentStack. The page also states that there is currently no way to write an error boundary as a function component; the docs suggest one reusable class or the react-error-boundary package.

    Illustration 1, minimal: a boundary that tags every report with the fragment name and version.

    import { Component, type ErrorInfo, type ReactNode } from "react";
    
    type Report = { fragment: string; version: string; kind: string; message: string };
    type Props = { fragment: string; version: string; fallback: ReactNode;
                   report: (r: Report) => void; children: ReactNode };
    
    export class FragmentBoundary extends Component<Props, { hasError: boolean }> {
      state = { hasError: false };
    
      static getDerivedStateFromError() {
        return { hasError: true };
      }
    
      componentDidCatch(error: unknown, info: ErrorInfo) {
        const { fragment, version, report } = this.props;
        const message = error instanceof Error ? error.message : String(error);
        report({ fragment, version, kind: "render", message: `${message} | ${info.componentStack ?? ""}` });
      }
    
      render() {
        return this.state.hasError ? this.props.fallback : this.props.children;
      }
    }

    Place the boundary above the Suspense that wraps a lazily loaded fragment. react.dev says that if the promise of lazy rejects, React throws the rejection reason to the nearest error boundary, so a bundle that fails to load takes the same fallback path as a render error:

    const Recommendations = lazy(() => import("./fragments/recommendations"));
    
    <FragmentBoundary fragment="recommendations" version={versions.recommendations}
                      report={report} fallback={<p>Recommendations are unavailable.</p>}>
      <Suspense fallback={<p>Loading recommendations</p>}>
        <Recommendations />
      </Suspense>
    </FragmentBoundary>

    Know the limits. react.dev lists what error boundaries do not catch: event handlers, server-side rendering, errors thrown in the boundary itself, and asynchronous code such as setTimeout or requestAnimationFrame callbacks. Also, lazy caches the load promise and calls load once, so a "Try again" button that only re-renders the same lazy component may not re-fetch the chunk. That consequence is judgement from the docs, not a quoted statement, so test your retry path.

    Figure 2. A render error stays inside its fragment

    sequenceDiagram accTitle: A render error contained by a fragment boundary accDescr: The recommendations fragment throws while rendering. Its boundary shows a fallback and reports the error with the fragment name and version. The shell and the other fragments keep running. participant R as Recommendations participant B as Boundary participant S as Shell reporter participant P as Product fragment R->>B: throws while rendering B->>B: render fallback instead B->>S: report(fragment, version) Note over P: keeps running, no change S-->>S: send to collector

    How to read this: the throw travels up one level, to the boundary, and no further. The product fragment is not involved.

    What boundaries miss: global handlers

    Two browser events cover most of the rest. MDN says the error event on window fires for script errors thrown synchronously, including event handlers, and that a rejected promise with no handler (including an uncaught throw in an async function) fires unhandledrejection instead. The onerror form receives message, source, lineno, colno and error; unhandledrejection is cancelable and carries the rejection reason.

    const FRAGMENT_PREFIXES = [
      ["https://static.example.com/recommendations/", "recommendations"],
      ["https://static.example.com/reviews/", "reviews"],
    ];
    
    function fragmentFromUrl(url) {
      const hit = FRAGMENT_PREFIXES.find(([prefix]) => url?.startsWith(prefix));
      return hit ? hit[1] : "unknown";
    }
    
    window.onerror = (message, source, lineno, colno, error) => {
      report({ fragment: fragmentFromUrl(source), kind: "uncaught", message: String(message) });
      return false; // leave the default console output in place
    };
    
    window.addEventListener("unhandledrejection", (event) => {
      report({ fragment: "unknown", kind: "unhandled-rejection", message: String(event.reason) });
    });

    Attribution is the hard part. For onerror you can map the script URL to a fragment, as above, but only if the bundle URL names the owner. A rejected promise has no script URL, so wrap each fragment's own entry points with its name (the shell can hand each fragment a report bound to its name) rather than guess. MDN's page on the error event does not say how cross-origin scripts are reported, so check what details arrive for bundles served from another origin before you rely on source.

    Figure 3. Which mechanism sees which failure

    flowchart TD accTitle: Choosing a catching mechanism by where the error happens accDescr: A decision tree. Render errors and lazy load failures go to the error boundary. Errors in event handlers go to the window error event. Unhandled promise rejections go to the unhandledrejection event. Slow API calls need a timeout and fallback in the fragment's own code. Q{"Where did it
    fail?"} -->|"while rendering, or
    the bundle did not load"| A["Error boundary"] Q -->|"inside an event handler"| B["window error event"] Q -->|"in a promise nobody
    handled"| C["unhandledrejection"] Q -->|"a network call is slow"| D["Timeout and fallback
    in the call itself"]

    How to read this: every branch ends in the same reporter. The point of the tree is that "we have error boundaries" is only the first branch.

    Isolating code and resources

    Boundaries stop a crash from spreading. They do not stop a script from doing something it should not. Two browser features narrow what a fragment may do.

    Content Security Policy (CSP) is a response header. MDN describes its primary use as controlling which resources, in particular JavaScript, a document may load, as a defence against cross-site scripting. Its script-src directive sets allowed script sources, and MDN recommends nonce- or hash-based policies (a "strict CSP"). Because micro-frontends often load bundles at runtime, note MDN's warning about 'strict-dynamic': a script with a nonce may load further scripts, which makes the policy less secure. CSP can be deployed in report-only mode first with Content-Security-Policy-Report-Only, which sends violations without enforcing. A CSP applies to a document, so it does not separate two fragments in the same page; scripts it allows all run with the same origin and privileges (see the authentication lesson's point on same-origin code, in Sharing Authentication Across Micro-Frontends).

    Iframe sandbox contains a whole document. Per MDN, an empty sandbox applies all restrictions and tokens lift them one by one. Without allow-same-origin the content gets a special origin that fails the same-origin policy, potentially blocking storage and cookies. MDN strongly discourages combining allow-scripts with allow-same-origin for a same-origin document, since it can then remove its own sandbox. The allow attribute applies a Permissions Policy to the frame. For the fragment page itself, the CSP frame-ancestors directive controls which documents may embed it.

    <iframe src="https://widgets.example.com/reviews" title="Reviews"
            sandbox="allow-scripts" loading="lazy"></iframe>
    Mechanism Contains Does not contain Cost
    Error boundary Render and load errors in one subtree Handlers, async code, hostile code Class component per fragment
    Global handlers Reporting what boundaries miss Nothing; they only observe Attribution work
    CSP Which scripts may load into a document Allowed scripts in the same page Nonce or hash plumbing
    Sandboxed iframe A whole document, with its own origin Slow loads, layout cost Messaging, sizing, accessibility

    Observability across fragments

    One user action, such as "add to cart", may start in the reviews fragment, call the cart service and end in a toast owned by the shell. To follow it you need a shared identifier. The W3C Trace Context Recommendation (23 November 2021) defines the traceparent header: four dash-separated lowercase hex fields, version-trace-id-parent-id-trace-flags, with 32 characters of trace id and 16 of parent id; all-zero ids are invalid. Its own example is 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01, and the 01 is the sampled flag. A system that forwards the header must update the parent-id, and the specification limits other changes. If no traceparent arrives, the receiver creates one.

    The shell is the natural place to start a trace per user action and to add traceparent to every fragment request:

    const hex = (bytes) =>
      Array.from(crypto.getRandomValues(new Uint8Array(bytes)),
                 (b) => b.toString(16).padStart(2, "0")).join("");
    
    export const newTraceId = () => hex(16);
    
    export function tracedFetch(traceId, input, init = {}) {
      const headers = new Headers(init.headers);
      headers.set("traceparent", `00-${traceId}-${hex(8)}-01`);
      return fetch(input, { ...init, headers });
    }

    Run in Node 26, this produced values that matched the pattern ^00-[0-9a-f]{32}-[0-9a-f]{16}-01$ every time in a 1,000-call loop. The shell stores traceId in each report next to fragment and version, so one search finds the browser errors and the server spans of the same action.

    Three cautions. Per the specification, these fields exist only for trace correlation and must not carry personal data. In a browser, a cross-origin request with a new header needs the server to allow it in Access-Control-Allow-Headers, and the specification notes requests can fail otherwise; see CORS in Practice. And a collector needs a retention and sampling plan, as in Observability and SLOs.

    Figure 4. One trace across fragments and services

    sequenceDiagram accTitle: A trace identifier crossing fragments and services accDescr: The shell starts a trace for an add-to-cart action. The reviews fragment calls the cart API with a traceparent header. The cart API forwards it to the inventory service with a new parent id. A later browser error is reported with the same trace id. participant S as Shell participant R as Reviews fragment participant C as Cart API participant I as Inventory service S->>S: new trace id for the action R->>C: traceparent (trace id, new parent id) C->>I: traceparent (same trace id, new parent id) I-->>C: error C-->>R: 500 R->>S: report(fragment, version, trace id)

    How to read this: the trace id stays the same through every hop and each hop makes a new parent id. The browser report carries the same id, so the two sides join.

    In practice: a product page with four teams

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every name in it is an illustrative assumption.

    The situation

    A retail product page is assembled by a shell from header, product, recommendations and reviews fragments, each deployed by its own team. The shell wraps each fragment in an error boundary and shows a fallback. A dashboard counts boundary reports.

    Starting architecture

    Figure 5. Reports only from boundaries

    Starting architecture: only error boundaries feed the dashboardFour fragments to Boundaries (render errors); Boundaries to DashboardBROWSERFour fragments: each with a boundaryFour fragmentseach with aboundaryBoundaries: render errors onlyBoundariesrender errorsonlyDashboard: counts by fragmentDashboardcounts byfragmentrender errors

    How to read this: anything that is not a render error never reaches the dashboard.

    Options considered

    Option What it fixes What it costs
    Add global handlers and tag reports Handler and async errors become visible Attribution rules to maintain
    Put every fragment in a sandboxed iframe Strong containment Messaging, sizing, accessibility work
    Add trace ids to requests Joins browser and server views Header allow-listing on servers

    The decision

    Judgement, labelled as such: keep boundaries, add window handlers with per-fragment attribution, add a fragment report that carries name, version and trace id, and keep iframes for code that is not owned by the company.

    Rollout and verification

    1. Add the handlers in report-only form, with no UI change.
    2. Throw a test error in each handler type in a staging page and check each arrives with the right fragment.
    3. Compare per-fragment counts with the previous dashboard for a week.

    The 2 a.m. failure

    Reviews are not loading for some users, but the dashboard is green. The cause is a click handler in the reviews fragment that throws after a deploy: React error boundaries do not catch event handler errors, so the page shows no fallback and the buttons just do nothing. Support tickets arrive before any alert.

    First three checks: (1) look for window error events by script URL, which the new handlers now capture; (2) compare the reviews fragment version of affected sessions; (3) check whether the failing call carries a trace id. Mitigation: roll back the reviews fragment to its previous version. Follow-up: alert on unattributed errors, require every fragment to report through the shell, and add an event-handler case to the fragment contract tests.

    Architect's notes

    • Give every fragment the same reporting contract: name, version, kind, trace id.
    • Decide fallbacks with the product owner. An empty recommendation panel is fine; an empty buy button is not.
    • Keep a page-level boundary too, so a shell bug still shows something readable.
    • Use iframes sparingly for fragments you cannot review, and accept the messaging cost.
    • Alert on unattributed errors, because they mean your tagging is wrong.

    Common mistakes

    • Believing one top-level boundary isolates fragments. It only isolates the page from a white screen.
    • Counting only boundary reports.
    • Retrying a failed lazy load by re-rendering, when React caches the load promise.
    • Putting personal data in trace fields.
    • Using a CSP as a fragment boundary. It governs loading, not what loaded code may do.
    • Combining allow-scripts and allow-same-origin on a same-origin frame.

    Key takeaways

    • Decide each fragment's blast radius; the goal is a fallback, not a crash.
    • Error boundaries catch render errors and lazy load failures; they miss event handlers and async code.
    • Global error and unhandledrejection handlers cover the rest, but attribution needs design.
    • CSP governs which scripts load; a sandboxed iframe contains a document.
    • Same-document fragments share one origin and one main thread.
    • traceparent carries a trace id across fragments and services; forward it with a new parent id.
    • Every report carries fragment, version and trace id.

    Practice exercises

    Exercise 1, Starter (30 min): Wrap a lazy component in a boundary and force a render error and a load failure. Write down which fallback appears and what is reported.

    Exercise 2, Intermediate (2 hours): Add window.onerror and unhandledrejection handlers to a page with two fragments, and attribute each error to its fragment from a script URL or a bound reporter.

    Exercise 3, Advanced (half day): Propagate traceparent from a shell through a mock API and back into an error report, then write the query that joins them.

    In this course:

    Elsewhere on the site:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments