← All posts

Voodoo.js: a runtime architecture without a compiler, Virtual DOM, or eval

A layer map of Voodoo.js: walker, scope, reactivity, the cost of skipping a build, bundle size, feature creep, and HTML as an artifact for AI coding.

Voodoo.js: a runtime architecture without a compiler, Virtual DOM, or eval
Contents

Can you build a modern frontend runtime powerful enough for real interfaces while refusing a mandatory compiler and a build pipeline? Voodoo.js answers “yes” — but only if you accept four hard constraints: plain HTML without a required build step, no eval / new Function, no Virtual DOM, and no third-party runtime dependencies. We unpacked the idea in the signal overview and the expression pipeline in the parser and interpreter teardown. This piece is the full architecture map: layers, trade-offs, benchmarks, bundle size, and the question of whether “one script” quietly turns back into a large framework.

Key takeaways

The chain matters more than a catalog of directives. The project’s interest is not a list of v-* attributes, but the path HTML → walker → expressions → scope → Proxy/effects → direct DOM writes.

Dropping the Virtual DOM is a consequence of the model, not a slogan. If the runtime knows which node depends on which value, comparing two trees is unnecessary. The exception is lists (v-for), where local reconciliation still appears.

A compiler moves work to build time; Voodoo.js moves it back into runtime. Hence an honest reading of the benchmarks: not a “React killer,” but a neighbor in milliseconds with a different entry cost.

The size of “one file” is a serious downside of the full build. Core is noticeably lighter than full; comparing voodoo.full.min.js with tiny Preact is asymmetric, but the fact remains: this is not a microscopic runtime.

The main maturity risk is feature creep. Router, HTTP, UI, charts, i18n… If the shell swells into “everything at once,” the original simplicity advantage can vanish. Meanwhile the “HTML + runtime” model is especially strong for AI-generated micro-apps.

Four constraints and one chain

The architecture grows out of constraints, not out of a desire to “do Vue, but in HTML”:

  1. an application from ordinary HTML without a mandatory build;
  2. expressions without eval / new Function;
  3. no Virtual DOM;
  4. no runtime dependencies.

Simplified chain:

HTML
  → DOM Walker (directives / components / text)
  → Lexer → Pratt Parser → AST
  → Interpreter
  → Scope lookup
  → Reactive Proxy / Effects
  → Direct DOM write

How JSX-like inserts are recovered from a browser-chopped DOM, and how the interpreter works, are covered in the parser satellite. What matters here is different: this is no longer a small templating engine, but a stack of layers with clear boundaries (core does not know about document; browser entrypoints are the only page start point, per ARCHITECTURE.md).

That separation is easy to miss when you only read marketing copy. Many HTML-first libraries blur “sprinkle behavior on nodes” with “own a language and a reactive graph.” Voodoo.js owns both. The walker is not a convenience loop; it is the compiler substitute. Scope is not a bag of globals; it is the security and lookup model. Effects are not a helper API; they are the unit of work that replaces tree diffing for ordinary bindings.

The browser HTML parser as an intermediate representation

Ordinary JSX cannot be “just dropped” into HTML: the browser does not execute {user} as JavaScript. Voodoo.js treats the aftermath of HTML parsing — text nodes and elements — as a reversible representation of an expression, rebuilds the string, and runs it through its own language pipeline.

JSX compiler:  source → compiler → JS → DOM
Voodoo:        source → browser HTML parser → DOM → runtime parser → AST → DOM

A compiler usually turns markup into createElement calls or direct DOM operations ahead of time. Voodoo.js does the equivalent while the page is running. The fundamental trade-off:

A compiler moves work out of runtime into build time. Voodoo.js moves it back into runtime.

So the framework cannot simultaneously be the simplest and the fastest in every scenario. Neighbors in spirit that “return expressiveness to markup” include htmx and HTML over WebSockets, but those usually sit on the axis “the server moves HTML,” not “a client expression language lives in the document.”

