Svelte — islands
@atolljs/svelte-island — a worker-hosted tree mounted as an ordinary element in a Svelte shell. The worker's render loop produces serialized DOM ops; the main thread just replays them.
Live demo — Svelte in the worker
Four islands, Svelte rendered inside the workers: a counter, a second counter instance, a notes composer, and a 1,000,000-record incident benchmark — each island gets its own client — Svelte schedules render work through ambient document resolution, so mounts on one shared worker can misroute ops. The shell is just a thin use:island host from @atolljs/svelte-island that replays their ops.
The incident benchmark is the real-world case for offloading a heavy component. One million incidents exist as lazily generated logical rows — the component is a fixed-height virtualized scroller that renders only the ~22 visible rows plus overscan, no matter where you scroll. Each scroll event crosses the island protocol as a structured payload (the driver stamps scrollTop), Sveltere-computes the window worker-side, and the commit rides back as an op batch — the stats line reports the worker-side re-render time, and a rendered emit updates the shell's status line. The main thread never touches more than a handful of DOM nodes; a million rows of state, generation, and diffing all stay off it.
If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.
The Svelte API
| Export | Signature | What it does |
|---|---|---|
island | <div use:island={{ client|worker, app, props, onEvent, slots, onReady }} /> | Action form — mounts on attach, forwards props on update(), destroys on teardown. Callbacks are read through a latest-options ref, so fresh closures never remount. |
createIslandState | createIslandState(): { status, error, handle } | Headless rune state — spread onto the action options to read status/error/handle reactively. |
defineSveltePolyWorker | defineSveltePolyWorker({ apps }) | Registry worker — components must be rune-compiled Svelte 5 (vite-plugin-svelte); they mount through real mount()/unmount() against the proxy document. |
defineSvelteMonoWorker | defineSvelteMonoWorker(Component) | Instance worker — the 1:1 topology, mounted namelessly. |
svelteIslandApp / emit | svelteIslandApp(Component) / emit(name, payload) | The adapter for shared registries, and the island → shell event channel. |
Mounting — the shell is a thin Svelte host
Every island below mounts against a pre-connected connectIslandWorker({ worker, doorbell }) client. Worker emits land in onEvent → shell state → the status line; props flow the other way — the action's update() hook fires when the options object re-evaluates — props changes push updateProps, deduped by serialized identity.
<script lang="ts">
/**
* The Svelte islands demo — SVELTE RUNNING INSIDE WORKERS.
*
* Each island below mounts an app from `worker/svelte.worker.ts`'s
* registry: real `.svelte` components (runes + {#each}) rendered by
* Svelte's `mount()` against the proxy DOM. The worker bundle carries
* Svelte; this page is just a thin host that replays its ops.
*
* What changes versus shell.tsx:
* - The shell itself is also Svelte — each island is a
* `<div use:island={{…}} />`; the action attaches, mounts the worker
* tree, and forwards `props` on every update.
* - `client` (not `worker:`) is passed so the doorbell choice travels
* with the connection; the two counters deliberately share ONE
* client so their islands live in the same OS worker.
* - Mediation is runes: worker emits write `$state` feeding the
* status line.
*
* The seven-island React demo lives in index.html / react-shell.html —
* this page is the small framework-island edition from the docs.
*/
import { island } from '@atolljs/svelte-island';
import { connectIslandWorker } from '@atolljs/islands';
import type { IslandHandle, Mode } from '@atolljs/islands';
/** The registry worker — one script serving all Svelte apps. */
const svelteWorker = (): Worker =>
new Worker(new URL('../worker/svelte.worker.ts', import.meta.url), { type: 'module' });
// SharedArrayBuffer only exists in cross-origin-isolated contexts — on
// hosts without COOP/COEP the doorbell can't bind, so every island runs
// its 50ms poll transport instead of push.
const isolated =
typeof SharedArrayBuffer !== 'undefined' &&
(typeof window.crossOriginIsolated === 'undefined' || window.crossOriginIsolated);
const initialMode: Mode = isolated ? 'push' : 'poll';
/**
* Four clients = four OS workers running the same script. Unlike the
* Vue/Solid/Angular demos, Svelte mounts get a client EACH: Svelte
* schedules render work through ambient `document` resolution, so two
* mounts sharing one worker can route ops to the wrong instance.
*/
const counterClient = connectIslandWorker({ worker: svelteWorker, doorbell: isolated });
const counter2Client = connectIslandWorker({ worker: svelteWorker, doorbell: isolated });
const notesClient = connectIslandWorker({ worker: svelteWorker, doorbell: isolated });
// The 1M-incidents benchmark gets its own worker — the whole point is
// the heavy component never contends with (or blocks) anything else.
const incidentsClient = connectIslandWorker({ worker: svelteWorker, doorbell: isolated });
/* ── Mediation state — worker emits → $state → status line ── */
let status = $state('mounting islands…');
let mode = $state<Mode>(initialMode);
let pids = $state<Record<string, string>>({});
/** Bump counter — re-render trigger for the aggregate stats read. */
let statsTick = $state(0);
/** Every mounted island's handle — setMode + the aggregate stats read them. */
const handles = new Map<string, IslandHandle>();
const ready = (key: string) => (handle: IslandHandle): void => {
handles.set(key, handle);
handle.setMode(mode); // applies the user's pick to late-mounting islands
pids = { ...pids, [key]: `worker ${handle.pid}` };
statsTick++;
};
const bump = (): void => {
statsTick++;
};
const setMode = (next: Mode): void => {
mode = next;
for (const handle of handles.values()) handle.setMode(next);
bump();
};
const stats = $derived.by(() => {
void statsTick; // tracked — recompute after every op batch
const flushes = [...handles.values()].reduce((a, i) => a + i.flushCalls, 0);
const ops = [...handles.values()].reduce((a, i) => a + i.opsApplied, 0);
return `sync: ${mode} · flush calls: ${flushes} · ops applied: ${ops}`;
});
const counterOpts = (label: string, key: string, client: typeof counterClient) => ({
client,
app: 'counter',
props: { label },
onReady: ready(key),
onActivity: bump,
onEvent: (name: string, payload: unknown) => {
const p = payload as { count?: number; label?: string };
if (name === 'incremented')
status = `${p.label} counter → ${p.count} (Svelte state stayed in the worker)`;
},
});
</script>
<h1>Svelte islands — Svelte in the worker</h1>
<p style="font: 12px monospace; color: #9aa4b2; margin-top: -8px">
registry worker, one client per island — the worker bundle carries Svelte, the shell is a thin
<code>use:island</code> host.
<a href="./index.html" style="color: #7fb6ff">framework-free shell →</a>
</p>
<div id="transport-bar">
<span>transport:</span>
<label
id="transport-toggle"
title={isolated ? 'push via SharedArrayBuffer doorbell — off falls back to 50ms polling' : 'needs cross-origin isolation (no SharedArrayBuffer)'}
>
<input
id="push-toggle"
type="checkbox"
checked={mode === 'push'}
disabled={!isolated}
onclick={() => setMode(mode === 'push' ? 'poll' : 'push')}
/>
push (SAB doorbell)
</label>
<span id="transport-stats">{stats}</span>
</div>
<div id="status-line">{status}</div>
<section class="island">
<div class="island-head"><span>app: counter (Svelte mount())</span><span class="badge">{pids.counter ?? 'worker …'}</span></div>
<div class="island-root" use:island={counterOpts('alpha', 'counter', counterClient)}></div>
</section>
<section class="island">
<div class="island-head"><span>app: counter — second instance (own worker, same registry)</span><span class="badge">{pids.counter2 ?? 'worker …'}</span></div>
<div class="island-root" use:island={counterOpts('beta', 'counter2', counter2Client)}></div>
</section>
<section class="island">
<div class="island-head"><span>app: notes (registry app — own worker, same script)</span><span class="badge">{pids.notes ?? 'worker …'}</span></div>
<div
class="island-root"
use:island={{
client: notesClient,
app: 'notes',
props: { title: 'svelte island' },
onReady: ready('notes'),
onActivity: bump,
onEvent: (name, payload) => {
const p = payload as { text?: string; total?: number };
if (name === 'noteAdded') status = `notes island emitted noteAdded → "${p.text}" (${p.total} total)`;
},
}}
></div>
</section>
<section class="island">
<div class="island-head"><span>app: incidents — 1,000,000 rows, virtualized (own worker)</span><span class="badge">{pids.incidents ?? 'worker …'}</span></div>
<div
class="island-root"
use:island={{
client: incidentsClient,
app: 'incidents',
onReady: ready('incidents'),
onActivity: bump,
onEvent: (name, payload) => {
const p = payload as { start?: number; end?: number; ms?: number };
if (name === 'rendered') status = `incidents island rendered rows ${p.start?.toLocaleString()}–${p.end?.toLocaleString()} in ${p.ms?.toFixed(1)}ms (of 1,000,000)`;
},
}}
></div>
</section>The worker side — Svelte running in the worker
The worker bundle carries Svelte itself — the framework's own renderer drives the proxy DOM and state stays worker-side.defineSveltePolyWorker serves the whole apps map from one script; islands mount by registry name. See the Islands page for modes, slots, and lifecycle.
/**
* Svelte island worker — a registry worker serving Svelte 5 components
* through `svelteIslandApp` (`mount()` bound to the instance's proxy
* document). Its bundle carries Svelte but no React — the point of the
* demo: islands are framework-agnostic over one op protocol.
*
* Components are real `.svelte` files — vite-plugin-svelte compiles them
* for the worker bundle just like a client bundle; runes and `{#if}` /
* `{#each}` blocks work against the proxy DOM (comment anchors included).
*/
import { defineSveltePolyWorker } from '@atolljs/svelte-island/worker';
import Counter from './svelte/Counter.svelte';
import Incidents from './svelte/Incidents.svelte';
import Notes from './svelte/Notes.svelte';
export const svelteWorker = defineSveltePolyWorker({
apps: { counter: Counter, notes: Notes, incidents: Incidents },
});<script lang="ts">
/**
* 'counter' — the docs' canonical Svelte island: a `label` wire prop
* ($props), a local `$state` count mutated by a delegated onclick, and an
* 'incremented' emit for the shell's status line.
*/
import { emit } from '@atolljs/svelte-island/worker';
let { label = 'count' }: { label?: string } = $props();
let count = $state(0);
const bump = (): void => {
count += 1;
emit('incremented', { count, label });
};
</script>
<div class="svelte-counter">
<span class="vanilla-heading">{label}: {count}</span>
<button class="mw-btn" onclick={bump}>increment</button>
</div><script lang="ts">
/**
* 'incidents' — the heavy-component benchmark: 1,000,000 incident
* records in the worker, rendered through a virtualized scroller. The
* main thread only ever sees ~20 rows of ops no matter how deep the
* user scrolls.
*
* Rows are lazily generated (deterministic pseudo-data — nothing is
* materialized until it's visible). Each scroll event is a dispatch
* round-trip; the worker re-renders the window and reports the
* re-render time back via 'rendered'.
*/
import { emit, runInInstance } from '@atolljs/svelte-island/worker';
import { getActiveInstance } from '@atolljs/islands/worker';
let { count = 1_000_000 }: { count?: number } = $props();
const ROW_H = 24;
const VIEW = 320;
const OV = 4;
const VISIBLE = Math.ceil(VIEW / ROW_H) + OV * 2;
const REGIONS = ['us-east', 'us-west', 'eu-central', 'ap-south', 'sa-east'];
const SEVS = ['P1', 'P2', 'P3', 'P4'];
const incident = (i: number) => ({
id: i,
site: `site-${(i * 7919) % 1409}`,
region: REGIONS[i % REGIONS.length],
sev: (i * 31) % 100,
dur: `${((i * 104729) % 977) % 60}m`,
});
const sevClass = (s: number): string =>
`inc-sev sev-p${s > 75 ? 1 : s > 40 ? 2 : s > 15 ? 3 : 4}`;
const sevLabel = (s: number): string => SEVS[s > 75 ? 0 : s > 40 ? 1 : s > 15 ? 2 : 3];
let start = $state(0);
let lastMs = $state(0);
let t0 = performance.now();
const first = $derived(Math.min(start, Math.max(0, count - VISIBLE)));
const rows = $derived(
Array.from({ length: Math.min(VISIBLE, count - first) }, (_, k) => incident(first + k)),
);
const end = $derived(first + rows.length - 1);
const onScroll = (e: Event): void => {
t0 = performance.now();
// The driver stamps the scroller's scrollTop onto the wire payload —
// the proxy element's geometry getters are stubs.
const st = (e as Event & { scrollTop?: number }).scrollTop ?? 0;
start = Math.max(0, Math.floor(st / ROW_H) - OV);
};
// After every commit that touched the window, report the re-render cost.
// $effect runs async after the event dispatch released the instance
// scope — capture this mount's key during setup (inside the mount task)
// and re-enter it for the emit.
const scope = getActiveInstance();
$effect(() => {
void rows; // track the window
lastMs = performance.now() - t0;
runInInstance(scope, () => emit('rendered', { start: first, end, ms: lastMs }));
});
</script>
<div class="incidents">
<div class="inc-stats">
{count.toLocaleString()} incidents · rows {first.toLocaleString()}–{end.toLocaleString()} ·
worker re-render {lastMs.toFixed(1)}ms
</div>
<div class="inc-viewport" onscroll={onScroll}>
<div class="inc-spacer" style:height="{count * ROW_H}px">
{#each rows as r (r.id)}
<div class="inc-row" style:top="{r.id * ROW_H}px">
<span class="inc-id">#{r.id}</span>
<span class="inc-site">{r.site}</span>
<span class="inc-region">{r.region}</span>
<span class={sevClass(r.sev)}>{sevLabel(r.sev)} · {r.sev}</span>
<span class="inc-dur">{r.dur}</span>
</div>
{/each}
</div>
</div>
</div>Notes
- Mediation is unidirectional — an island's emit lands in
onEvent, the shell writes state, and it flows back in as props. No hand-wiredupdatePropscalls. - Fixed-dimension libs (recharts) get width/height as props — there's no ResizeObserver channel into the worker.
emitneeds instance scope — it routes through the active task, so it's free inside event handlers. Async commit hooks (VueonUpdated, Svelte$effect, AngularafterEveryRender) run after the task releases it: capturegetActiveInstance()during setup and re-enter withrunInInstance(scope, () => emit(…))— or, in Angular, just declare anoutput()field and the adapter's output bridging re-enters for you (that's how the incidents benchmark reports its re-render time).worker/client/appare mount-stable — swap them through a {#key} block.- Props arriving while the mount is in flight coalesce (last write wins) and flush once the handle lands — nothing is dropped.
- Event dispatches end with a flushSync() worker-side, so state updates land in the dispatch's own op batch.
- The shell page is an ordinary Svelte 5 component — the only island-specific piece is the action options object.