Web Development

Replacing React with Preact: Shaving 160 KB Off Every Page

Web Development

Replacing React with Preact: Shaving 160 KB Off Every Page

Swapping React for Preact via @astrojs/preact with compat mode. Zero code changes, zero regressions, 30% less JavaScript.

TL;DR: Replaced React with Preact via @astrojs/preact + @preact/compat. Zero component rewrites. Zero regressions. The page bundle dropped from 565 KB to 396 KB — a 30% reduction. Every page with interactive islands now loads 160 KB less JavaScript.

The Setup

This site uses Astro 6 with React islands for a handful of interactive components:

  • EngagementDock — like/view tracking on blog posts
  • CommandPalette — Cmd+K search overlay
  • CopyEmail — clipboard copy button on the contact page
  • ToastProvider — ephemeral notification toasts
  • FilterControls — category filtering on blog and project listings
  • Giscus — GitHub-backed comments on blog posts
  • ReadingProgress — scroll progress bar on blog posts

Every one of these is a thin interactive wrapper. None of them use React's reconciler, virtual DOM diffing, or complex tree reconciliation. They were paying the React tax — ~130 KB for React + ReactDOM — for features they don't use.

The Migration

The swap took three changes:

1. Swap packages:

- @astrojs/react, react, react-dom, @nanostores/react
+ @astrojs/preact, preact, @preact/compat, @nanostores/preact

2. Swap integrations:

- react({ include: '**/*.{tsx,jsx}' })
+ preact({ compat: true })

3. Swap one import path:

- import { useStore } from '@nanostores/react'
+ import { useStore } from '@nanostores/preact'

That was it. @preact/compat aliases react to preact/compat at the Vite level, so every existing import { useState } from 'react' resolves to Preact's hooks implementation. Zero component files were touched beyond that one import path change.

The Results

Metric Before (React) After (Preact) Change
Total JS payload 565 KB 396 KB -169 KB (-30%)
Framework runtime ~160 KB ~19 KB -141 KB (-88%)
Per-page JS (with islands) 321 KB 201 KB -120 KB (-37%)
E2E tests passing 25/25 25/25 No regressions

The breakdown of the new page bundle:

Chunk Size What
page.*.js 201 KB Sentry SDK + shared vendor runtime
preact.module.*.js 10.4 KB Preact core
signals.module.*.js 7.7 KB Preact signals (nanostores integration)
EngagementDock.*.js 7.2 KB Like/view tracking
CommandPalette.*.js 6.4 KB Search overlay
jsxRuntime.module.*.js 5.7 KB Preact JSX transform
hooks.module.*.js 2.7 KB Preact hooks (useState, useEffect, etc.)
client.js 2.6 KB Preact client runtime
Other components ~7 KB ToastProvider, CopyEmail, Giscus, Filters

The biggest win is React + ReactDOM going from ~160 KB to Preact's ~19 KB. The second win is the Astro client runtime dropping from 182 KB (React-specific) to 2.6 KB (Preact-specific).

Tradeoffs

Preact is not a drop-in replacement for every React app. Some edge cases:

  • Suspense — Not in Preact. We weren't using it.
  • createPortal — Available in compat. We weren't using it.
  • Third-party React libraries — Most work through compat, but exotic ones may not.
  • TypeScript types — Preact has its own types. Compat provides React-compatible types.

For this site — a content blog with thin interactive islands — Preact is a strict upgrade. The same JSX, the same hooks, the same mental model. Just less of it.