Building Glyx
For the past 18 months I've been building a desktop framework for React developers. Not another Electron wrapper, not a WebView shell — something genuinely different: a GPU renderer and a custom React reconciler running on a stripped-down V8 runtime.
This is the story of why, and how.
Starting from tradeoffs
Electron is an engineering triumph. Shipping a full Chromium browser with every app was the right call in 2013. You got a universal rendering engine, a full Node.js runtime, and the ability to hire any web developer. VS Code, Slack, Notion — the list of successful Electron apps is long for good reason.
The tradeoffs of the browser-engine model are well documented. Glyx explores a different point in the design space: a native GPU renderer with no browser lineage, using React as the programming interface.
wgpu + Vello: the GPU layer
wgpu (opens in a new tab) is a Rust implementation of the WebGPU API. It runs on Vulkan, Metal, and DirectX 12. Vello (opens in a new tab) is a 2D renderer built on wgpu — it draws paths, text, and images using compute shaders.
Together, they give you a 2D scene graph with GPU acceleration, on any platform, without a browser.
The Glyx renderer takes a positioned element tree and issues Vello draw calls. A View becomes a filled rectangle. A Text becomes a Vello glyph run. A Pressable is a View with an event handler attached. Everything is a GPU draw call.
Crucially: Vello and wgpu are the same GPU pipeline as Canvas3D. There's no "2D mode" and "3D mode" — they're different users of the same wgpu command encoder.
The React reconciler
React's reconciler is its best-kept secret. Most developers think of React as a DOM library. It's not — it's a diffing engine with a pluggable host. React Native uses the same diffing engine as React DOM but produces native iOS/Android view hierarchies. Glyx does the same thing for our element tree.
The react-reconciler package exposes a host config interface. You implement about 20 methods:
const reconciler = ReactReconciler({
createInstance(type, props) {
// Map 'View', 'Text', etc. to native element structs
return glyx_create_element(type, props)
},
commitUpdate(instance, updatePayload, type, oldProps, newProps) {
// Props changed — push the diff to Rust
glyx_update_element(instance, updatePayload)
},
appendChild(parent, child) {
glyx_append_child(parent, child)
},
// ... ~15 more methods
})The Rust side receives these mutations and updates the element tree, which the layout engine (Taffy) processes on the next frame.
Getting this right took about two months. The tricky parts:
- Concurrent mode — React's scheduler interleaves work across frames. The reconciler must be non-blocking. Our Rust calls are synchronous by design (no async FFI in the hot path), so we batch them at commit time.
- Text measurement — React needs to know text sizes before layout. We call into Rust synchronously to measure glyph runs. This is one of two synchronous Rust calls in the render path; everything else is batched.
- Portal semantics —
glyxWindow.Portalrenders into a secondary window's element tree, not the main one. This required a second reconciler instance per window.
Taffy and the layout problem
React gives us a tree; the renderer needs rectangles. The gap between them is a flexbox layout engine, and we use Taffy (opens in a new tab) — a Rust implementation of the CSS flexbox (and grid) algorithm.
The hard part was never "run the algorithm." It was making layout feel free.
- Text is the tricky leaf. Flexbox needs a node's intrinsic size before it can position siblings. For a
Textnode that means shaping the glyph runs and measuring their width — a call into Parley, our text shaper. Taffy has no concept of text, so we attach aTextMeasureCtxto every text leaf and hand Taffy a measure closure. With no explicitheight, Taffy asks the closure for the wrapped(width, height); with an explicit height it skips the measure entirely. That single design choice is what lets multi-line text "just fit" without hard-coding a height in JS. - Sub-pixel rounding. Early on, Taffy's measurement rounding would shave a fraction of a pixel off a text node's width and wrap the last word onto a new line. The fix was a
+1pxguard on measured text width so wrapping only happens when it genuinely should. - Baseline alignment. Mixing
TextandViewchildren in a row and aligning onbaselinemeant threading the font baseline (not the box top) through the layout result into the glyph painter. Taffy givesAlignItems::Baseline; we had to make the renderer honor the measured baseline rather than the box. - Avoiding the full rebuild. The reconciler batches prop changes and commits them at once, but most updates are visual (color, background, clip, scroll position) and don't change geometry. We classify props as layout-affecting (
width,height,flex,padding,gap,text,font_size, …) versus visual-only, and only re-run Taffy for the former — skipping the Taffy rebuild on scroll and hover frames saves roughly a millisecond each. For nodes that do change, we update the Taffy style in place and mark just that subtree dirty; Taffy skips clean subtrees automatically. - Scroll views. A
ScrollViewclips its children and offsets them byscroll_offset_y. That offset is a visual transform, not a layout change — so we apply it as a cumulative offset during the recursive render pass and write scroll-adjusted coordinates back into the hit-test cache, so aPressabledeep inside a scrolled list still receives clicks at the right place.
Taffy knows nothing about rendering. It only computes geometry. That separation is exactly why the same layout tree feeds TinySkia and Vello identically.
V8 snapshots: the startup trick
A V8 snapshot is a serialized heap image. At build time, you run V8, execute your JavaScript, and serialize the resulting heap to a binary file. At startup, instead of parsing and compiling JavaScript, V8 deserializes the heap directly into memory.
The deserialization is fast. The first time V8 runs your code, it JIT-compiles everything. The snapshot captures the already-compiled functions. On subsequent starts (and for distributed apps, every start), V8 skips straight to executing.
For Glyx, the snapshot includes:
- The Glyx runtime library (~200KB of JS)
- React and the custom reconciler (~150KB)
- Your application code (varies)
The result: cold startup under 50ms for a typical app, versus 600–800ms without the snapshot.
The gotcha: snapshot generation takes ~15 seconds. This is the glyx build step. Development mode skips snapshot generation and uses HMR instead.
The binary trailer model
One of the design goals was a single-file executable. No installer that puts files in five different places. One file, double-click, run.
The mechanism: the Glyx runtime binary has a 64-byte trailer at the end. When it starts, it reads the trailer to find an offset within itself. At that offset is the app blob — your code, assets, snapshot. The OS sees one file; the runtime finds two logical sections.
This is the same model Bun uses for its single-file executables, independently arrived at. It's a clean solution.
The runtime binary is ~18MB. A typical app blob is 1–3MB. Total: ~20MB.
Internationalization: ICU and the locale problem
A stripped V8 still needs Intl — number, date, and currency formatting, collation, plural rules. V8 gets all of that from ICU, and ICU's data file (icudtl.dat) is ~10 MB. For a framework obsessed with small binaries, shipping 10 MB of locale data you don't use is a non-starter.
So we load ICU data once, before V8 initializes, and we trim it to the locales an app actually declares. The app lists locales: ['en', 'ja', 'de-DE'] in its config; at build time we run icupkg to strip every top-level locale bundle except the ones requested (plus root and each locale's parent prefixes, e.g. de for de-DE). A typical app drops from ~10.8 MB to ~4.8 MB — about 56% — with no loss of formatting fidelity for the locales it ships.
Two things fall out of this:
- The default locale (first entry in
locales) is applied to V8 at startup, sonew Intl.NumberFormat()with no argument formats in the app's primary language. - Any locale you format with explicitly must also be declared, or it falls back to
rootdata — numbers still format, but month and day names won't be localized.
At runtime the data is loaded from an external icudtl.dat next to the executable (for packaged apps) or from the embedded copy (for dev/tests). Either way, Intl.* and .toLocaleString() just work inside your React components.
Packaging and installers
The single-file binary is the headline, but real distribution means native installers. glyx package wraps the trimmed binary with its runtime files, icon, and licenses:
- Windows — NSIS produces a
.exeinstaller (Start Menu and desktop shortcuts, Add/Remove Programs entry, deep-link scheme registration). The icon is embedded into the.exedirectly viarcedit, no recompile. - macOS — a
.appbundle and a.dmgbuilt with the systemhdiutil. - Linux — a
.debproduced entirely in Rust (no external tools) and a.tar.gzfallback.
NSIS and rcedit are downloaded and cached automatically on first use, verified by SHA-256 — nothing for the app developer to install.
Where it is now
Since writing this, a lot has shipped. The reconciler is stable. V8 snapshots and the binary trailer model are in production. The capability system covers filesystem, network, database, audio, AI inference, camera, keychain, and more.
Some highlights from recent releases:
- Local AI — embed, generate, and transcribe audio, all on-device via Candle
- Canvas 2D + 3D — GPU-accelerated drawing surfaces alongside the UI tree
@glyx-dev/design— a design system with Catppuccin-based tokens, dark/light/system themes, and base components- Backend commands — a typed
backend.*pattern for registering Rust async functions callable from React - JS plugins — extend any app without touching the Rust layer
- Hot reload — sub-100ms edit-to-screen cycle in dev mode, using a Chrome DevTools Protocol inspector
- Internationalization — ICU locale data trimmed per-app via the
localesconfig, with fullIntlsupport at runtime - Packaging — native NSIS /
.dmg/.debinstallers and cross-compilation throughglyx package - Taffy-driven layout — incremental flexbox layout with a text measure function, so geometry only recomputes when it actually changes
The core model — React reconciler, GPU renderer, V8 snapshot, single binary — is unchanged and will not change before v1.0.
The framework is still pre-release. APIs marked Experimental may shift. If you're building something with it, I want to hear about it.
If you build something with it, I want to hear about it.
— Tobi Adelabu
GitHub (opens in a new tab) · Twitter (opens in a new tab) · Discord (opens in a new tab)