Repository · 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.
SubcomposeLayoutmeasures 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.
CompositionLocalpropagation,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.throwToBoundarychannel instead — this includes commit-timeSideEffect/DisposableEffectthrows, which escape rather than being contained or auto-surfaced viaonErrorin v1 (proven by escape-asserting testssideEffectThrow_underBoundary_isNotContainedanddisposableEffectInitThrow_underBoundary_isNotContained; an earlier revision of this document over-claimedonErrorsurfacing here); (3) layout and draw failures live in a separate subsystem (theLayoutNodetree 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-keyedresetKeys(thereact-error-boundarypattern) 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 viaonError, while an explicit user-gesturereset()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
ErrorBoundarysurface, 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
LocalErrorBoundarychannel 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-keyedresetKeysre-attempting on the next composition (never re-entrantly); a bounded re-throw guard that rate-limits auto-reset (holding the fallback + reporting viaonError) while always honoring a user-gesturereset(); and data-driven recovery (keyresetKeyson 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
RemoteObservableBoundaryv1 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-throwRemoteObservableBoundary(desktop) overErrorBoundary, 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-corebuilds, andRemoteObservableBoundaryhas 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.