Why keep calling this an intermediate representation rather than a hack? Because the design depends on it. If the browser normalizes a fragment differently than the recovery scanner expects, the expression string is wrong before the lexer even starts. That is not a footnote; it is the price of using the platform’s HTML parser as a free front-end to your language. Teams evaluating Voodoo.js should treat weird HTML edge cases — tables, void elements, custom elements, unexpected whitespace — as first-class risks, not as “docs later.”

Scope, reactivity, and why the Virtual DOM is “not needed”

After the parser comes the question: where do we look up user? A dedicated scope hierarchy (rootv-datav-for → component) plus magics and allowedGlobals gives controlled lookup without turning the page into one global JavaScript context. That is also a piece of the security model without eval.

An update on count++ does not go through rendering a whole component and diffing trees. It goes through Proxy.set → trigger → effects that read count → queue → microtask → evaluate → write to a concrete node. Conceptually the dependency graph is:

WeakMap(target → Map(key → Set<ReactiveEffect>))

Hence the absence of Virtual DOM as a job: if an effect is bound to <strong>{count}</strong>, you change textContent — you do not reconcile a new tree with an old one. The cost is many small effects and dependency accounting on every read; on huge trees with thousands of bindings that can become visible. The project’s own architecture notes that in some forms of very large lists a Virtual DOM sometimes wins, while the fine-grained model wins where only really dependent nodes update.

The scheduler collapses a burst of synchronous mutations into one flush; post-flush handles lifecycle. Honesty about lists requires a caveat: v-for still reconciles real DOM blocks (mutation log, keys, LIS) — see the deep dive in the interpreter article.

For architecture reviews, this is the slide that matters in a design meeting. Product managers hear “no Virtual DOM” and hear “magic speed.” Engineers should hear “different unit of work.” Effects localize updates; they also multiply bookkeeping. If your UI is a spreadsheet of ten thousand cells with overlapping derived values, you are not escaping complexity — you are choosing where it lives. Voodoo.js puts that complexity in the reactive graph and the list reconciler rather than in a compile step.

DOM Walker, directives, and a “clean” DOM

runtime/walker.ts is the engine: walk nodes, collect directives, resolve terminal v-for / v-if first, create scope, run behavior, strip service attributes, descend into children. Order is critical: you cannot bind {item.name} before an iteration scope exists.

Architecture principle: every declarative behavior in HTML is a directive. Priorities (FOR/IF/DATA/COMPONENT/…) let capabilities grow without rewriting the walker. After processing, v-* / @ / : may disappear from visible markup while sources live in a WeakMap — the inspector DOM looks “clean,” metadata sits aside. MutationObserver (with autoDiscover) animates nodes inserted from outside and tears down effects on removal; EffectScope ties the life of the reactive graph to the life of a subtree.

A component here is not a render function returning a virtual tree. It is an animated region of existing DOM with state/props/lifecycle. Props are evaluated in the parent scope. That is a different mental model from React, and closer to “a document with islands of behavior.”

This model changes how you think about ownership. In a typical SPA, the component tree owns the DOM. In Voodoo.js, the document owns the structure; the runtime owns behavior overlays. That is why the cleaned inspector view is more than a cosmetic trick: it advertises the contract. What you see in DevTools after directives install is closer to what a designer or a CMS editor understands as “the page.” What remains in memory is the program. Teams that live in Storybook-first component catalogs will feel friction; teams that ship static pages with islands of interactivity will feel relief.

Full layer diagram

Assembled map:

HTML (source of truth)
  → DOM Walker + MutationObserver
      → Directives / Components / Text
          → Scope (lexical hierarchy)
              → Lexer → Parser → AST → Interpreter
                  → Reactive Proxy (tracking)
                      → Effects + microtask scheduler
                          → Real DOM (text / style / attrs)

Above that, a real project still has platform services (HTTP, store, router, i18n, UI…) — and those feed the paradox of the “simple script” discussed below.

Lifecycle comparison:

