Porting the Landing to Svelte 5 + Runes — 61% Smaller Gzip and Why the Compiler Approach Wins Here
Svelte 5 with Runes is my third port in the framework comparison series. The results so far: React 49.00 kB, Vue 28.76 kB (−41%), Svelte 18.92 kB (−61%). The size drop isn't a coincidence — it's what happens when "the framework" is mostly a compiler and not mostly runtime.
Entry #3 in the framework comparison series. The same landing page, same features, same CSS, same data loading — only the component layer changes. Svelte 5 ships a new Runes API that replaces the older $: label syntax, and it's the first Svelte version I've felt fully comfortable using with TypeScript.
🔗 Live demo: https://sen.ltd/portfolio/portfolio-app-svelte/
📦 GitHub: https://github.com/sen-ltd/portfolio-app-svelte
Feature parity with the React and Vue ports. Shared code (filter.ts, data.ts, types.ts, style.css, tests/filter.test.ts) is byte-identical.
Runes in action
Svelte 5 makes reactivity explicit via function-call "runes" — $state, $derived, $effect, $props. Here's the main component's core:
<script lang="ts">
import type { PortfolioData, Entry, Lang } from './types'
import { loadPortfolioData } from './data'
import { filterAndSort, type FilterState, type SortKey } from './filter'
import { MESSAGES, detectDefaultLang } from './i18n'
let status = $state<'loading' | 'error' | 'ready'>('loading')
let errorMsg = $state('')
let data = $state<PortfolioData | null>(null)
let lang = $state<Lang>(detectDefaultLang())
let filter = $state<FilterState>({
query: '', category: 'all', stack: 'all', stage: 'all', sort: 'number',
})
$effect(() => {
loadPortfolioData()
.then((d) => { data = d; status = 'ready' })
.catch((e) => { errorMsg = String(e); status = 'error' })
})
let visible = $derived(
data ? filterAndSort(data.entries, filter, lang) : []
)
</script>
Three things to notice:
-
$statereturns plain mutable values. You candata = ddirectly — no setter to call, nouseStatetuple to destructure. The compiler tracks the mutation. -
$derivedhas no dependency array. Whatever reactive values you touch inside are auto-tracked.filter,lang, anddataall flow in automatically. -
Runes are TypeScript-friendly.
$state<FilterState>(...)accepts a type parameter cleanly. The old$:label syntax was awkward to type; Runes fix that.
Why Runes matter vs. $:
Older Svelte used the $: label for reactive derivations:
<!-- Svelte 4 -->
<script lang="ts">
let count = 0
$: doubled = count * 2
</script>
This worked within the top-level script block but fell apart outside — you couldn't export reactive values, couldn't move them to modules, and the TypeScript story was awkward. Runes fix all of that by being real JavaScript:
// Svelte 5
let count = $state(0)
let doubled = $derived(count * 2)
That's just a function call. It plays nicely with modules, type inference, and refactoring tools. Runes are the change that makes Svelte 5 feel like a first-class TypeScript citizen.
bind:value for two-way input binding
Forms use bind::
<input type="text" bind:value={filter.query} placeholder={m.searchPlaceholder} />
<select bind:value={filter.category}>
<option value="all">{m.allLabel}</option>
{#each data.categories as c}
<option value={c.id}>{c.name[lang]}</option>
{/each}
</select>
Equivalent to Vue's v-model. The React version requires a value + onChange pair per field; this one line does both ends.
Why the bundle is so small
dist/assets/index-<hash>.js 57.78 kB │ gzip: 18.92 kB
Versus React's 49 kB, that's 30+ kB gone. Where?
- The runtime is tiny. Svelte's runtime is in the 3-4 kB range. There's no virtual DOM, no reconciler, no event synthesizer.
-
The compiler does the heavy lifting.
.sveltefiles compile to direct DOM manipulation code. There's no interpretation layer in the browser — your component is literally a function that creates nodes and assigns text. -
Dead-code elimination in templates.
{#if condition}branches that aren't reachable or whose state never triggers them get flagged and removed.
React and Vue both ship an imperative kernel that could handle any app. Svelte compiles the kernel away and emits code that only does what your app needs. The bundle difference is that architecture choice made visible.
$effect for URL sync
Query-string sync is an effect:
<script lang="ts">
$effect(() => {
const q = new URLSearchParams()
if (filter.query) q.set('q', filter.query)
if (filter.category !== 'all') q.set('category', filter.category)
if (filter.stack !== 'all') q.set('stack', filter.stack)
if (filter.stage !== 'all') q.set('stage', filter.stage)
if (filter.sort !== 'number') q.set('sort', filter.sort)
q.set('lang', lang)
const qs = q.toString()
window.history.replaceState(null, '', qs ? `${location.pathname}?${qs}` : location.pathname)
})
</script>
Reads filter.* and lang → automatically re-runs when any of them change. No dep array, no stale closure trap, no useEffect(..., [filter, lang]) to accidentally truncate.
EntryCard uses $props()
<!-- EntryCard.svelte -->
<script lang="ts">
import type { Entry, Lang } from './types'
import { MESSAGES } from './i18n'
let {
entry,
lang,
stackMap,
stageMap,
categoryMap,
}: {
entry: Entry
lang: Lang
stackMap: Map<string, { id: string; name: string; color: string }>
stageMap: Map<string, { id: string; icon: string; name: Record<Lang, string> }>
categoryMap: Map<string, { id: string; name: Record<Lang, string> }>
} = $props()
let stage = $derived(stageMap.get(entry.stage))
let category = $derived(categoryMap.get(entry.category))
</script>
<article class="card">
<div class="card-head">
<span class="entry-number">#{String(entry.number).padStart(3, '0')}</span>
{#if stage}
<span class="stage-badge">{stage.icon} {stage.name[lang]}</span>
{/if}
{#if entry.source === 'closed'}
<span class="source-badge">🔒 Closed source</span>
{/if}
</div>
<!-- ... -->
</article>
$props() with a destructuring pattern and a TypeScript annotation gives you fully-typed props. The compiler turns the conditional {#if stage} blocks into branch-specific DOM updates, which is materially cheaper than virtual-DOM diffing of a stale tree.
Byte-identical shared files
$ diff repos/portfolio-app-react/src/filter.ts repos/portfolio-app-svelte/src/filter.ts
# no output
$ diff repos/portfolio-app-react/src/types.ts repos/portfolio-app-svelte/src/types.ts
# no output
$ diff repos/portfolio-app-react/src/style.css repos/portfolio-app-svelte/src/style.css
# no output
This is the constraint that makes the bundle-size comparison honest — the bytes that changed are exclusively the framework's rendering layer.
Tests
$ npm test
✓ tests/filter.test.ts (14 tests) 8ms
Same 14 tests as every other port. No Svelte-specific component tests. Logic stays in pure functions; framework is an implementation detail.
Series
This is entry #23 in my 100+ public portfolio series, and #3 in the framework comparison series.
- 📦 Repo: https://github.com/sen-ltd/portfolio-app-svelte
- 🌐 Live: https://sen.ltd/portfolio/portfolio-app-svelte/
- 🏢 Company: https://sen.ltd/
Next: SolidJS (024) at 8.33 kB — another 56% reduction and the smallest result in the series so far.