DEV Community

SEN LLC
SEN LLC

Posted on

Preact Port: Same React Source Code, Swap the Runtime, Get 8.75 kB Gzip ( 82%) — Series Finale

Preact Port: Same React Source Code, Swap the Runtime, Get 8.75 kB Gzip (−82%) — Series Finale

The last port in the framework comparison series, and the one where I changed the fewest files. The React App.tsx from entry 021 works unmodified — Vite aliases reactpreact/compat, and the bundle drops from 49.00 kB to 8.75 kB. For most React apps that don't lean on concurrent mode, this swap is genuinely a free win.

Entry #10 in the framework comparison series. Running scoreboard so far: React 49 kB, Vue 28.76, Svelte 18.92, Solid 8.33, Nuxt 52.01, SvelteKit 32.50, Qwik first-paint 28.60, Astro 3.17, Lit 9.70. And now Preact at 8.75 kB — second-smallest in the series, on par with Solid, and achieved with zero component code changes from the React version.

🔗 Live demo: https://sen.ltd/portfolio/portfolio-app-preact/
📦 GitHub: https://github.com/sen-ltd/portfolio-app-preact

Screenshot

Copy + two config lines

The entire port:

$ cp ../portfolio-app-react/src/App.tsx src/App.tsx
$ cp ../portfolio-app-react/src/main.tsx src/main.tsx
# then edit main.tsx's root render and vite.config.ts
Enter fullscreen mode Exit fullscreen mode

vite.config.ts:

import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
import { resolve } from 'node:path'

export default defineConfig({
  plugins: [preact()],
  resolve: {
    alias: {
      react: resolve(__dirname, 'node_modules/preact/compat'),
      'react-dom': resolve(__dirname, 'node_modules/preact/compat'),
      'react/jsx-runtime': resolve(__dirname, 'node_modules/preact/jsx-runtime'),
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Now every import { useState, useEffect, useMemo } from 'react' in the codebase resolves to Preact's compat shim. The React App.tsx component file runs untouched. This is "drop-in replacement" in its most literal form.

What does change

Two things in main.tsx:

// React
import { createRoot } from 'react-dom/client'
createRoot(document.getElementById('root')!).render(<App />)

// Preact
import { render } from 'preact'
render(<App />, document.getElementById('root')!)
Enter fullscreen mode Exit fullscreen mode

Root rendering uses Preact's native API. Could in principle go through preact/compat's createRoot instead, but calling the native render directly is cleaner for the entrypoint.

And that's literally it. Component code unchanged, hooks unchanged, JSX unchanged, state management unchanged. Shared files (filter.ts, types.ts, data.ts, style.css, tests/filter.test.ts) were already byte-identical, and the series guarantee holds for the Preact port as well.

Why the bundle is so much smaller

Preact's design goal is "React API surface with a minimal implementation." The math:

Feature React Preact
VDOM Yes (Fiber) Yes (simpler)
Hooks useState, etc. useState, etc. (compat)
Runtime size ~40 kB gzip ~3-4 kB gzip
Scheduler Concurrent mode, Fiber Synchronous only
Strict Mode Yes Yes
Suspense Full Limited

Concurrent mode + Fiber scheduler + transitions account for a big chunk of React's runtime weight. Preact deliberately ships none of that, and instead implements the 95% of the API that most apps actually use in ~10% of the bytes.

For an app like this landing page — no transitions, no Suspense, no useDeferredValue — React's extra machinery is dead code. Preact removes it entirely and the app behaves identically.

When to use Preact

Preact fits when:

  • ✅ Small to mid-size SPAs
  • ✅ No reliance on Concurrent Mode, useTransition, RSC
  • ✅ Existing React codebase that needs to slim down without a rewrite
  • ✅ Core Web Vitals / Lighthouse scores matter

Preact doesn't fit when:

  • ❌ Large React app with significant concurrent-mode usage
  • ❌ React Server Components
  • ❌ Third-party libraries that import react at a level deeper than hooks (edge cases)

This landing page sits squarely in the ✅ column — a filter + grid SPA with no advanced concurrency needs. The Preact swap is basically free.

The compat surface

Preact 10 covers most of React 18:

  • useState, useEffect, useMemo, useCallback, useRef, useContext
  • ✅ Forward refs
  • ✅ Fragments
  • createPortal
  • ⚠️ useTransition, useDeferredValue (API exists, simplified implementation)
  • ❌ Server Components

The landing only uses the ✅ section, so I never hit a compatibility wall. The Preact documentation has a complete compat status page for anyone porting a larger app.

Byte-identical files go further here

In every other port the constraint was "shared files (filter.ts, etc.) are byte-identical with React." For Preact, it goes further: even the component file (App.tsx) is byte-identical. Nothing changed except the runtime alias.

$ diff repos/portfolio-app-react/src/App.tsx repos/portfolio-app-preact/src/App.tsx
# no output

$ diff repos/portfolio-app-react/src/filter.ts repos/portfolio-app-preact/src/filter.ts
# no output
Enter fullscreen mode Exit fullscreen mode

Which means the only thing contributing to the bundle delta is the framework runtime itself. React → Preact is purely a runtime swap. The code that produces the 49 kB React bundle and the 8.75 kB Preact bundle is the same code. The difference is 100% framework overhead.

Tests

14 Vitest cases on filter.ts, same as every port.

Final scoreboard

Port gzip vs React Note
021 React 49.00 kB baseline
022 Vue 28.76 kB −41% proxy reactivity
023 Svelte 18.92 kB −61% compile-heavy
024 Solid 8.33 kB −83% fine-grained, no VDOM
025 Nuxt 52.01 kB +7% meta-framework tax
026 SvelteKit 32.50 kB −33% meta on compile-heavy
027 Qwik 28.60 kB (first-paint) −42% resumability
028 Astro 3.17 kB −94% zero runtime
029 Lit 9.70 kB −80% Web Components
030 Preact 8.75 kB −82% React API drop-in

Top three: Astro (3.17), Solid (8.33), Preact (8.75).
Worst: Nuxt (52.01), the only port heavier than React.

Takeaways from the series

Ten ports later, five durable lessons:

  1. No-VDOM architectures win on bundle size when the app is simple enough that their constraints don't matter.
  2. Meta-frameworks add a fixed tax (~10-20 kB) that's painful on small apps and amortized on larger ones.
  3. "Zero runtime" is an extreme but legitimate architecture (Astro) for content-driven UIs.
  4. Preact is the best "stay on React" escape hatch — swap the runtime alias, ship 5-6× less JS.
  5. Web Components via Lit are now a real option, not just a promise.

Which framework is "right" depends entirely on the shape of the app. This series was about making that judgment quantitative rather than vibes-based — the numbers are real, the comparisons are clean, and for at least this one type of app the ranking is consistent.

Series

This is entry #30 in my 100+ public portfolio series, and the final entry in the framework comparison series.

Thanks for reading the series. Entries 021 through 030 cover the full comparison from React baseline through every modern JS framework worth naming. Reading them in order shows how each design choice maps to bundle cost — which is a useful way to hold the tradeoffs in mind the next time you pick a framework for a real project.

Top comments (0)