React (simplified) Voodoo.js
Question How do we get the correct DOM from the new state? Which nodes depend on the changed state?
Path state → render → virtual tree → diff → patch state → Proxy → graph → effect → interpreter → DOM write
Svelte complex compiler → simple runtime simple pipeline → more complex runtime

On fine-grained updates it sits closer to Solid, but Solid gets JSX through a compiler; Voodoo.js gets it through DOM + interpreter.

Reading the table as a checklist is a mistake. React is not “wrong” for asking about the whole tree; that question fits teams that want one render function as the single source of truth. Voodoo.js asks a narrower question because the markup already exists. Svelte pays at compile time so the shipped runtime can stay thin. Voodoo.js pays at runtime so the authoring path can stay “open the file.” None of these answers is free. Architecture is choosing which tax you pay and when.

Benchmarks: what skipping a build buys you

Published author measurements (list of 1000 elements, median; line around 0.13 / repository state September 2026):

Framework Create 1k Update every 10th Clear 1k
Vanilla JS 39.51 ms 6.65 ms 20.04 ms
Preact 71.03 ms 2.73 ms 30.68 ms
Voodoo.js 77.47 ms 4.69 ms 30.14 ms
Vue 78.72 ms 14.29 ms 32.84 ms
Solid 80.13 ms 0.90 ms 21.85 ms
React 81.22 ms 4.65 ms 33.55 ms
Alpine 157.06 ms 111.29 ms 32.76 ms

Reading: it does not destroy the competition; it is noticeably faster than Alpine on update; close to React in some scenarios; far from vanilla and from Solid on update; and it does not require a JSX compiler. Formula:

absence of a build step is bought with measurable runtime cost.

On the cost of heavy build pipelines in the “ordinary” world, see also Turbopack chunking.

How should a team use this table? As a veto on slogans, not as a procurement score. If your product’s pain is cold-start complexity for internal tools, a few dozen milliseconds on create may be acceptable. If your product’s pain is updating dense dashboards sixty times a second, Solid’s update column is the one that should keep you honest. Voodoo.js’s pitch is not “win every cell.” It is “stay competitive enough while deleting the mandatory toolchain.” That is a product claim as much as a performance claim.

Runtime size: the uncomfortable truth

In published numbers, roughly:

voodoo.core.min.js   ~141 KB min / ~48 KB gzip
voodoo.min.js        ~265 KB min / ~85 KB gzip
voodoo.full.min.js   ~442 KB min / ~134 KB gzip

Full pulls HTTP, forms, validation, router, UI, charts, i18n, animation, and more. Comparing full with minimal Alpine/Preact is unfair — but saying “just one tiny script” is also wrong. For internal panels, 50–130 KB gzip is often acceptable; for a marketing landing with a hard budget it is already a choice of entrypoint (core vs full) and a sober audit.

Size interacts with the architecture story in a subtle way. The core’s weight is partly the language and reactivity stack — the price of doing compiler work in the browser. The full build’s weight is partly product ambition — the desire to be a platform, not a sprinkle library. Critics who only attack gzip size without separating those two stories argue past the maintainers. Defenders who only say “pick core” without admitting that many tutorials pull people toward full argue past the users. An honest architecture review names both layers.

Feature creep and the artifact format for AI

The starting dream: <script src="voodoo.js"> and done. Then stores, router, HTTP, forms, UI, charts, DnD, WebSocket, devtools, CLI inevitably appear… The ecosystem grows — and the question returns: will completeness eat the original simplicity?

Here also sits the strongest “for” argument in the AI-coding era. A request like “dashboard with a table, search, and CRUD” today often births a Vite/React tree. The alternative:

index.html + one runtime

The document is portable again: send as a file, open locally, embed, generate with a model, edit without a dev server. This is less “a new React,” more a new artifact format — an HTML-native reactive document. The radical horizon is not victory of one framework, but a class of technologies “HTML + reactive runtime” instead of a mandatory JSX/TS/compiler/bundler tower.

Where I would place Voodoo.js today (aligned with the pillar verdict):

