Skip to content

Compiler Output

svelte/compiler’s compile() turns a .svelte file into a plain JavaScript module that calls functions exported from svelte/internal/client (or svelte/internal/server, for SSR). There is no framework runtime “interpreting” your component at render time — the interpretation already happened, once, in the compiler. Every claim below was produced by running compile() from the exact version this course targets (node_modules/svelte resolves to 5.57.1 in this repo’s harness, the same version pinned in SveltePlayground’s esm.sh loader) — click Compile on any embed below to reproduce it yourself.

$state becomes $.state, count++ becomes $.update

Section titled “$state becomes $.state, count++ becomes $.update”
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>

Compiling that (client target, runes mode, dev: false) produces:

import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<button> </button>`);
export default function Playground($$anchor) {
let count = $.state(0);
var button = root();
var text = $.only_child(button, true);
$.template_effect(() => $.set_text(text, $.get(count)));
$.delegated('click', button, () => $.update(count));
$.append($$anchor, button);
}
$.delegate(['click']);

The compiler’s rune-to-call mapping is a literal switch over the recognized rune name — this is the real handler, trimmed, from packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js:

// packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js — CallExpression (trimmed)
export function CallExpression(node, context) {
const rune = get_rune(node, context.state.scope);
switch (rune) {
case '$state':
case '$state.raw': {
// ...
const callee = b.id('$.state', node.callee.loc);
return b.call(callee, value);
}
case '$derived':
case '$derived.by': {
let fn = context.visit(node.arguments[0]);
return b.call('$.derived', rune === '$derived' ? b.thunk(fn) : fn);
}
case '$effect':
case '$effect.pre': {
const callee = rune === '$effect' ? '$.user_effect' : '$.user_pre_effect';
// ...
}
}
}

And count++ — an UpdateExpression, not a call — goes through a separate visitor, .../visitors/UpdateExpression.js, which looks up whether the identifier being updated has a registered reactive transform (set up when the binding was analyzed as $state) and, if so, defers to that transform instead of emitting a plain ++. For a $state binding this produces $.update(count) — proven directly in the compiled output above, and in a real compile() assertion:

src/lib/compiler-state.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): $state becomes $.state, count++ becomes $.update, template read becomes $.get inside $.template_effect/$.set_text', () => {
const src = `<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>
`;
const result = compile(src, { generate: 'client', runes: true, dev: false, filename: 'Playground.svelte' });
const js = result.js.code;
expect(js).toContain('let count = $.state(0);');
expect(js).toContain('$.template_effect(() => $.set_text(text, $.get(count)));');
expect(js).toContain("$.delegated('click', button, () => $.update(count));");
expect(js).toContain("$.delegate(['click']);");
});
✓ compile(): $state becomes $.state, count++ becomes $.update, template read becomes $.get inside $.template_effect/$.set_text
Test Files 1 passed (1)
Tests 1 passed (1)

$.state(0) (defined in packages/svelte/src/internal/client/reactivity/sources.js) is the actual runtime constructor for a reactive cell — trimmed to the parts that matter here:

// packages/svelte/src/internal/client/reactivity/sources.js — source / state (trimmed)
export function source(v, stack) {
var signal = { f: 0, v, reactions: null, equals, rv: 0, wv: 0 };
return signal;
}
/*#__NO_SIDE_EFFECTS__*/
export function state(v, stack) {
const s = source(v, stack);
push_reaction_value(s);
return s;
}

A $state value is a plain object with a current value (v), a list of things that depend on it (reactions), and version counters (rv/wv) — nothing more exotic than that. Signals & Scheduling covers what reactions, rv/wv, and the f flags field actually do.

$derived becomes $.derived, and stays lazy

Section titled “$derived becomes $.derived, and stays lazy”
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<button onclick={() => count++}>{doubled}</button>

Compiles to:

let count = $.state(0);
let doubled = $.derived(() => $.get(count) * 2);
var button = root();
var text = $.only_child(button, true);
$.template_effect(() => $.set_text(text, $.get(doubled)));
$.delegated('click', button, () => $.update(count));

Two details worth noticing: the $derived(expr) rune wraps the expression in a thunk (b.thunk(fn) in the compiler source above) before calling $.derived — the expression is not evaluated at this point, only a function that can evaluate it later is created. And every read of count or doubled elsewhere in the compiled output goes through $.get(...) — reads are never bare property accesses, because $.get is also how a dependency gets registered (see Signals & Scheduling).

src/lib/compiler-derived.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): $derived becomes $.derived(() => ...), reading it elsewhere is $.get(derivedName)', () => {
const src = `<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<button onclick={() => count++}>{doubled}</button>
`;
const js = compile(src, { generate: 'client', runes: true, dev: false, filename: 'Playground.svelte' }).js.code;
expect(js).toContain('let doubled = $.derived(() => $.get(count) * 2);');
expect(js).toContain('$.template_effect(() => $.set_text(text, $.get(doubled)));');
});
✓ compile(): $derived becomes $.derived(() => ...), reading it elsewhere is $.get(derivedName)
Test Files 1 passed (1)
Tests 1 passed (1)

$effect becomes $.user_effect; $effect.pre becomes $.user_pre_effect

Section titled “$effect becomes $.user_effect; $effect.pre becomes $.user_pre_effect”
src/lib/compiler-effect.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): $effect becomes $.user_effect', () => {
const src = `<script>
let count = $state(0);
$effect(() => { console.log('count is', count); });
</script>
<button onclick={() => count++}>{count}</button>
`;
const js = compile(src, { generate: 'client', runes: true, dev: false, filename: 'Playground.svelte' }).js.code;
expect(js).toContain('$.user_effect(() => {');
});
✓ compile(): $effect becomes $.user_effect
Test Files 1 passed (1)
Tests 1 passed (1)

The full compiled body for that snippet is:

let count = $.state(0);
$.user_effect(() => {
console.log('count is', $.get(count));
});
var button = root();
var text = $.only_child(button, true);
$.template_effect(() => $.set_text(text, $.get(count)));
$.delegated('click', button, () => $.update(count));

Notice the function that builds the whole component is now wrapped in $.push($$props, true) / $.pop(...) (omitted above for brevity) the moment an $effect is present — the compiler needs a component context to know when to actually run a top-level $effect (deferred until mount; see user_effect in packages/svelte/src/internal/client/reactivity/effects.js). $effect.pre compiles to $.user_pre_effect, a RENDER_EFFECT | USER_EFFECT (runs in the render phase, before the DOM is updated) instead of a plain EFFECT | USER_EFFECT — the flag difference Signals & Scheduling uses to prove the actual run order.

Templates: cloning, child/sibling walking, and $.set_text

Section titled “Templates: cloning, child/sibling walking, and $.set_text”

The <button> </button> string passed to $.from_html is the template — parsed and cloned once per mount, never re-parsed. $.only_child(button, true) grabs the button’s one text-node child (the true means “yes, get its child as text”), and every dynamic value gets its own $.template_effect that writes into that specific node with $.set_text. Try it with two independent values:

<script>
let a = $state('a0');
let b = $state('b0');
</script>
<p>{a}</p>
<p>{b}</p>

Look at the compiled output: two separate $.template_effect calls, one per <p>, each calling $.set_text on its own text node. Fine-Grained DOM proves directly that changing a never touches b’s text node — not “produces the same visible text,” but literally never calls nodeValue’s setter on it.

onclick={...} on a plain element compiles to a delegated handler, not a direct addEventListener call. The compiler’s event visitor (packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/events.js, function build_event) chooses between the two:

// .../visitors/shared/events.js — build_event (trimmed)
export function build_event(context, event_name, handler, capture, passive, delegated) {
// ...
return b.call(
delegated ? '$.delegated' : '$.event',
b.literal(event_name),
context.state.node,
fn,
capture && b.true,
passive === undefined ? undefined : b.literal(passive),
);
}

For a delegatable event (click is one), the runtime’s delegated() function — packages/svelte/src/internal/client/dom/elements/events.js — does not attach a listener to the element at all:

// packages/svelte/src/internal/client/dom/elements/events.js — delegated (trimmed)
export function delegated(event_name, element, handler) {
(element[event_symbol] ??= {})[event_name] = handler;
}

It just stashes the handler on the element itself, under a symbol key. The actual addEventListener happens once per event type, at the root, registered by $.delegate(['click']) (emitted at module scope — see the compiled output above). When a click happens anywhere in the app, one root listener (handle_event_propagation) walks event.composedPath() looking for the nearest ancestor carrying a handler for that event type under event_symbol, and calls it. This is real, deliberate event delegation: N buttons cost one root listener, not N.

generate: 'server': no reactivity graph at all

Section titled “generate: 'server': no reactivity graph at all”
src/lib/compiler-server.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): generate: "server" produces svelte/internal/server output with a plain string template, no reactivity graph', () => {
const src = `<script>
let count = $state(0);
</script>
<button onclick={() => count++}>clicks: {count}</button>
`;
const js = compile(src, { generate: 'server', runes: true, filename: 'Playground.svelte' }).js.code;
expect(js).toContain("import * as $ from 'svelte/internal/server';");
expect(js).toContain('let count = 0;'); // NOT $.state — server output has no reactivity graph
expect(js).toContain('$$renderer.push(`<button>clicks: ${$.escape(count)}</button>`);');
});
✓ compile(): generate: "server" produces svelte/internal/server output with a plain string template, no reactivity graph
Test Files 1 passed (1)
Tests 1 passed (1)

The full server output is small enough to show in full:

import * as $ from 'svelte/internal/server';
export default function Playground($$renderer) {
let count = 0;
$$renderer.push(`<button>clicks: ${$.escape(count)}</button>`);
}

There is no $.state, no signal graph, no event delegation — count is a plain variable, rendered once into a template-literal string (Hydration & SSR covers what render() from svelte/server actually returns, including the <!--[--> hydration markers this snippet doesn’t show because it has no block-level content).

src/lib/compiler-dev.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): dev: true adds $.tag()/$.add_locations()/component-name tracking that dev: false omits', () => {
const src = `<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button>
`;
const dev = compile(src, { generate: 'client', runes: true, dev: true, filename: 'Playground.svelte' }).js.code;
const prod = compile(src, { generate: 'client', runes: true, dev: false, filename: 'Playground.svelte' }).js.code;
expect(dev).toContain("$.tag($.state(0), 'count')");
expect(dev).toContain('$.add_locations(');
expect(dev).toContain('Playground[$.FILENAME]');
expect(prod).not.toContain('$.tag(');
expect(prod).not.toContain('$.add_locations(');
});
✓ compile(): dev: true adds $.tag()/$.add_locations()/component-name tracking that dev: false omits
Test Files 1 passed (1)
Tests 1 passed (1)

dev: true wraps $.state(0) in $.tag(..., 'count') (so devtools and error messages can show the variable’s real name), records every template’s source location with $.add_locations(...) (for the “click to open in editor” browser extension feature), and stamps Playground[$.FILENAME] = 'Playground.svelte' on the component function. None of this exists in a production build — it is pure development ergonomics, added by the same compiler, controlled entirely by the dev option — exactly the checkbox the SveltePlayground embeds above toggle.

Legacy (non-runes) mode: $: compiles very differently from $derived

Section titled “Legacy (non-runes) mode: $: compiles very differently from $derived”
src/lib/compiler-legacy.test.ts
import { compile } from 'svelte/compiler';
import { expect, test } from 'vitest';
test('compile(): legacy mode ($: reactive statement, runes: false) compiles to $.legacy_pre_effect, not $.derived', () => {
const src = `<script>
export let x = 1;
$: doubled = x * 2;
</script>
<p>{doubled}</p>
`;
const js = compile(src, { generate: 'client', runes: false, filename: 'Playground.svelte' }).js.code;
expect(js).toContain('$.legacy_pre_effect(');
expect(js).toContain('$.legacy_pre_effect_reset();');
expect(js).not.toContain('$.derived(');
});
✓ compile(): legacy mode ($: reactive statement, runes: false) compiles to $.legacy_pre_effect, not $.derived
Test Files 1 passed (1)
Tests 1 passed (1)

The full legacy-mode output:

import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<p> </p>`);
export default function Playground($$anchor, $$props) {
$.push($$props, false);
const doubled = $.mutable_source();
let x = $.prop($$props, 'x', 8, 1);
$.legacy_pre_effect(() => ($.deep_read_state(x())), () => {
$.set(doubled, x() * 2);
});
$.legacy_pre_effect_reset();
var p = root();
var text = $.only_child(p, true);
$.template_effect(() => $.set_text(text, $.get(doubled)));
$.append($$anchor, p);
$.pop();
}

