Why Svelte, the Compiler
Runtime frameworks vs a compiler
Section titled “Runtime frameworks vs a compiler”Most UI frameworks are runtime libraries. You ship the framework to the browser, and at runtime it maintains a virtual DOM, re-runs your components when state changes, diffs the new virtual tree against the old one, and applies the differences. That machinery — the reconciler, the scheduler — is code the user downloads and the CPU runs on every update.
Svelte takes the work out of the browser and into the build step. The Svelte compiler reads your components ahead of time, figures out precisely which DOM nodes depend on which state, and emits plain JavaScript that updates exactly those nodes. There is no virtual DOM to ship, no diffing at runtime.
flowchart TB
subgraph runtime["Runtime framework"]
ship["ship framework + components"] --> vdom["runtime keeps a virtual DOM"]
vdom --> diff["diff on every state change"]
end
subgraph svelte["Svelte (compiler)"]
build["compile components at build time"] --> emit["emit precise update code"]
emit --> tiny["ship tiny JS, no diffing"]
end What “no virtual DOM” buys you
Section titled “What “no virtual DOM” buys you”- Smaller bundles. You don’t ship a reconciler. A Svelte component compiles to roughly the imperative code you’d hand-write to update those exact nodes, so there’s little framework overhead riding along.
- Less runtime work. When
countchanges, there’s no tree to re-create and diff — the generated code updates the one text node that showscount. Updates are surgical by construction. - Less indirection. The gap between “what I wrote” and “what runs” is small: the compiler’s output is close to what you’d write by hand.
This is why Svelte is sometimes called “the disappearing framework” — much of it compiles away, leaving mostly your logic plus a thin helper layer.
What the compiler actually produces
Section titled “What the compiler actually produces”Given a component with let count = $state(0) and <p>{count}</p>, the compiler statically sees that the text node depends on count. It generates code that (1) creates the <p> once, and (2) registers that when count changes, this specific text node’s content is updated — nothing else. Multiply that across a whole app and you get updates that touch only what changed, without any runtime bookkeeping to figure out what changed.
<script> let count = $state(0);</script>
<!-- The compiler knows this text node — and only this one — depends on count. --><button onclick={() => count++}>clicked {count} times</button>You write declarative components; the compiler writes the imperative DOM code. That division of labor is the entire value proposition.
The compiler’s output, for real
Section titled “The compiler’s output, for real”svelte/compiler is the same isomorphic package the Svelte tooling uses internally, and it runs anywhere JavaScript does — including in your own Node scripts and tests. compile(source, options) returns the generated JavaScript directly, so instead of taking “the compiler emits precise update code” on faith, you can read exactly what it emits for a specific component:
import { test, expect } from 'vitest';import { compile } from 'svelte/compiler';
const source = `<script> let count = $state(0); let doubled = $derived(count * 2); $effect(() => { console.log(count); });</script><button onclick={() => count++}>{count} / {doubled}</button>`;
test('compile() output names the exact runtime primitives each rune becomes', () => { const { js } = compile(source, { filename: 'Richer.svelte' }); // $state becomes a call to $.state(); reading it goes through $.get(). expect(js.code).toMatch(/let count = \$\.state\(0\);/); // $derived becomes $.derived(() => ...), lazily re-reading its own dependency. expect(js.code).toMatch(/let doubled = \$\.derived\(\(\) => \$\.get\(count\) \* 2\);/); // $effect becomes $.user_effect(...) — "user_effect" to distinguish it // internally from the effects the compiler generates for template updates. expect(js.code).toMatch(/\$\.user_effect\(/); // The template's own reactive text update is a template_effect calling set_text — // this is the "surgical update" from the mental model, made concrete. expect(js.code).toMatch(/\$\.template_effect\(\(\) => \$\.set_text\(text, /);});✓ compile() output names the exact runtime primitives each rune becomesReading that output line by line: let count = $.state(0) is the rune becoming a reactive cell; $.get(count) is every read of count becoming an explicit call (so the compiler always knows exactly where a dependency is read); $.derived(() => ...) wraps the computation instead of running it eagerly; $.template_effect(() => $.set_text(text, ...)) is the one line that updates the DOM whenever count or doubled changes — there is no other update path, and no diffing anywhere in this output. This is “the compiler traces the dependency graph and writes surgical update code,” not a metaphor for it.
Runes mode vs legacy mode: how the compiler decides
Section titled “Runes mode vs legacy mode: how the compiler decides”Every .svelte file compiles in one of two modes, and the compiler picks automatically:
import { test, expect } from 'vitest';import { compile } from 'svelte/compiler';
test('a component with no runes compiles in legacy mode by default', () => { const source = `<script>\n let count = 0;\n</script>\n<button on:click={() => count += 1}>{count}</button>`; const { metadata } = compile(source, { filename: 'Legacy.svelte' }); expect(metadata.runes).toBe(false);});
test('any rune usage switches the whole file to runes mode', () => { const source = `<script>\n let count = $state(0);\n</script>\n<button onclick={() => count++}>{count}</button>`; const { metadata } = compile(source, { filename: 'Runes.svelte' }); expect(metadata.runes).toBe(true);});
test('the `runes` compiler option forces runes mode even with no runes present', () => { const source = `<script>\n let count = 0;\n</script>\n<p>{count}</p>`; const { metadata } = compile(source, { filename: 'Forced.svelte', runes: true }); expect(metadata.runes).toBe(true);});✓ a component with no runes compiles in legacy mode by default✓ any rune usage switches the whole file to runes mode✓ the `runes` compiler option forces runes mode even with no runes presentcompile()’s returned metadata.runes boolean is the same detection the tooling (svelte-check, the editor extension) surfaces to you as diagnostics — it’s a per-file decision, made from that file’s own contents (or forced project-wide via compilerOptions.runes in svelte.config.js / the Vite plugin). That’s why a large codebase can migrate one component at a time: an untouched Svelte 4 file keeps compiling in legacy mode right next to a converted one in runes mode, and the compiler never has to guess across file boundaries.
flowchart TB file[".svelte file"] --> scan["compiler scans for rune usage"] scan -->|"a rune is used, or runes: true"| runes["runes mode: $state/$derived/$effect only"] scan -->|"no runes found"| legacy["legacy mode: export let, top-level reactive let, $:"] runes --> out["compiled output (this lesson's tests, above)"] legacy --> out
Mental Model
Section titled “Mental Model”Two ideas do all the work in this lesson: the compiler is a static analyzer first (it decides delegation, dependency tracking, and which mode a file is in, all before your code ever runs), and its output is generated source, not a black box — compile() proves that directly, and the Svelte Internals module walks the full pipeline (signals, scheduling, fine-grained DOM updates, hydration) for anyone who wants to go further than “what does this one file produce.”
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Reading compile() output directly | The clearest possible answer to “what does this actually do” — no framework documentation can be more precise than the generated source itself | The exact helper names and shape are internal and can change between versions (proven by the Callout above) — don’t build application logic around them |
| Automatic per-file mode detection | Incremental migration, file by file, with zero configuration | A file that mixes both modes’ reactivity primitives fails to compile at all (the legacy-recognition table in the events & attributes lesson) rather than silently picking one |
Common Misconceptions
Section titled “Common Misconceptions”- “The compiler’s output is a documented, stable API you can import from.” It isn’t — proven above via the Callout:
svelte/internal/client’s exports and shape are private and have changed across versions. Read them to learn, don’t depend on them in shipped code. - “Runes mode is a project-wide setting you flip once.” It’s normally detected per file, automatically, from that file’s own contents — proven above with
metadata.runeson three different sources.compilerOptions.runescan force it project-wide, but that’s the exception, not the default path. - “
$derived(() => count * 2)computes eagerly, like a plain function call would.” The compiled output wraps it, and reading it always goes through$.get(doubled)— it’s a lazily-read reactive cell, not code that reruns unconditionally on every change (the$derivedlesson covers push/pull evaluation in depth).
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing code that touches the compiler or its output directly, check for:
- Does application code import anything from
svelte/internal/client(orsvelte/internal/*generally) instead of the publicsvelte/svelte/store/svelte/attachments/svelte/eventsentry points? That’s importing an implementation detail, proven unstable above. - Does a test assert on the exact compiled output string of a component, rather than its observable behavior (what renders, what a user can do)? Compiled output is allowed to change shape between patch versions.
- Is a file’s mode assumed rather than checked — e.g. code review comments claiming “this is a runes-mode component” without any rune actually present, when the file in front of you is still legacy mode by the same detection rule proven above?
🐛 Spot the bug
// app.ts — a "performance debugging" helper someone addedimport { proxy, get } from 'svelte/internal/client';
export function inspectState(value: unknown) {// reach into Svelte's internal state representation directlyreturn get(proxy(value));}Show the answer
This imports directly from svelte/internal/client — the exact module this lesson’s Callout names as unstable, private, and subject to change between versions with no deprecation warning, because it was never meant to be consumed outside of compiler-generated code. It may work today and break on the next Svelte patch release with no warning in a changelog aimed at application authors, because from the maintainers’ point of view nothing public changed.
The fix is to not reach for internals at all — everything an application legitimately needs (reading state, inspecting values, debugging) is available through the public API: $inspect(value) inside a component during development, or $state.snapshot(value) to get a plain, debuggable object out of reactive state, both documented and stable across versions.