Scenario Assessment
Prototype / internal tool / AI micro-app Strong
Static page with moderate dynamics Good
Core of a large product, hiring for React, strict perf/SEO Early / usually no
Replacing Solid/Svelte “because no build” Bad motivation

For AI workflows specifically, the artifact format matters more than any single directive. Models already emit coherent HTML. They struggle with fragile configuration graphs. If the success criterion for a generated UI is “open this file and click,” Voodoo.js-shaped stacks reduce the distance between generation and verification. That does not remove review, accessibility, or data-boundary discipline. It changes the default shape of the deliverable from “repository” to “document.” Teams building agent pipelines should treat that shape as a first-class design choice, not as nostalgia for the 2000s.

The main technical debt: a custom expression language

The security upside of a custom interpreter (allowlisted globals, CSP without unsafe-eval) mirrors into debt: you must maintain parsing, precedence, arrays, objects, functions, arrows, JSX-like inserts, errors, scope, and security boundaries. Every language extension pushes toward the question:

How far can you grow a DSL before it becomes a bad copy of JavaScript?

Too little — the framework is awkward. Too much — a second JS runtime inside HTML. A sane hybrid: real JavaScript for logic and APIs, Voodoo.js expressions for bindings in markup.

This debt is also an organizational debt. Someone has to own edge cases when an expression almost works. Someone has to document which language features are intentional and which are accidental. Someone has to decide whether TypeScript types for markup expressions are worth inventing. Mature frameworks amortize that work across years and companies. A young runtime amortizes it across nights and pull requests. If you adopt Voodoo.js for more than a sandbox, budget maintenance of the expression surface the way you would budget a small language — because that is what it is.

Frequently asked questions

How does this article differ from the parser teardown?

Short answer: the parser piece is a vertical deep dive into the expression pipeline; this one is a horizontal map of architecture, benchmarks, size, and product strategy.

Read both: interpreter + this material.

Do you need a Virtual DOM if you have fine-grained effects?

Short answer: for pinpoint bindings in this model — no; for complex lists, reconciliation still appears.

The claim “VDOM is bad forever” is not being made here.

Why is the full bundle so large?

Short answer: because full is already a services platform, not only a reactive core.

Take core / narrow entrypoints if byte budget is critical.

Is this a good target for AI generation?

Short answer: yes for single-file micro-apps; no as a blind replacement for a corporate React stack.

Fewer configs — shorter path “prompt → clickable HTML,” higher risk of a sheet without boundaries.

Should a team learn Voodoo.js instead of Svelte/Solid?

Short answer: as a broadening of perspective and a sandbox tool — yes; as the sole competence of a product team — no.

The market and ecosystem still orbit mature frameworks.

Where are the primary sources?

Short answer: the repository, ARCHITECTURE.md, benchmarks, and docs on GitHub; a short announcement — the news post.

Links: GitHub, ARCHITECTURE.md, benchmarks.

What to investigate next

  1. src/parser/ — how close the interpreter has come to a JavaScript subset.
  2. src/reactivity/ — compare Proxy/effects/scheduler with Vue 3 and Solid.
  3. runtime/walker.ts — dynamic DOM and HTML edge cases.
  4. v-for — whether a runtime-only framework can hold large lists against compile-time systems.
  5. Bundle budgets: which entrypoints internal tools actually need.

Conclusion

Voodoo.js does not prove that React, Vue, or Svelte are obsolete. It proves something else:

modern frontend can be architected in a completely different way.

The most accurate formula today:

Not a revolution that has already won. An experiment showing that a mandatory compiler is not a law of nature for every class of application.

The next industry stage may sound less like “which framework compiles better” and more like “does this class of interface need a compiler at all.” Voodoo.js is one of the most interesting experimental answers to that question.

A practical step: on one internal task, compare three artifacts — a Vite+React scaffold, one HTML file on Voodoo.js core, and an htmx page — by time to first screen and by the size of what lands in git. Numbers will say more than slogans.

Lab frame: fix the experiment invariant (bytes, CSP, or DX without a build) — otherwise an architecture brief never converges into a decision.