A $: doubled = x * 2 is not a $.derived — it’s a special kind of effect ($.legacy_pre_effect) that re-derives and $.sets a mutable_source every time its declared dependencies ($.deep_read_state(x())) change. This is a real, meaningful difference from $derived: legacy reactive statements are eagerly re-run effects with a value assigned into them, while $derived is a genuinely lazy, pull-based computation (Signals & Scheduling proves the laziness with a call-count test). They often look interchangeable in simple examples, which is exactly why AI-generated migrations from legacy to runes mode sometimes get the semantics subtly wrong.

  • Does an explanation of “why Svelte is fast” describe a virtual DOM diff? There is no virtual DOM anywhere in this compiled output — the compiler already generated the exact $.set_text/$.set_attribute call each value needs.
  • Does code assume $derived(expr) evaluates expr immediately, the way a normal function call would? The compiled output above wraps it in a thunk — it’s lazy, not eager (proven properly in Signals & Scheduling).
  • Is a migration from $: doubled = x * 2 to let doubled = $derived(x * 2) treated as a pure syntax swap? They compile to genuinely different runtime constructs (an eagerly re-run effect vs. a lazy computation) — usually equivalent, but not always, especially with side effects inside the expression.
  • Does a comment claim an onclick handler is “attached directly to the button”? For a delegatable event, it isn’t — the handler is stashed on the element and invoked by one shared root listener ($.delegate([...])), which matters if code later tries to manually removeEventListener the “same” handler from the DOM node.

