Lazy-loaded component slots for Svelte 5. Wraps () => import('./Foo.svelte') in a reactive cell so you can pass the not-yet-loaded component across function, class, and component boundaries, then mount it with a normal {#await} block or the bundled <Lazy> wrapper.
npm install @coroama/svelte-lazy
# or
bun add @coroama/svelte-lazy
Peer dependency: svelte ^5.0.0. Built on top of @coroama/svelte-box, pulled in as a transitive; you do not install it directly.
Live demo: https://isaiahcoroama.github.io/svelte-lazy/. Source: src/routes/+page.svelte.
lazy() vs <Lazy>Svelte 5 already supports dynamic components. Assign the constructor to a variable and mount with <Component {...props} />. Dynamic import() already produces code-split chunks. So why a library?
Because the glue between the two is repetitive and easy to get wrong:
<!-- illustrative pseudocode, annotate types if you copy this -->
<script>
let Comp = $state(null);
let loading = $state(false);
let error = $state(null);
async function load() {
if (Comp || loading) return;
loading = true;
try {
const mod = await import('./HeavyPanel.svelte');
Comp = mod.default;
} catch (e) {
error = e;
} finally {
loading = false;
}
}
</script>
Every call site re-implements:
loading flag for the spinner.error slot for failures.svelte-lazy collapses all of that into one reactive handle (LazyComponent) plus one optional wrapper (<Lazy>):
<script>
import { lazy, Lazy } from '@coroama/svelte-lazy';
const heavy = lazy(() => import('./HeavyPanel.svelte'));
</script>
<Lazy lazy={heavy}>
{#snippet children(HeavyPanel)}
<HeavyPanel data={rows} />
{/snippet}
</Lazy>
The lazy(...) factory returns a FastBox-backed cell that:
svelte-box.prefetch(), ensure(), reset(), plus the underlying .value cell.<script lang="ts">
import { lazy, Lazy } from '@coroama/svelte-lazy';
const profilePanel = lazy(() => import('./ProfilePanel.svelte'));
</script>
<Lazy lazy={profilePanel}>
{#snippet children(ProfilePanel)}
<ProfilePanel />
{/snippet}
{#snippet pending()}
<p>Loading…</p>
{/snippet}
</Lazy>
<Lazy> fires the loader on mount and swaps the pending snippet for the resolved component once the chunk arrives. To kick the load earlier (hover, focus, route preload), call profilePanel.prefetch() from the relevant handler. See Prefetch on hover.
lazy() vs <Lazy>The library ships one runtime primitive and one optional wrapper. Pick based on whether you need to control the template structure.
| Feature | lazy() (inline {#await}) |
<Lazy> wrapper |
|---|---|---|
| Dedupe concurrent loads | yes | yes |
Reactive .value cell crosses boundaries |
yes | yes |
Manual prefetch() / ensure() / reset() |
yes | yes |
bind: on the lazy-loaded component |
yes | yes (via children snippet) |
| Custom pending / error UI | yes | yes (named snippets) |
| Automatic prefetch on mount | no, call yourself | yes |
Auto-retry after external reset() |
no, call prefetch() again |
yes |
| One-liner call site | no, four-line template | yes |
Reach for <Lazy> when you want a one-liner with pending and error slots. Use the cell directly when you need to control the surrounding template, for example composing multiple lazy cells in one branch.
{#await} patternThe cell is a FastBox<Promise<{ default: Component }> | null>. Once the loader fires, the promise lives on .value and {#await} does the rest:
<script lang="ts">
import { lazy } from '@coroama/svelte-lazy';
let source = $state('# Hello');
let dirty = $state(false);
const editor = lazy(() => import('./MarkdownEditor.svelte'));
</script>
<button onclick={() => editor.prefetch()}>edit</button>
{#if editor.value}
{#await editor.value then { default: MarkdownEditor }}
<MarkdownEditor bind:value={source} bind:dirty />
{/await}
{/if}
The outer {#if editor.value} guards the not-yet-fired case. After prefetch(), {#await} handles pending, resolved, and rejected branches.
<Lazy> wrapperThe same example through <Lazy>:
<script lang="ts">
import { lazy, Lazy } from '@coroama/svelte-lazy';
let source = $state('# Hello');
let dirty = $state(false);
const editor = lazy(() => import('./MarkdownEditor.svelte'));
</script>
<Lazy lazy={editor}>
{#snippet children(MarkdownEditor)}
<MarkdownEditor bind:value={source} bind:dirty />
{/snippet}
{#snippet pending()}<p>Loading editor...</p>{/snippet}
{#snippet error(err)}<p>Failed: {(err as Error).message}</p>{/snippet}
</Lazy>
<Lazy> calls prefetch() on mount, so the loader fires as soon as the wrapper appears in the tree. The children snippet receives the resolved component constructor, so bind:, props, slots, and events all work normally.
The error snippet is typed Snippet<[unknown]>. A dynamic import() rejection is not statically guaranteed to be Error, so callers narrow with (err as Error).message or err instanceof Error ? err.message : String(err). If the same <Lazy> shows up in more than one place, lift the narrowing into a tiny helper:
function narrowError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
and call {narrowError(err)} inside {#snippet error(err)}.
prefetch() is the cheap warmup. Calling it kicks the loader and returns the same shared promise on subsequent calls. Wire it to whatever signal predicts the next interaction:
<button
onpointerenter={() => settings.prefetch()}
onfocus={() => settings.prefetch()}
onclick={() => (showSettings = true)}
>
settings
</button>
{#if showSettings}
<Lazy lazy={settings}>
{#snippet children(Settings)}<Settings />{/snippet}
</Lazy>
{/if}
By the time the user clicks, the chunk is already in cache. The wrapper has nothing to fetch.
If the loader rejects (offline, deploy mid-load, malformed chunk), the rejected promise is cached. reset() clears the cell so the next prefetch() / ensure() re-fires the loader. reset() returns the same LazyComponent, so the retry call site is one chain:
<Lazy lazy={panel}>
{#snippet children(Panel)}<Panel />{/snippet}
{#snippet error(err)}
<p>Failed to load: {(err as Error).message}</p>
<button onclick={() => panel.reset().prefetch()}>retry</button>
{/snippet}
</Lazy>
Inside <Lazy>, calling panel.reset() alone is enough. The wrapper's $effect watches panel.value and auto-fires prefetch() when the cell goes back to null. The explicit .prefetch() in the chain makes the retry obvious at the call site.
During reset, the wrapper briefly sees lazy.value === null between the reset and the new prefetch assignment. Both the {#if lazy.value} else branch and the {#await} pending branch render the pending snippet, so the user sees a continuous loading state with no flash to empty. If your pending snippet has internal state (a spinner with an animation phase, an aria-live region), it remounts on each reset.
reset() does not cancel an in-flight load (dynamic import() has no abort signal). The stale promise is detached from value. The original request still completes in the background.
LazyComponent across boundariesThe whole point of building on svelte-box is that the cell is a real reactive handle, not a snapshot. Pass it to a class, a route store, a parent slot, or a sibling component:
// stores/panels.ts
import { lazy, type LazyComponent } from '@coroama/svelte-lazy';
export class PanelStore {
settings = lazy(() => import('../panels/Settings.svelte'));
profile = lazy(() => import('../panels/Profile.svelte'));
/** Warm up everything the user is likely to open next. */
warmAll() {
this.settings.prefetch();
this.profile.prefetch();
}
}
export const panels = new PanelStore();
<!-- App.svelte -->
<script>
import { panels } from './stores/panels';
import { Lazy } from '@coroama/svelte-lazy';
</script>
<button onpointerenter={() => panels.warmAll()}>open menu</button>
<Lazy lazy={panels.settings}>
{#snippet children(Settings)}<Settings />{/snippet}
</Lazy>
Same handle, multiple call sites, one network request.
lazy(loader)Factory. Returns a fresh LazyComponent<T> wrapping loader. T infers from the loader's default export.
declare function lazy<T>(loader: LazyLoaderFn<T>): LazyComponent<T>;
LazyLoaderFn<T> and ComponentCell<T> are defined under Types below.
class LazyComponent<T>A FastBox<Promise<ComponentCell<T>> | null> with three methods on top.
| Member | Description |
|---|---|
new LazyComponent(loader) |
Direct construction. Prefer lazy(loader). |
.value |
Inherited from FastBox. The cached promise, or null if the loader hasn't fired. Reactive. |
.prefetch() |
Fire the loader if needed, return the import promise. Idempotent: subsequent calls reuse the same promise. |
.ensure() |
Awaits the loader and the resulting tick(). Use before measuring the DOM or focusing inside the lazy tree. |
.reset() |
Clear the cached promise so the next prefetch() / ensure() re-fires the loader. Returns this for chaining. Used for retry-after-failure. |
Inherited from FastBox / BaseBox: get(), set(v), del(), snapshot(), eager(), toJSON(), and all 14 type guards (isNull, etc.). See the svelte-box README for details.
<Lazy>Wrapper component. Calls prefetch() on mount and again any time lazy.value is reset to null, so an external panel.reset() makes the wrapper re-fire the loader. Renders pending / children / error snippets through a {#await} block.
The prop shape is exported as LazyProps<T> for callers who want to write their own wrappers on top:
import type { LazyProps } from '@coroama/svelte-lazy';
// LazyProps is defined as:
// type LazyProps<T extends AnyComponent = AnyComponent> = {
// lazy: LazyComponent<T>;
// children: Snippet<[T]>;
// pending?: Snippet;
// error?: Snippet<[unknown]>;
// };
children is required and receives the resolved component constructor. Use a normal <Component> tag inside the snippet to pass props, bind:, slots, and events.pending renders during the load. Defaults to nothing.error renders if the loader rejects. Defaults to nothing.type AnyComponent = Component<Record<string, any>, Record<string, any>>;
type ComponentCell<T> = { default: T };
type LazyLoaderFn<T> = () => Promise<ComponentCell<T>>;
lazy() per chunklazy(() => import('./Foo.svelte')) is the unit of code-splitting. Declare it once at module scope (or on a long-lived store) and share the handle. Declaring it inside a component's <script> is fine, but a new instance on every mount loses the dedupe benefit between mounts.
bind: works through the children snippetThe <Lazy> children snippet hands you the component constructor, not a pre-bound element. Bind whatever you want:
<script>
let dirty = $state(false);
let values = $state({});
</script>
<Lazy lazy={form}>
{#snippet children(Form)}
<Form bind:dirty bind:values />
{/snippet}
</Lazy>
This is why <Lazy> takes a snippet instead of forwarding props directly: forwarding can't carry bind: through.
await ensure() inside a $derived$derived is synchronous. Use $effect for the await, and always handle rejection because ensure() propagates whatever the loader threw:
<script>
$effect(() => {
if (showPanel.value) {
panel
.ensure()
.then(() => focusInput())
.catch((err) => console.error('lazy load failed', err));
}
});
</script>
reset() does not cancel an in-flight loadDynamic import() has no abort signal. reset() detaches the promise from .value so the next prefetch() re-fires, but the original network request still resolves in the background. The bundler caches the module, so the second prefetch() after a successful first load is free.
loaded is implicitThere's no lazy.loaded boolean. The {#await ... :then} branch is the source of truth for "loaded right now". If you need a sync flag outside the template (e.g. for analytics), set one yourself in the :then branch.
Inference works through Vite's import types:
const panel = lazy(() => import('./Settings.svelte'));
// panel: LazyComponent<typeof import('./Settings.svelte').default>
To annotate a parameter that accepts any lazy component, use the bare class:
import type { LazyComponent } from '@coroama/svelte-lazy';
function warm(panel: LazyComponent) {
panel.prefetch();
}
The default type parameter is AnyComponent, so LazyComponent without an argument is the right type for "any code-split component".
The library is not a runtime speedup. The performance benefit is the chunk of code you no longer ship in the initial bundle, and that benefit comes from dynamic import(), not from the library. The library is the ergonomics layer that makes deferring those chunks cheap enough to actually do.
The honest comparison is lazy loading vs no lazy loading, not "this library vs a hand-rolled loader."
Both versions of "lazy loading" (hand-rolled and via the library) ship the same chunks. The bench numbers below measure the library's per-call overhead inside the lazy-loading path, not whether your app feels faster.
What the library gives you over a hand-rolled equivalent is correctness and ergonomics:
prefetch() calls on the same panel dedupe. Three hover events on one item (pointerenter, focus, click) fire one network request. A naive hand-rolled cache that does not self-check fires three.FastBox. Passing it through plain functions, class fields, and component props keeps reactivity intact without re-wrapping.If none of these concerns apply to your codebase, hand-roll. The runtime cost of hand-rolling is lower by a small constant; the maintenance cost is higher.
The hot path is prefetch(). Each call does:
this.value for the falsy check.After the first call the body is just a read and a return. The if (!this.value) branch is the only meaningful work, and that branch is taken once per LazyComponent instance for the lifetime of the page (or until a reset()).
ensure() adds one await this.prefetch() plus one await tick(). The tick is the dominant cost when the loader is already cached, because awaiting it queues a microtask and a render flush even when nothing changed.
reset() is a single reactive write of null and a return of this. No measurable cost.
The wrapper component runs one $effect that reads lazy.value and conditionally calls prefetch(). The effect re-fires when either the prop reference or lazy.value changes. Per render, this is one reactive read and at most one method call.
Memory per LazyComponent is one FastBox instance plus one private loader field. The FastBox itself is a small object with one $state cell. There is no proxy layer (this uses FastBox, not Box) so the per-instance overhead is the same as a class with a single $state field, which is the cheapest reactive container available.
benchmarking/lazy.svelte.bench.ts runs a three-way comparison per scenario where it makes sense. The baseline is what a developer would write without the library (usually a plain object holding the cache plus an if(!cache) check). The other columns are lazy(loader) and direct new LazyComponent(loader).
The scenarios are app-shaped, not micro-shaped.
ensure() with tick. User clicks, the panel ensures itself before code that touches the DOM. Includes the await tick() step.reset() + prefetch(). Deploy mid-session leaves a panel rejected. Retry button clears and refires.warmAll(). A class with ten lazy fields plus a method that prefetches them all. Common for "preload this section" buttons.warm(panel). A panel is passed into a function from another component. One call per interaction..value read. One read per render, not a tight loop. This is the realistic cost of a {#if lazy.value} guard.Numbers below come from a single Chromium run on Linux x86_64 through @vitest/browser-playwright. Treat them as ballpark figures. They drift between machines, browsers, and Svelte versions.
Reading the column: Nx slower means the library completed 1/N of the baseline's operations in the same wall-clock time.
| Scenario | Baseline | LazyComponent |
|---|---|---|
App boot, 20 panels (lazy() factory) |
20 hand-rolled cache objects | 5.17x slower |
App boot, 20 panels (new LazyComponent) |
20 hand-rolled cache objects | 5.56x slower |
Menu hover, 8 cold prefetch() |
8 manual if(!cache) cache = load() |
3.71x slower |
Menu re-hover, 8 warm prefetch() (no-op) |
8 manual if(!cache) no-op checks |
4.11x slower |
First open, ensure() with tick |
await load(); await Promise.resolve |
1.50x slower |
Retry, reset() + prefetch() round trip |
cache = null; if(!cache) load() |
2.38x slower |
Store pattern, 10 fields, warmAll() |
class with 10 hand-rolled caches | 3.16x slower |
Cross-boundary warm(panel) call |
function mutates a cache object | 2.21x slower |
Per-render .value read on a warm cell |
plain object field read | 2.19x slower |
The cluster around 2x to 5x is the cost of the FastBox reactive cell against a plain { value: null } object. Every read goes through a $state getter, every write through a $state setter, and the class adds the usual function call overhead on top. The first-open row sits at 1.50x because ensure() is dominated by await tick(), which is the same on both sides, so the per-call gap is diluted.
There is no server-side bench in CI. Dynamic import() dominates the cost on the server; the library's per-call overhead does not. To get a Node-side number, copy the bench file and run it under environment: 'node' locally.
bun run bench # human-readable
bun run bench:json # machine-readable, writes bench-results.json
The bench file is benchmarking/lazy.svelte.bench.ts. Edit it to add a scenario that matches your app's call pattern.
The bench reports throughput in operations per second. Every single-call scenario in the table runs at hundreds of thousands of operations per second on the slower side. A 5x slowdown on something that takes around 1.4 microseconds lands at roughly 7 microseconds. None of that is visible to a user.
A 60Hz frame is 16 ms. The library only does meaningful work during three events:
prefetch() on a given LazyComponent. Cost is dominated by the network request, not by the library.reset() + prefetch() retry. Two reactive writes and a new network request.Compare that to the bundle-size win. Deferring a 200KB component out of the initial chunk shaves roughly 100 to 500 ms off Time-To-Interactive on a mid-tier phone over 4G. The library's per-call overhead is invisible against it.
If you have thousands of LazyComponent instances on one page and read their .value in tight loops, hoist the cell value into a local before the loop. Same advice as any FastBox use.
Dynamic import() runs on whatever runtime you're in. On the server, the loader fetches the module from the local bundle. On the client, the bundler emits a code-split chunk. The library does nothing different between the two. The same lazy(...) call works in both contexts.
Two practical notes:
LazyComponent in load() return data. It's a class instance with a FastBox inside, not serializable. Construct lazy handles on the client or in a shared module, not in route data.<Lazy> inside a route's component tree, not inside the layout <script> top level. Top-level prefetch() on the server adds a server-side import without a client benefit.The library ships as ESM with "sideEffects": ["**/*.css"] so all exports are tree-shakeable. The whole runtime is one class plus one factory plus one wrapper component, on top of @coroama/svelte-box's FastBox.
Tree-shaking is independent across the two entry points. <Lazy> only references LazyComponent as a type, so importing the wrapper alone does not pull in the runtime class.
^5.0.0. The library uses the runes API ($state, $props, snippets) through @coroama/svelte-box.strict mode. Loader inference unifies with Vite's () => import('./X.svelte') automatically.import() as a separate chunk. Verified with Vite (Rollup under the hood). Other bundlers that implement the same standards-compliant code-splitting model (Webpack 5+, Rspack, esbuild via plugin) should work but are not part of CI.A LazyComponent prints as a class instance with a FastBox inside, which is rarely what you want in the console. Use snapshot() for a plain non-reactive view:
console.log(panel.snapshot()); // null, or the cached Promise
console.log(JSON.stringify(panel)); // also fine, via the inherited toJSON hook
If you want a quick sanity check of which chunk a lazy(...) call produced, open the DevTools Network tab and watch the request fire when you call prefetch(). The bundler names dynamic chunks after the file path you imported, so import('./MarkdownEditor.svelte') becomes a MarkdownEditor-*.js request.
For unit tests, panel.value is null or a Promise, both safe to compare with expect(...).toEqual(...). To assert resolved content, await the cell:
import type { Component } from 'svelte';
import MarkdownEditor from './MarkdownEditor.svelte';
await panel.ensure();
const { default: Loaded } = await panel.value!;
expect(Loaded).toBe(MarkdownEditor);
// `Loaded` is typed as `Component<...>`, the same shape MarkdownEditor exports.
get and set are inherited from FastBox. They reach the cell, not the inner component. panel.get() returns null or the cached Promise. To replace the cached promise (rare), use panel.value = newPromise or panel.set(newPromise).import() is not retryable on its own. If the bundler's network request fails, the rejected promise sticks until you call reset(). The library does not retry automatically because there is no general right answer for backoff, and most retry policies are app-specific.reset() does not cancel an in-flight load. Dynamic import() has no abort signal. The original request still completes. Plan around this if you care about wasted bandwidth.<Lazy> requires a children snippet. A wrapper with no children renders nothing once the loader resolves. The required snippet receives the resolved component constructor, so callers always control the mount.structuredClone(panel) throws. The cell is a class instance wrapped around reactive state, not a plain object. Clone panel.value or panel.snapshot() instead.error snippet receives unknown. That matches what try/catch provides under strict TypeScript. Narrow with err instanceof Error or cast with as Error when you need .message. Svelte's inline {:catch err} binding types err more loosely (effectively any), so the demo can write err.message without a cast inside {:catch}. The recommended pattern is to apply the same narrowing in both places for portability across strict-TS targets.This is a young, single-maintainer project.
The repository ships:
@vitest/browser-playwright) across three files:lazy() factory, idempotent prefetch(), ensure() + tick semantics, reset() chaining, retry after rejection, sync-throw normalization, inherited FastBox helpers.<Lazy> wrapper's pending/error/children snippets and auto re-prefetch on external reset.bun run bench to reproduce.bind:, and the retry path with simulated failure..github/workflows/ covering CI (lint, check, test, build on Linux/macOS/Windows), post-merge benchmarks, GitHub Pages deploys of the demo route, npm publishing through Trusted Publisher OIDC with provenance and a CycloneDX SBOM, plus CodeQL static analysis.Single-maintainer project.
@coroama/svelte-box, authored by the same maintainer. Bus factor across both libraries is one person. Both packages publish through GitHub Trusted Publisher OIDC with provenance, so a compromise of the npm registry alone cannot push unsigned releases. If you have a strict policy against single-maintainer dependency chains, weigh this before adopting.@coroama/svelte-box plus the standard dynamic import() syntax. When Svelte 6 lands, the intent is to support it on the same major version if the upgrade does not force a public-surface break. Otherwise cut a new major.Parts of this project were written or refined with help from Anthropic's Claude. That includes documentation drafts, code review passes, test scaffolding, and configuration boilerplate. Every change was read, edited, and accepted by a human maintainer before landing on master. Treat AI involvement the same way you would treat any other contributor: the maintainer is accountable for the result, not the tool that produced the first draft.
MIT. See LICENSE.
Source, issues, and changelog: https://github.com/IsaiahCoroama/svelte-lazy. The changelog follows the Keep a Changelog format. Anything that changes the public surface gets an entry under that release.