DEV Community

SEN LLC
SEN LLC

Posted on

Porting the Landing to Svelte 5 + Runes — 61% Smaller Gzip and Why the Compiler Approach Wins Here

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

Screenshot

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>
Enter fullscreen mode Exit fullscreen mode

Three things to notice:

  1. $state returns plain mutable values. You can data = d directly — no setter to call, no useState tuple to destructure. The compiler tracks the mutation.
  2. $derived has no dependency array. Whatever reactive values you touch inside are auto-tracked. filter, lang, and data all flow in automatically.
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Versus React's 49 kB, that's 30+ kB gone. Where?

  1. The runtime is tiny. Svelte's runtime is in the 3-4 kB range. There's no virtual DOM, no reconciler, no event synthesizer.
  2. The compiler does the heavy lifting. .svelte files 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.
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

$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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Next: SolidJS (024) at 8.33 kB — another 56% reduction and the smallest result in the series so far.

Top comments (0)