🐛 Spot the bug

<script>
let count = $state(0);
</script>
<!-- calling the handler through a spread should be identical to a literal onclick -->
<button {...{ onclick: () => { count += 1; } }}>{count}</button>
Show the answer

This isn’t actually a bug in the reactivity — count += 1 still goes through the same $.update-style transform and still updates the button’s text. The real issue is the assumption in the comment: spreading onclick through {...{ onclick: ... }} (a computed/spread attribute) is compiled by a different code path than a literal onclick={...} attribute — spread attributes are applied with $.set_attributes/$.event, not the delegated $.delegated/$.delegate pair this lesson’s compiled output shows for a literal handler. It still works, but it silently opts this one handler out of event delegation (a real, non-delegated addEventListener gets attached to this specific button instead of being handled by the shared root listener), which is invisible in the rendered output and only shows up as a difference if something depends on delegation specifically (for example, code that expects exactly one root-level click listener, or relies on $.delegate’s registered-events set).

The compiler is a one-time translator, not a runtime interpreter: every rune, every {expression} in a template, and every event handler becomes a specific, plain JavaScript call against svelte/internal/client (or /server) — decided once, by walking the AST, never re-decided at render time. Reading compiled output is reading the actual program that runs; there is no second, hidden layer underneath it.

ChoiceBenefitCost
Compile-time rune resolution (vs. a runtime reactivity library)The runtime never has to figure out “is this reactive,” “which node does this touch” — it’s already decided; smaller, more predictable runtimeThe compiler must fully understand your code’s shape (destructuring, reassignment patterns); unusual patterns can hit real compiler errors instead of “just working” at runtime
Event delegation by default ($.delegated/$.delegate)One root listener instead of one per element — cheaper for large lists of interactive rowsA handler attached via a spread/computed attribute silently opts out of delegation — invisible unless you specifically check for it
dev/generate as compile options, not runtime flagsZero-cost production builds (no $.tag/$.add_locations shipped); a single source compiles to genuinely different server vs. client codeThe exact same .svelte file can produce three meaningfully different outputs (client/server/legacy) — “what does this code do” always depends on which compile target produced the JS you’re actually reading
  • “$derived(expr) runs expr right away, like a normal assignment.” Proven false above: the compiler wraps it in a thunk (() => ...), and nothing calls that thunk until something actually reads the derived value.
  • “$: x = ... and let x = $derived(...) are the same thing, just old vs. new syntax.” They compile to different runtime primitives — an eagerly re-run effect ($.legacy_pre_effect) vs. a lazy pull-based computation ($.derived) — proven by the two very different compiled bodies above.
  • “Every onclick gets its own addEventListener.” A literal onclick={...} on a normal element is delegated: the handler is stashed on the element, and one listener at the root dispatches to it — proven directly from the compiled output and the runtime source of delegated().
What does let count = $state(0); compile to (client target, runes mode)?
Given this compiled output, is count * 2 evaluated at the moment this line runs?
let doubled = $.derived(() => $.get(count) * 2);
What does the compiler emit for the click handler?
<button onclick={() => count++}>{count}</button>
What is different about generate: 'server' output compared to the client target, for let count = $state(0)?
How does a legacy $: doubled = x * 2 (compiled with runes: false) differ from let doubled = $derived(x * 2) at the runtime-construct level?