In my last post, I introduced DriftJS — a frontend framework built around a register-based bytecode VM instead of Virtual DOM diffing or a compile-only reactive model. This post is the natural follow-up: how does the reactivity actually work under the hood?
I've been calling the mechanism Jump-Based Reactivity in my own notes — that's just the name I've given it for now, not an established or official term, so take it as a working label rather than a claim of industry terminology. Here's the actual model behind it.
The two established approaches, and where they spend their time
Virtual DOM diffing (React, and Vue's core diffing engine) answers "what changed?" at runtime, by comparing two trees. Even a single-character update walks a comparison algorithm over the affected subtree before a single DOM node gets touched. The cost is proportional to how much UI you're describing, not how much of it actually changed.
Signals (Solid, Svelte 5, Angular signals) answer "what changed?" by building a live subscription graph at runtime — a reactive value tracks every computation that reads it, and writing to it directly notifies exactly those subscribers. No tree, no diff, but there's a graph of subscription objects that has to be built, maintained, and torn down as your app runs.
This model takes a third position: answer "what changed, and where" entirely at compile time, so that at runtime there's no tree to walk and no graph to traverse — just a direct address to jump to.
How it actually works
When your template is compiled, every place a piece of state gets rendered — an interpolated value, a dynamic attribute — has a precise, fixed location in the compiled instruction stream. The compiler doesn't just generate that instruction stream; it builds an index while doing so: for every variable your component declares, it records exactly which instruction addresses depend on it.
Think of it as a table of contents built at compile time: "if count changes, the instructions that care about it live at these exact addresses." This table is static — built once, when the component is compiled, and reused for every instance of that component afterward. Nothing about it changes at runtime.
At runtime, when a state variable is mutated, the framework doesn't recompute anything or walk anything. It looks up the variable's entry in that compile-time table, and for each address recorded there, it jumps directly to that exact point in the instruction stream and executes precisely one instruction — read the new value, write it to the DOM. Then it stops. No re-execution of the surrounding component, no walk through unrelated instructions, no revisiting parts of the UI that didn't change.
Multiple state changes in the same tick get coalesced into a single batched pass, so five synchronous mutations don't trigger five separate rounds of this — they're collected and flushed together, with a safety limit against updates that could otherwise trigger each other in an unbounded loop.
Conditional and loop blocks work on the same principle at a slightly coarser level — tracked as named regions rather than individual instructions, so a state change that affects which branch is active, or which items exist in a list, resolves to "which regions does this variable affect" via a direct lookup, not a scan across every conditional or loop in the component.
Why this isn't Virtual DOM with extra steps
There's no tree, at any point, that gets rebuilt and compared. The entire "what needs to update" question is answered once, at compile time, and stored as a flat address table — never re-derived at runtime against a tree structure. A component with hundreds of static DOM nodes and one dynamic binding pays a cost proportional to one — the address recorded for that binding — never the size of the surrounding tree.
Why this isn't Signals with a different name
This is the comparison people reach for fastest, and it's worth being precise about. A signal's dependency relationships are discovered dynamically, at read time — a computation reads a signal, and that read creates a subscription object that has to live in memory for as long as the relationship holds. This compile-time address table is the opposite: built once, entirely at compile time, and it's pure data — a lookup table, not a graph of live subscription objects being created and torn down as the app runs.
The other real difference is granularity, and it's worth being upfront about: signals are fine-grained by construction — one reactive value, one precise set of subscribers. This model currently groups by declared variable name rather than individual binding. That's a real tradeoff against what signals give for free, and it's the thing I'm actively tightening next — recent benchmarking made it clear exactly where that coarseness costs the most: targeted updates to individual list items, not bulk rendering.
Lineage, and what's actually new
Compiling a template into an instruction stream that gets executed rather than diffed isn't new — Ember's Glimmer engine has done that since around 2017, and it's the closest existing relative to this design. What's different is how the "what changed" question resolves at runtime. Prior bytecode-VM approaches build their update-tracking structures at runtime and walk them sequentially when state changes. This model's address table is built at compile time and resolved through direct lookups — no runtime structure to walk, just a jump to a known address.
That's the actual claim: not reactivity reinvented from nothing, but a genuinely different way of assembling ideas that mostly already existed — compile-time indexing, direct addressing — into something with a different performance profile in practice.
Feedback, are genuinely welcom
GitHub Repo: Repo
Top comments (0)