Skip to content

Threading and architecture

4. The defining constraint: who owns the main thread

Section titled “4. The defining constraint: who owns the main thread”
  • macOS: AppKit must run on the main thread, inside a run loop that never returns ([NSApp run]).
  • Linux: GTK and Wayland/X11 integrations expect the same.
  • Windows: windows are tied to the thread that created them.
  • Bun: the entry script runs on the main thread. Bun has no public API to run one tick of its event loop, and it doesn’t expose its epoll/kqueue file descriptor.

Calling the native loop from main-thread JS would freeze Bun’s timers, promises and I/O. Polling the native loop from a JS timer trades idle CPU against input latency.

Decision: the bootstrap starts a Worker running the app, then calls the core’s mt_run() on the main thread. That call blocks for the life of the process. All app JavaScript runs in the Worker.

Validated (Linux, Bun 1.4.4): with the main thread blocked inside an FFI call, the Worker’s timers kept their schedule and its async I/O completed. A native pthread also woke the Worker through JSCallback({ threadsafe: true }). The spike code is in Appendix A. Spike results (2026-11-03), Linux: passes with a real winit window and wgpu, under Xvfb with Mesa lavapipe; see threading test results.

  • winit’s event loop runs on Bun’s main thread inside mt_run.
  • An idle window draws 0 frames and causes 0 wake-ups.
  • fetch and timers in the Worker keep working through live resizes.
  • With the Worker blocked in a busy loop for 1 s, the main thread kept drawing, and wake-ups coalesced to exactly one.

macOS and Windows (2026-11-03): the same self-test passes in CI (run) on macOS (Apple Silicon, Metal) and Windows (D3D12, WARP). winit’s event loop runs on Bun’s main thread on all three desktops.

┌──────────────────────────────── one process ────────────────────────────────┐
│ │
│ MAIN THREAD (native loop, no JS after bootstrap) │
│ winit event loop · windows · wgpu surfaces · IME · AccessKit · menus │
│ ├─ input ──► hit-test / scroll / focus in core ──► event queue ─► wake ──┼──┐
│ ├─ resize ──► lock tree, relayout (bounded wait) ──► repaint │ │
│ └─ RedrawRequested ──► composite latest scene (+ native scroll/anim) ──► │ │
│ wgpu present │ │
│ ▲ │ │
│ EventLoopProxy │ "scene ready" / window commands │ │
│ │ │ │
│ WORKER THREAD (Bun Worker: the app) │ │
│ TSX components ─► Solid signals ─► renderer ─► op encoder (ArrayBuffer) │ │
│ ▲ │ queueMicrotask │ │
│ │ ▼ │ │
│ dispatch handlers ◄─ mt_take_events() mt_commit(ptr,len) ─────────────┼──┤
│ ▲ │ (runs in core, on this │ │
│ └──────── onWake (threadsafe JSCallback) ◄┘ thread: apply ops, │ │
│ layout, text, scene) ◄─┼──┘
└─────────────────────────────────────────────────────────────────────────────┘
Thread Runs Owns
Main winit loop; compositing and present; IME; AccessKit adapter; menus, dialogs, clipboard Windows, wgpu device/queue/surfaces, GPU textures and atlases, scroll offsets, running animations
Worker (JS) App code; Solid; op encoding. The core’s mt_commit runs here synchronously JS mirror tree, signals, handlers
Core worker pool (optional, later) Image decoding, font loading —

The shadow tree (nodes, styles, layout results, text layouts) sits behind a Mutex in the core. mt_commit (Worker thread) and resize relayout (main thread) both take that lock. The scene that drawing reads is an immutable snapshot (Arc<Scene>) handed from commit to the main thread, so drawing never needs the lock.

  1. The main thread never calls into JS, and it never waits on the tree lock longer than one commit’s layout. During a resize it waits at most about 16 ms, then draws what it has.
  2. JS never calls platform APIs directly. Everything goes through the core, which forwards to the main thread through winit’s EventLoopProxy.
  3. Exactly one mt_commit per JS turn that changed anything. Zero FFI calls for turns that changed nothing.
  4. Scrolling, compositor-only animations (transform, opacity) and hover effects driven by native state need no JS round trip.
  5. A node is never re-inserted where it already is. A move is an INSERT with a new before anchor.
  6. The Worker reports its own failures. Once mt_run starts, the main thread never reads Worker messages or error events, so an uncaught error or rejection in the Worker logs and calls mt_quit(70) itself. The bootstrap waits for the Worker’s ready message before it blocks. (Found by the threading spike.)