Native (mobile)
The dual-thread architecture
Lynx runs your application across two threads, and miso maps onto both:
- BTS — the background thread ("background thread script"). This is where your application logic lives. Everything runs here by default: the
updatefunction, event handling,Effectscheduling and all virtual-DOM diffing. - MTS — the main thread ("main thread script"). This thread owns the actual element tree and rendering. It is where the pixels land. It is also available as a low-latency escape hatch for performance-critical event handling (see Main-thread events).
The same Haskell bundle runs on both threads; the native runtime selects the BTS or MTS drawing context per thread from a global flag, so there is no renderer to register — native starts the app directly. The guiding principle: everything originates on the BTS. The MTS is a rendering surface that the BTS drives across the thread boundary.
Knowing which thread you are on
Lynx builds the bundle with rspeedy, which compiles the sources twice, once per thread, inlining a compile-time constant (__BACKGROUND__) that distinguishes the two. That constant surfaces in Haskell as three top-level Bools in Miso.Runtime (re-exported from Miso):
mtsTruewhen this execution context is the Lynx main thread.btsTruewhen this context is the Lynx background thread.webTruefor a plain web / WASM build (neither Lynx thread).
Exactly one is True and the value is invariant for the lifetime of a JS context, so the runtime computes it once and caches it. Runtime code branches on mts / bts to decide where work runs (e.g. the scheduler suppresses the paint step on the MTS, which keeps only a read-only model replica).
What crosses the thread boundary, and how
Because logic (BTS) and rendering (MTS) live on different threads, miso synchronises them by shipping messages across the boundary. This is largely invisible, but understanding it explains the API constraints below.
- Initial draw — the very first draw happens on the MTS itself, and does not rely on the BTS diffing a tree and transferring patches. The root component is booted from a
StaticPtr(vianative), so the MTS reconstructs it from the pointer'sStaticKeyalone and renders the first frame locally (Lynx's instant first frame). Only after this does the cross-thread patch protocol take over: every subsequent diff runs on the BTS and ships patches to the MTS. - Subsequent component mounts — when the BTS
viewmounts a child, that mount is synchronised to the MTS asynchronously using static mounting: the child is wrapped in astaticpointer so only itsStaticKey— not a closure — crosses the boundary. See Static mounting. - State synchronisation — the BTS owns the shared model and ships it to the MTS as it changes (JSON-serialised, hence the
ToJSON/FromJSONconstraints on native mounting combinators), so main-thread*MainWithhandlers observe an eventually-consistent copy. A child's initial props ride the static-mount payload, but props and the global context are not re-synced on later changes: after the first frame they stay background-thread-only (matching ReactLynx). - Events — events raised on the MTS are, by default, forwarded to the BTS where
updateruns. Cross-thread handlers are carried as anEventHandlerembedded withevent . static (…)so the peer thread can rebuild the handler from itsStaticKey.
The lifecycle, in sequence
Component initialization and lifecycle. The runOnBG / runOnMain effects and the on / onMain handlers (highlighted) are how work hops between the threads.First-frame rendering (instant first frame)
The MTS painting frame one itself is Lynx's instant first frame: the user sees UI without waiting for a background render and patch round-trip. Meanwhile the BTS boots the same root and builds the identical virtual-DOM tree in lockstep — with deterministic nodeId parity, so both threads address the same elements — but suppresses its own create-patches for that first frame, since the MTS already painted them. A single global initialDraw latch governs this on both threads; native clears it once the whole root mount has finished.
After that handover the responsibilities are fixed, mirroring ReactLynx: the BTS is the sole diff / paint authority — it runs update, diffs and ships patches — while the MTS only applies those patches (and runs main-thread scripts / handlers). The MTS never diffs or repaints from the scheduler again; this is why the shared model is BTS-owned and why nothing you do on the MTS should try to redraw declaratively.