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
- 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.
- 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.
- 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
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
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
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
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
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
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
- Add the handlers in report-only form, with no UI change.
- Throw a test error in each handler type in a staging page and check each arrives with the right fragment.
- 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-scriptsandallow-same-originon 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
lazyload failures; they miss event handlers and async code. - Global
errorandunhandledrejectionhandlers 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.
traceparentcarries 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.
Related reading
In this course:
- The Event Loop and the Main Thread, why a busy fragment hurts the whole page.
- Sharing Authentication Across Micro-Frontends, trust inside one page.
Elsewhere on the site: