← Workstreams

Workstream: Compose Error Boundaries

Status: Shipped on the fork (v1 merged and hardened through three review rounds; ecosystem frontend adoption + post-hardening artifact republish pending) · Component: Maximize developer productivity

Goal

Get first-class error boundaries into Jetpack Compose itself — an official Compose primitive that contains an exception thrown anywhere in a composable subtree, rolls that subtree back to a consistent state, hands the Throwable to a handler, and renders fallback content in its place, with a reset path that re-attempts composition once the underlying cause clears. The capability React popularized as an error boundary, but native to Compose rather than bolted on top of it.

This is deliberately an upstream workstream, and it spans two upstreams, because modern Compose is split across two homes: the Compose compiler lives in the Kotlin repository (merged there as of Kotlin 2.0, versioned with Kotlin), while the Compose runtime (androidx.compose.runtime — Composer / Recomposer / SlotTable) lives in Google's AOSP androidx and is mirrored by JetBrains' compose-multiplatform-core. A real error boundary is a compiler + runtime co-design (see below), so official support has to land in both. Because the Compose Multiplatform frontends across this ecosystem consume that same compiler and runtime, every desktop / Wasm / JS UI inherits the feature once it lands — and the userland workarounds we maintain today can be deleted rather than ported. Until it merges upstream we fork and build both ourselves and ship patched artifacts, while preparing the changes to upstream.

Why it must live in the compiler and runtime, not userland

An exception thrown during composition or recomposition today propagates up through the Recomposer and takes down the whole UI — there is no supported way to contain a composition failure to a subtree and substitute fallback content. Only the runtime can do this safely: catching a composition error means unwinding a partially applied composition — slot table writes, applier operations, remembered state, CompositionLocal propagation, and registered effects — back to a consistent state, then re-running just the failed subtree. Userland code cannot reach those internals.

And it is not only the runtime. The compiler — now part of the Kotlin repository — rewrites every @Composable into balanced group start/end calls (startReplaceGroup / endGroup) and, for that reason, forbids try/catch around a composable invocation today: an exception unwinding mid-group leaves those groups imbalanced, which is exactly the runtime's "Start/end imbalance" crash class (248513437, 329588687). Clean containment therefore needs the compiler to emit (and relax its blanket ban around) group-boundary unwinding and the runtime to roll the slot table / applier back so the groups balance again — a two-repo change neither side can make alone. That coupling is why the change is genuinely hard, and why no userland boundary can be complete.

What "roll back to a consistent state" concretely means — and why the work is bounded: the target is the state the guarded subtree held at the start of the failed pass, and Compose already owns two of the three mechanisms to get there. State writes (MutableState) made during the failed pass are discarded by disposing that pass's mutable snapshot instead of applying it — the snapshot system is MVCC, so writes stay isolated until apply() and disposing rolls them back atomically. Node-tree mutations are discarded by dropping the subtree's not-yet-applied ChangeList — the Composer defers every Applier change to the end of recomposition, so a failed pass's changes are never handed to the applier. The one genuinely new piece is subtree-scoped slot-table unwinding — discarding the subtree's slots back to their pre-pass state — which the compiler's balanced group boundaries make well-defined. remembered values from the discarded pass get the existing onAbandoned() callback, and the torn-down prior subtree gets its normal onForgotten / DisposableEffect.onDispose teardown; then fallback composes in the subtree's slot (replacing the crashed subtree, as React does), and reset() re-attempts content from a clean slate. So "hard to implement" is real but bounded — one new mechanism riding on an existing transactional substrate, not a from-scratch transaction system.

The community workaround — the one this ecosystem ships today — is subcomposition: isolate the fragile subtree in its own Composition / SubcomposeLayout so a failure there can be caught at the sub-composition seam. It works, but it inherits subcomposition's limitations, none of which an application author should have to reason about just to get a fallback UI:

  • Layout semantics change. SubcomposeLayout measures its children during the layout pass rather than during composition, which breaks intrinsic measurements, complicates sizing and ordering, and adds a measurement pass.
  • The composition seam leaks. CompositionLocal propagation, movableContentOf, state hoisting, and tooling / inspector traversal all behave differently across a subcomposition boundary.
  • It is overhead on the non-error path. Every guarded subtree pays for a second composition whether or not anything ever fails.
  • It does not cleanly cover every phase. Failures raised outside the narrow seam still escape and crash the app.

Official support replaces all of that with a single boundary the runtime understands natively — no second composition, no layout-phase surprises, no leaked seam.

Vision: ErrorBoundary as a first-class Compose primitive

The committed shape (the exact API to be settled upstream) mirrors React's official Component error-boundary primitive — but in Compose's natural composable form. (React has no function-component equivalent, which is the entire reason its third-party react-error-boundary wrapper exists; Compose, lacking React's class/Hook split, can ship the ergonomic form as the primitive.) When content — or anything it composes — throws, the runtime contains the throw, composes the fallback in the same slot, and offers a reset() that discards the error state and re-attempts content:

@Composable
fun ErrorBoundary(
    fallback: @Composable ErrorBoundaryScope.() -> Unit,          // render-phase, pure   (getDerivedStateFromError)
    onError: ((Throwable, CompositionErrorInfo) -> Unit)? = null, // commit-phase logging (componentDidCatch)
    resetKeys: Array<Any?> = emptyArray(),                        // auto-reset when any key changes
    content: @Composable () -> Unit,
)
interface ErrorBoundaryScope { val error: Throwable; fun reset() }

// Out-of-composition errors (event lambdas, LaunchedEffect / coroutine bodies) forward
// explicitly — the useErrorBoundary().showBoundary analog, since composition cannot see a
// coroutine's throw:
val LocalErrorBoundary: ProvidableCompositionLocal<ErrorBoundaryHandle?>
fun interface ErrorBoundaryHandle { fun throwToBoundary(error: Throwable) }

The two-parameter split is React's, made explicit: fallback is the pure render-phase branch (the getDerivedStateFromError analog) and onError is the commit-phase side effect for logging (the componentDidCatch analog), receiving a composition stack — the info.componentStack analog that Compose's source-info / tooling data can produce.

The rest of the committed shape — the semantics behind that surface:

  • Containment + rollback across compiler and runtime: a throw during (re)composition of the guarded subtree unwinds that subtree's slot-table and applier changes to the last consistent state instead of failing the whole composition — the core runtime change everything else rests on, paired with the compiler emitting balanced group boundaries the runtime can unwind to (and lifting its blanket try/catch-around-composables ban at the boundary).
  • Phase coverage is explicit, in three tiers: (1) composition + recomposition — the render-phase analog — is contained in v1, where the slot-table rollback and group-balance unwinding live; (2) effects, coroutines, and event handlers are a by-design non-goal of the composition catch — they never pass through composition (exactly as React catches neither async nor event-handler errors), so they reach the boundary through the imperative LocalErrorBoundary.throwToBoundary channel instead — this includes commit-time SideEffect / DisposableEffect throws, which escape rather than being contained or auto-surfaced via onError in v1 (proven by escape-asserting tests sideEffectThrow_underBoundary_isNotContained and disposableEffectInitThrow_underBoundary_isNotContained; an earlier revision of this document over-claimed onError surfacing here); (3) layout and draw failures live in a separate subsystem (the LayoutNode tree and draw scope, not the slot table) with no React analog, and are a defined v2 extension — named so they are not silently dropped, but not allowed to block v1.
  • Reset re-attempts, it does not merely re-render the fallback: reset() and the input-keyed resetKeys (the react-error-boundary pattern) re-run the protected content on the next composition — never re-entrantly inside the failing pass — so a boundary recovers the moment the cause clears rather than staying stuck on the fallback. A bounded re-throw guard (which React's own libraries still only propose) keeps a re-attempt that throws again before any successful commit from spinning: auto-reset (resetKeys / data-driven) is rate-limited and falls back to holding the fallback + reporting the loop via onError, while an explicit user-gesture reset() is always honored — a human tap cannot spin without further input. This matters more here than in a browser: headless / agent-driven UIs make a silent spin catastrophic, not a console warning.
  • Inheritable downstream: because it lands in the shared runtime, Compose Multiplatform inherits it and the ecosystem's GUI, WUI/Wasm, and JS frontends get it for free — no per-frontend reimplementation.

Relationship to Observables / RemoteObservableBoundary

The Observables workstream already needs exactly this primitive — and is the immediate beneficiary. Its read contract throws a structured RemoteObservableDataUnavailable when a projection value is not yet loaded or does not satisfy the ambient freshness constraint, and its RemoteObservableBoundary renders fallback / keep-last-good-dimmed content in response. Catching that throw at the boundary's own read needs no runtime support — but catching a throw raised deep inside the guarded subtree (the "deep-throw boundary" the Observables roadmap defers to a v2 spike) is an error boundary, and the only userland way to get it is subcomposition.

So RemoteObservableBoundary is a userland workaround, and this workstream is what retires it: once Compose has official error boundaries, RemoteObservableBoundary becomes a thin specialization — catch RemoteObservableDataUnavailable specifically, render last-known-good — layered directly on the runtime primitive, with no subcomposition and none of its limitations. The Observables deep-throw v2 ergonomics stop being blocked on a workaround spike and become a small wrapper over a supported feature.

Recovery is data-driven, not timer-driven: RemoteObservableBoundary keys resetKeys on the projection's availability/revision, so the Observable's existing invalidation / onNewValue push is the re-attempt trigger — reusing the generic mechanism with no bespoke recovery code, and with the re-throw guard as the backstop. The division of labor stays crisp: the loaded / CONSTRAINT_NOT_MET last-known-good path needs no error boundary (it is handled at the read), so only the deep-throw NEVER_LOADED path uses the boundary, recovering through resetKeys fed by invalidation.

Current state

No official error-boundary API exists in Jetpack Compose; an uncaught composition or recomposition exception crashes the application, and the compiler actively forbids try/catch around composable calls. The JetBrains maintainers closed the standing ErrorBoundary request (#2582) in 2024 as "hard to implement," offering only subcomposition / ComposePanel workarounds — which the requester demonstrated still crash the whole app. The runtime team has since begun touching this seam (Compose Runtime 1.10.0-alpha02 added "log exceptions captured in composition"), so there is a foothold to build on, not a blank slate. In this ecosystem the only containment in production is the subcomposition-based RemoteObservableBoundary approach described above — a workaround, not a primitive.

Our fork now carries a working, adversarially reviewed v1 across JVM, JS, and Wasm. The compose-multiplatform-core fork implements ErrorBoundary in the Compose runtime — containment of composition and recomposition failures with snapshot-disposal rollback, fallback in the content's slot, fallback-throw escalation to the outer boundary, reset() / resetKeys re-attempts with a bounded re-throw guard, and the imperative LocalErrorBoundary.throwToBoundary channel. Three independent adversarial reviews drove a hardening round (per-containment onError delivery, trip-record keying by composite hash plus nesting depth after the reviews' hard-cap test exposed a recursive hash-collision class, registry cleanup on boundary removal, forced-reset race fixes), and the suite now covers 56 ErrorBoundary scenarios — each run under both slot-table implementations and on three platforms: desktop/JVM (full runtime suite green), Kotlin/JS (Node), and Kotlin/Wasm (Node). See the design write-up and the implementation pull request. A comprehensive 2026-07 hardening campaign (merged as PR #3, PR #5, and PR #4, each gated on an adversarial review with every finding addressed) fixed ten further verified defects, each with a fail-first regression test: resetKeys stored by array reference; a stale fallback scope reused for equals-equal Throwables; deferred movableContentOf insertions escaping an enclosing boundary (performInsertValues bypassed containment); a contained sibling in a multi-composition insert batch discarding a successful sibling's late changes; nested deferred movable references not inheriting the enclosing boundary marker; a dangling recompose-scope anchor after a contained deferred insert was reset and recovered under a depth-1 boundary (the Gap composer now rotates to a fresh insert table per deferred insertion); pre-commit error notifications overwriting one another (now queued and dispatched in order); a throwing onError callback poisoning the recomposer (now best-effort logged); retained composer/recompose-scope references on forgotten boundary handles; and onError-less errors being retained and replayed when a callback later appeared. Runnable throw→fallback→reset→forward demos live in the runtime-test-utils test source sets and passed on all three targets. A third hardening round (merged as PR #6, driven by independent post-merge adversarial and test-comprehensiveness reviews of the round-2 result) reproduced fail-first and fixed three further defects: nested movable references enqueued globally by an earlier item of a deferred insert batch surviving the batch's contained rollback with anchors into the retired insert table; a live boundary's queued onError reports being silently lost when an outer containment abandoned the accepting pass and removed the boundary before its reporting side effect could commit (now delivered at committed forgetting); and forgotten boundaries retaining their last error/notification graphs through user-held handles (now fully cleared). The same round closed the reviews' coverage gaps — a true composite-key-hash-collision attribution test, depth-2 nested movable containment, repeated-callback-failure accounting, and exact full-text assertions for the hard-cap error, the callback-failure log line, and the demo transcripts — bringing the cross-platform ErrorBoundary suite past 60 scenarios. The spike's central finding revises this plan's core assumption: v1 needs no compiler change — React-style whole-pass abandonment (riding the runtime's existing abort/retry machinery, with attribution via a marker-group parent walk and bookkeeping that survives the abandoned pass) delivers the committed semantics runtime-only; the compiler co-design shrinks to a later subtree-scoped-unwinding optimization. Patched runtime artifacts are published to kotlin.directory as org.jetbrains.compose.runtime:runtime-desktop:1.12.0-beta02-errorboundary.4 — plus runtime, runtime-js, runtime-wasm-js, runtime-saveable, and runtime-saveable-desktop at the same version — so desktop and web frontends can pin them. (Those published artifacts predate the 2026-07 hardening merges; republishing post-hardening artifacts is a pending follow-up for any frontend that needs the fixes.) A verification review round hardened the primitive further (containment of boundaries first revealed by a recomposition; per-containment onError). The Observables deep-throw v2 ergonomics are now proven over the primitive: the Observable library ships a deep-throw RemoteObservableBoundary (desktop) specializing ErrorBoundary with availability-driven recovery and schema-skew error rendering — with end-to-end tests over a real recomposer — completing this workstream's "retire the workaround" item in its corrected form (the v1 read-at-boundary never had a subcomposition path to delete; the deep form is new capability built directly on the primitive). The capability is documented alongside the GUI / Compose guidance. Layout/draw containment remains the defined v2 extension. Remaining: pointing the ecosystem's frontends (currently on Compose Multiplatform 1.9.0 / Kotlin 2.1) at the patched 1.12-line artifacts — the web (js/wasm) form of the Observable wrapper follows the same toolchain alignment, since the fork's newer Kotlin produces klibs the current web toolchain cannot consume — and upstreaming.

Plan / roadmap

  • [x] Problem write-up + API proposal: specify the containment / rollback / reset semantics and the desired ErrorBoundary surface, aligned with the Observables deep-throw v2 requirements so a single API serves both. Done — see the design write-up in our fork.
  • [x] Compiler + runtime spike: a proof-of-concept that contains a recomposition throw to a subtree, rolls the slot table / applier back to a consistent state, and composes a fallback — built against our own fork of compose-multiplatform-core — establishing feasibility before committing to an upstream surface. Done, with a finding that reshapes the workstream: no compiler fork is needed for v1 — whole-pass abandonment on the runtime's existing abort/retry machinery delivers the committed semantics runtime-only (see the implementation pull request); the Kotlin-repo compiler work shrinks to the later subtree-scoped-unwinding optimization.
  • [ ] Carry our own patched builds (interim): until the change merges upstream, maintain forks/builds of both the Kotlin-repo Compose compiler and compose-multiplatform-core, and point this ecosystem's Compose Multiplatform frontends at the patched artifacts so error boundaries are usable here before they are official.
  • [ ] Upstream to both repos: raise the proposal and land it in both homes — the compiler change in the Kotlin repository and the runtime change in AOSP androidx (mirrored into compose-multiplatform-core) — anchored on existing demand: the closed ErrorBoundary request #2582, the root-handler regression #1764, and the runtime team's recent "log exceptions captured in composition" work (Compose Runtime 1.10.0-alpha02). Retire our forks once it lands.
  • [ ] Phase coverage: contain composition / recomposition first (v1); route effect / coroutine / event-handler failures through the imperative LocalErrorBoundary channel rather than the composition catch (matching React's line); and scope layout / draw containment — a separate subsystem with no React analog — as a v2 follow-on.
  • [x] Reset + recovery semantics: explicit reset() and input-keyed resetKeys re-attempting on the next composition (never re-entrantly); a bounded re-throw guard that rate-limits auto-reset (holding the fallback + reporting via onError) while always honoring a user-gesture reset(); and data-driven recovery (key resetKeys on data availability). Done, with end-to-end runtime tests of every behavior — including the guard stopping an immediate re-throw loop — run under both slot-table implementations and on desktop/JVM, JS and Wasm. (Screenshot tests of fallback/recovered states belong to the frontend consumers once they move to the patched artifacts.)
  • [x] Retire the workaround: done in its corrected form — the shipped RemoteObservableBoundary v1 catches at its own read and never had a subcomposition path to delete, so this item resolved to building the deep-throw v2 on the primitive: the Observable library now ships a deep-throw RemoteObservableBoundary (desktop) over ErrorBoundary, with availability-driven recovery and end-to-end tests over a real recomposer.

Graduation

Graduation has two tiers, because they have different owners:

  • Functional graduation (within our control) — error boundaries work in this ecosystem's Compose Multiplatform frontends via our patched compiler + compose-multiplatform-core builds, and RemoteObservableBoundary has dropped its subcomposition path against that fork. This is when the workstream delivers its value — the deep-throw v2 ergonomics and the subcomposition removal are unblocked by our patched build, not by an upstream merge — so the capability is documented alongside the GUI / Compose guidance in the Documentation Repository.
  • Upstream graduation (outside our control) — the change lands in AOSP androidx and the Kotlin repository, at which point we retire the forks and the recurring maintenance cost drops to zero.

The fork is maintained for exactly as long as upstream Compose lacks error boundaries — no longer. Carrying a patched compiler (pinned to each Kotlin version) and a patched compose-multiplatform-core is a real rebase tax on every Compose / Kotlin bump, which is the standing incentive to upstream quickly: the sooner it merges, the sooner the fork — and its tax — goes away.