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.tsxfrom entry 021 works unmodified — Vite aliasesreact→preact/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
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
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'),
},
},
})
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')!)
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
reactat 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
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:
- No-VDOM architectures win on bundle size when the app is simple enough that their constraints don't matter.
- Meta-frameworks add a fixed tax (~10-20 kB) that's painful on small apps and amortized on larger ones.
- "Zero runtime" is an extreme but legitimate architecture (Astro) for content-driven UIs.
- Preact is the best "stay on React" escape hatch — swap the runtime alias, ship 5-6× less JS.
- 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.
- 📦 Repo: https://github.com/sen-ltd/portfolio-app-preact
- 🌐 Live: https://sen.ltd/portfolio/portfolio-app-preact/
- 🏢 Company: https://sen.ltd/
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)