Optimizing React Performance: 99/100 Lighthouse Score Achievement
An in-depth case study of how I optimized my portfolio website to achieve a 99/100 Lighthouse performance score using modern React optimization techniques.
Where it started
The first Lighthouse run was humbling: 35 on mobile and 68 on desktop. First contentful paint took 2.3 seconds, the largest paint took 3.1, and the main thread sat blocked for 280 milliseconds. The site looked great and felt slow, which is the worst combination.
The stack itself was modern: React 18 on Vite with Tailwind, Framer Motion, and Three.js. The problem was that all of it shipped to every visitor up front: a 1.2 MB main bundle, heavy animations firing on load, and large unoptimized images.
Measure before touching anything
Before changing a single line I wired rollup-plugin-visualizer into the Vite build to see exactly where the weight lived. The picture was immediately clear: Three.js and Framer Motion dominated the vendor bundle, and nothing was split by route. That one chart decided the entire plan.
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [react(), visualizer({ gzipSize: true })],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
animations: ['framer-motion'],
three: ['three', '@react-three/fiber'],
},
},
},
},
});Ship less JavaScript
Every route became a React.lazy import behind a Suspense boundary, so visitors only download the page they are on. The 3D scene got the same treatment at component level, loading only when it actually renders. Together these cuts took the initial bundle from 1.2 MB down to about 400 KB.
// src/App.tsx
const HomePage = lazy(() => import('./pages/HomePage'));
const ProjectsPage = lazy(() => import('./pages/ProjectsPage'));
const BlogPage = lazy(() => import('./pages/BlogPage'));
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/blog" element={<BlogPage />} />
</Routes>
</Suspense>Fix the images
A small Sharp script now generates four WebP sizes plus a JPEG fallback for every image, and a picture-based component serves the right one for each screen. Below-the-fold images lazy load with async decoding while the hero stays eager. The total image payload dropped from 1.8 MB to 540 KB.
Calm the main thread
The last phase was animation discipline: animate only transform and opacity so the browser composites on the GPU, trigger animations with useInView so nothing runs off screen, and clean up will-change once an animation finishes. Total blocking time fell from 280 to 45 milliseconds.
The results
After three phases the same site scored 93 on mobile and 99 on desktop. First contentful paint dropped to 1.1 seconds, the largest paint to 1.8, and layout shift became nearly invisible at 0.02. Nothing about the design was sacrificed to get there.
Measure first, always
Every phase started with data from the bundle visualizer or Lighthouse. Guessing wastes days; a bundle chart tells you in seconds where the real weight is.
Images are the cheapest win
One script and one component removed 1.3 MB of payload without touching a single feature. If you only do one thing, automate your image pipeline.
Users only need the page they are on
Route-level code splitting is nearly free in modern React, and it removed two thirds of the initial bundle. Default to lazy and make eager loading the exception.
Performance is a feature you maintain
Scores drift as new features land, so the audits now run as part of the workflow. Keeping a 99 is much easier than reaching it a second time.
Want to see it in practice?
Everything in this article comes from projects I have actually built and shipped. Browse the work, or reach out if you want to talk through the details.