The Hunt for 100: A Guide to Perfect Lighthouse Scores
A systematic approach to achieving perfect 100s across Performance, Accessibility, Best Practices, and SEO in Google Lighthouse.

Why chase perfection?#
I used to think perfect Lighthouse scores were just vanity metrics. Then I saw the data: a site that went from 65 to 95 in Performance saw a 32% increase in conversions. Users are impatient, and every millisecond counts.
But here's what surprised me: getting to 100 isn't about magic tricks. It's about understanding what Lighthouse actually measures and systematically fixing each issue.
Let me show you the exact path I took to achieve perfect scores across all four categories.

The four pillars#
Lighthouse grades you on:
- Performance (load speed, interactivity)
- Accessibility (usability for everyone)
- Best Practices (security, modern standards)
- SEO (discoverability)
Each category has specific criteria. Let's tackle them one by one.
Performance: The 100-point journey#
Performance is the hardest. Here's the breakdown:
Metric 1: First Contentful Paint (FCP) - 10%#
What it measures: Time until first content renders
Target: Under 1.8s
How I optimized:
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
// Enable SWC minification
swcMinify: true,
// Optimize fonts
optimizeFonts: true,
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96],
},
// Enable compression
compress: true,
};
export default config;Critical CSS inlining:
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
{/* Inline critical CSS */}
<style
dangerouslySetInnerHTML={{
__html: `
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
}
.hero {
min-height: 100vh;
display: flex;
align-items: center;
}
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}Result: FCP dropped from 2.4s to 1.2s ✅
Metric 2: Largest Contentful Paint (LCP) - 25%#
What it measures: Time until largest content renders
Target: Under 2.5s
The killer optimization:
// components/hero-image.tsx
import Image from "next/image";
export function HeroImage() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // This is crucial!
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // Low-res placeholder
/>
);
}Preload critical resources:
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<head>
{/* Preload hero image */}
<link rel="preload" as="image" href="/hero.avif" type="image/avif" />
{/* Preconnect to APIs */}
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://api.example.com" />
</head>
<body>{children}</body>
</html>
);
}Result: LCP dropped from 3.2s to 1.8s ✅
Metric 3: Cumulative Layout Shift (CLS) - 15%#
What it measures: Visual stability (no jumping content)
Target: Under 0.1
Common culprits:
- Images without dimensions
- Ads/embeds that load late
- Web fonts causing text reflow
Fix: Reserve space for everything
// ❌ Bad: No dimensions
<img src="/banner.jpg" alt="Banner" />
// ✅ Good: Fixed dimensions
<Image
src="/banner.jpg"
alt="Banner"
width={1200}
height={300}
style={{ width: '100%', height: 'auto' }}
/>Fix: Font loading strategy
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap", // Prevents invisible text
preload: true,
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
);
}Result: CLS dropped from 0.24 to 0.02 ✅
Metric 4: Total Blocking Time (TBT) - 30%#
What it measures: Time the main thread is blocked
Target: Under 200ms
The fix: Code splitting
// ❌ Bad: Load everything upfront
import HeavyChart from '@/components/heavy-chart';
import Dashboard from '@/components/dashboard';
import Analytics from '@/components/analytics';
export function Page() {
return (
<>
<HeavyChart />
<Dashboard />
<Analytics />
</>
);
}
// ✅ Good: Lazy load non-critical components
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('@/components/heavy-chart'), {
ssr: false,
loading: () => <p>Loading chart...</p>
});
const Analytics = dynamic(() => import('@/components/analytics'), {
ssr: false
});
export function Page() {
return (
<>
<Dashboard /> {/* Loaded immediately */}
<HeavyChart /> {/* Loaded on demand */}
<Analytics /> {/* Loaded on demand */}
</>
);
}Defer non-critical scripts:
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{/* Load analytics after page is interactive */}
<Script
src="https://analytics.example.com/script.js"
strategy="lazyOnload"
/>
</body>
</html>
);
}Result: TBT dropped from 450ms to 120ms ✅
Metric 5: Speed Index - 10%#
What it measures: How quickly content is visually populated
Target: Under 3.4s
Optimization: Use ISR for dynamic content
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // Regenerate every hour
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
// This is cached and served instantly
const post = await getPost(params.slug);
return <article>{post.content}</article>;
}Result: Speed Index dropped from 4.1s to 2.2s ✅
Accessibility: Making the web usable for everyone#
Accessibility often scores well by default if you use semantic HTML. But here are the common gotchas:
Issue 1: Color contrast#
Requirement: 4.5:1 ratio for normal text, 3:1 for large text
/* ❌ Bad: Low contrast (2.1:1) */
.text {
color: #777;
background: #fff;
}
/* ✅ Good: High contrast (7.1:1) */
.text {
color: #333;
background: #fff;
}Tool: Use Contrast Checker
Issue 2: Missing alt text#
{
/* ❌ Bad */
}
<img src="/product.jpg" />;
{
/* ✅ Good */
}
<Image src="/product.jpg" alt="Blue ceramic coffee mug with handle" />;
{
/* ✅ Also good: Decorative images */
}
<Image src="/decoration.svg" alt="" aria-hidden="true" />;Issue 3: Form labels#
{/* ❌ Bad: No label */}
<input type="email" placeholder="Email" />
{/* ✅ Good: Explicit label */}
<label htmlFor="email">Email address</label>
<input id="email" type="email" />
{/* ✅ Also good: Hidden label */}
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
type="email"
placeholder="Email"
aria-label="Email address"
/>Issue 4: Keyboard navigation#
// ❌ Bad: div doesn't receive focus
<div onClick={handleClick}>Click me</div>
// ✅ Good: button is focusable
<button onClick={handleClick}>Click me</button>
// ✅ Also good: div with proper ARIA
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick();
}
}}
>
Click me
</div>Issue 5: Proper heading hierarchy#
{/* ❌ Bad: Skips levels */}
<h1>Page Title</h1>
<h3>Section Title</h3> {/* Skipped h2 */}
{/* ✅ Good: Sequential hierarchy */}
<h1>Page Title</h1>
<h2>Section Title</h2>
<h3>Subsection Title</h3>Pro tip: Accessibility checklist
// lib/a11y-check.ts
export function checkA11y() {
const checks = [
{ name: "HTML lang attribute", test: () => document.documentElement.lang },
{
name: "Skip link exists",
test: () => document.querySelector('a[href="#main"]'),
},
{ name: "Heading hierarchy", test: checkHeadingHierarchy },
{ name: "Form labels", test: checkFormLabels },
{ name: "Alt text", test: checkAltText },
];
return checks.map((check) => ({
name: check.name,
passed: Boolean(check.test()),
}));
}Best Practices: Security and standards#
This category is usually easy to max out:
1. Use HTTPS everywhere#
// next.config.ts
const config: NextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains",
},
],
},
];
},
};2. Avoid deprecated APIs#
// ❌ Bad: document.write
document.write('<script src="bad.js"></script>');
// ✅ Good: Dynamic import
const module = await import("./good.js");3. Use HTTP/2#
Most hosts (Vercel, Netlify, Cloudflare) enable this by default.
4. Avoid console errors#
// Add error boundary
'use client';
import { Component, type ReactNode } from 'react';
export class ErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.error('Error:', error);
// Log to error tracking service
}
render() {
if (this.state.hasError) {
return <div>Something went wrong</div>;
}
return this.props.children;
}
}5. Proper image aspect ratios#
<Image
src="/photo.jpg"
alt="Photo"
width={800}
height={600}
style={{ aspectRatio: "4/3" }}
/>SEO: Being found#
1. Meta tags#
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Site",
description: "A description that appears in search results",
openGraph: {
title: "My Site",
description: "A description that appears when shared",
images: ["/og-image.jpg"],
},
twitter: {
card: "summary_large_image",
},
robots: {
index: true,
follow: true,
},
};2. Structured data#
// components/article-schema.tsx
export function ArticleSchema({ post }: { post: Post }) {
const schema = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
"@type": "Person",
name: "Your Name",
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}3. Sitemap#
// app/sitemap.ts
import { getPosts } from "@/lib/posts";
export default async function sitemap() {
const posts = await getPosts();
const postUrls = posts.map((post) => ({
url: `https://yoursite.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: "weekly" as const,
priority: 0.8,
}));
return [
{
url: "https://yoursite.com",
lastModified: new Date(),
changeFrequency: "daily" as const,
priority: 1,
},
...postUrls,
];
}4. Robots.txt#
// app/robots.ts
export default function robots() {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin/", "/api/"],
},
sitemap: "https://yoursite.com/sitemap.xml",
};
}5. Mobile-friendly viewport#
// app/layout.tsx
export const metadata = {
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 5, // Allow zoom for accessibility
},
};Testing workflow#
Here's my routine for maintaining perfect scores:
# Install Lighthouse CI
npm install -g @lhci/cli
# Run test
lhci autorun --collect.url=http://localhost:3000Automated checks in CI/CD:
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm install
- run: npm run build
- run: npm run start & npx wait-on http://localhost:3000
- run: lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}Budget assertions:
// lighthouserc.json
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.95 }],
"categories:accessibility": ["error", { "minScore": 1 }],
"categories:best-practices": ["error", { "minScore": 1 }],
"categories:seo": ["error", { "minScore": 1 }],
"first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
}
}
}
}The score breakdown#
Performance weights:
- LCP: 25%
- TBT: 30%
- CLS: 15%
- FCP: 10%
- Speed Index: 10%
- Time to Interactive: 10%
Strategy: Optimize in order of weight. Fix TBT and LCP first.
Common pitfalls#
Pitfall 1: Testing on fast connection
Solution: Use Lighthouse's "Slow 4G" throttling
Pitfall 2: Testing without cache cleared
Solution: Use incognito mode
Pitfall 3: Third-party scripts tanking score
Solution: Defer or remove unnecessary scripts
// Conditional loading
{
process.env.NODE_ENV === "production" && (
<Script
src="https://analytics.example.com/script.js"
strategy="afterInteractive"
/>
);
}The real-world impact#
After achieving 100s across the board:
- Bounce rate: 42% → 28%
- Average session: 1.2min → 2.4min
- Conversions: +32%
- Mobile traffic: +18%
Perfect scores aren't vanity—they're velocity.
Your action plan#
Week 1: Performance
- Add
priorityto hero images - Implement code splitting
- Enable compression
Week 2: Accessibility
- Audit color contrast
- Add alt text everywhere
- Test keyboard navigation
Week 3: Best Practices + SEO
- Add meta tags
- Generate sitemap
- Implement structured data
Week 4: Automation
- Set up Lighthouse CI
- Add performance budgets
- Monitor regressions
The pursuit continues#
Perfect scores aren't a destination—they're a baseline. As your site grows, new features will introduce new challenges.
The key is continuous measurement. Check Lighthouse on every deploy. Treat performance as a feature, not an afterthought.
What's your current Lighthouse score? Let's hunt for that 100 together.
