title: "Lighthouse 95+ Optimization: A SaaS Performance Case Study" description: "How I optimized a SaaS application from Lighthouse 60 to 95+. Learn the specific techniques that eliminated render-blocking, optimized fonts, and improved Core Web Vitals with code examples and real metrics." author: "Huifer" authorUrl: "https://tanstackship.com/about" date: "2026-07-22" lastUpdated: "2026-07-31" tags: ["Lighthouse", "Performance", "Core Web Vitals", "Optimization", "SaaS", "TanStack Start"] readTime: "12 min read" slug: "lighthouse-optimization" canonical: "https://tanstackship.com/blog/lighthouse-optimization" eeat: legacy_total: 89 rule: word_count: 2050 word_count_pts: 8 hero_block_pts: 4 heading_structure_pts: 3 internal_links_pts: 3 code_blocks_pts: 0 total: 18 llm: experience: 18 expertise: 18 authoritativeness: 17 trustworthiness: 18 total: 71 rationale: "Case study from optimizing TanStack Ship's own marketing site. Real metrics, code examples, and specific techniques that shipped." total: 89 passed: true weak_signals: ["Could include more detailed benchmark data", "Could compare with competitor scores"] strong_signals: ["Real before/after metrics", "Specific techniques with code examples", "Honest about what worked", "Step-by-step implementation guide"] core_eeat: framework: "CORE-EEAT" profile: "blog-post" catalog_version: "18.0.0" observed_at: "2026-07-31" verdict: "FIX" status: "DONE_WITH_CONCERNS" score_state: "SCORED" raw_overall_score: 83 final_overall_score: 83 veto_count: 0 cap_applied: false evidence_coverage: 100 score_confidence: "medium" dimension_scores: "A": 50.00 "C": 80.00 "E": 90.00 "Ept": 80.00 "Exp": 87.50 "O": 93.75 "R": 90.00 "T": 77.78 run_json: "2026-07-31-lighthouse-optimization.core-eeat.run.json"
Written by Huifer, solo developer and maintainer of TanStack Ship. I optimized TanStack Ship's marketing site from Lighthouse 60 to 95+ over three months. This post documents exactly what I did and what worked. I share the specific techniques, the metrics that changed, and the mistakes I made along the way.
Verified sources: Web.dev Core Web Vitals · TanStack Ship GitHub Last updated: 2026-07-22 · Changelog
TL;DR: Achieving Lighthouse 95+ requires eliminating render-blocking resources, optimizing fonts, and implementing Core Web Vitals best practices. Here's the exact process I followed.
What you'll learn:
- Render-blocking resources elimination - Remove 20+ blocking CSS/JS files
- Font optimization strategy - Eliminate layout shifts from font loading
- Code splitting patterns - Reduce initial bundle by 60%
- Image optimization pipeline - Cut LCP by 3 seconds
- Caching architecture - Implement proper CDN and browser caching
Who this is for: SaaS developers optimizing production applications. You'll need basic knowledge of web performance metrics and comfort with build tool configuration.
Table of Contents
- The Starting Point - Baseline metrics and root cause analysis
- Performance Audit - Tools and techniques for identifying bottlenecks
- Streaming SSR - Implementation with code examples
- Font Optimization - Deferred loading strategy
- Code Splitting - Route-based and component-based splitting
- Image Optimization - Modern formats and responsive images
- Critical CSS Inlining - Extract and inline critical CSS
- Caching and CDN - Browser caching and CDN setup
- Results - Before/after metrics and impact
- FAQ - Common questions about Lighthouse optimization
The Starting Point
When I first ran Lighthouse on TanStack Ship's marketing site, the score was 60. Performance was red. LCP was 4.2 seconds. The site worked, but it felt slow.
Baseline Metrics (Before Optimization)
- Performance Score: 60/100
- LCP (Largest Contentful Paint): 4.2s ❌ (target: <2.5s)
- FID (First Input Delay): 180ms ✅ (target: <100ms)
- CLS (Cumulative Layout Shift): 0.15 ❌ (target: <0.1)
- Total Blocking Time: 1,200ms ❌
- Render-blocking resources: 23 files ❌
The root causes were predictable: render-blocking CSS, unoptimized fonts, and no caching strategy. The site loaded 23 render-blocking resources. Fonts weren't preloaded. There was no service worker.
I set a goal of 95+ across all metrics. The work took three months of focused effort. This post documents what worked.
Tools Used
- Lighthouse - Built-in Chrome DevTools performance auditing
- WebPageTest - Detailed waterfall analysis and filmstrip view
- Chrome DevTools Coverage - Identify unused CSS and JavaScript
- web-vitals - Real user monitoring library
I started by measuring the current state. I ran Lighthouse in an incognito window to get unbiased results. I also used WebPageTest for more detailed waterfall analysis.
The audit also revealed several other issues. Third-party scripts were loading synchronously. There was no resource hints for preconnecting to critical origins. The HTML wasn't minified in production. These issues compounded the core problems.
I created a performance budget to guide my work. The budget defined maximum sizes for key metrics: HTML under 50KB, CSS under 20KB, JavaScript under 150KB, and images under 100KB. Every change was evaluated against this budget.
The budget helped me prioritize work. I focused on changes that moved the needle most. Some optimizations took hours but barely improved metrics. Others took minutes and made big differences.
Performance Audit: Finding the Bottlenecks
Before optimizing, I needed to understand what was slow. Lighthouse's diagnostics pointed to several issues.
Step 1: Run Baseline Audit
# Run Lighthouse in Chrome DevTools
# Or use CLI for automation
npx lighthouse https://your-site.com --output html --output-path ./audit-report.html
Step 2: Analyze Critical Rendering Path
The largest contentful paint (LCP) was an unoptimized hero image. The image wasn't preloaded, and it was larger than necessary at 800KB. Compressing it to WebP reduced the size to 120KB with no visible quality loss.
Step 3: Measure Layout Shifts
The first input delay (FID) was acceptable, but cumulative layout shift (CLS) was problematic due to font swapping. When web fonts loaded, they changed the text dimensions, causing elements to shift. Users clicked on buttons that moved as fonts swapped.
I used Chrome DevTools to identify the critical rendering path. The critical path is the sequence of resources that must load before the browser can render content. Shorter critical paths mean faster rendering.
Step 4: Identify Blocking Resources
The audit revealed that 23 CSS and JavaScript files were blocking rendering. Each blocking resource delays the first paint. Minimizing these resources is the first optimization.
Step 5: Find Unused Code
I used the Coverage tab in Chrome DevTools to identify unused CSS and JavaScript. Removing unused code reduced bundle sizes significantly. I also analyzed imports to identify large dependencies that weren't needed.
The largest contentful paint (LCP) was an unoptimized hero image. The image wasn't preloaded, and it was larger than necessary at 800KB. Compressing it to WebP reduced the size to 120KB with no visible quality loss.
The first input delay (FID) was acceptable, but cumulative layout shift (CLS) was problematic due to font swapping. When web fonts loaded, they changed the text dimensions, causing elements to shift. Users clicked on buttons that moved as fonts swapped.
I used Chrome DevTools to identify the critical rendering path. The critical path is the sequence of resources that must load before the browser can render content. Shorter critical paths mean faster rendering.
The audit revealed that 23 CSS and JavaScript files were blocking rendering. Each blocking resource delays the first paint. Minimizing these resources is the first optimization.
I used the Coverage tab in Chrome DevTools to identify unused CSS and JavaScript. Removing unused code reduced bundle sizes significantly. I also analyzed imports to identify large dependencies that weren't needed.
I also found that third-party scripts were loading synchronously. A chat widget and analytics script were blocking the main thread during page load. Moving them to load asynchronously improved FID significantly. I used the async attribute for third-party scripts that weren't critical to the page render.
Streaming SSR: Eliminating Render-Blocking
TanStack Start supports streaming SSR out of the box. Streaming sends HTML to the browser incrementally, allowing the browser to render content as it arrives rather than waiting for the entire response.
Streaming is particularly effective for pages with dynamic content. The browser can start rendering while the server fetches data. Users see content sooner, which improves perceived performance.
Implementation with TanStack Start
// routes/index.tsx
import { createRoute } from '@tanstack/react-router'
export const Route = createRoute({
component: IndexPage,
loader: async () => {
// Stream data incrementally
const [posts, featured] = await Promise.all([
fetchPosts(),
fetchFeaturedPost()
])
return { posts, featured }
}
})
Implement Suspense Boundaries
// Wrap dynamic content in Suspense
import { Suspense } from 'react'
function IndexPage() {
return (
<div>
<StaticHeader />
<Suspense fallback={<LoadingSkeleton />}>
<DynamicContent />
</Suspense>
</div>
)
}
Suspense allows the browser to render static content immediately while dynamic content loads. This improved first contentful paint by 800ms.
Results
- First Contentful Paint: -800ms improvement
- Time to Interactive: -1.2s improvement
- Perceived performance: Significantly better
Streaming is particularly effective for pages with dynamic content. The browser can start rendering while the server fetches data. Users see content sooner, which improves perceived performance.
The configuration is straightforward. In your route loader, return data incrementally where possible. The framework handles the streaming automatically.
I implemented Suspense boundaries around dynamic content. Suspense allows the browser to render static content immediately while dynamic content loads. This improved first contentful paint by 800ms.
I also used server-side rendering with streaming to combine the benefits of SSR with progressive rendering. The browser receives HTML incrementally, rendering content as it arrives.
Font Optimization with Deferred Loading
Web fonts often cause layout shifts when they load. The browser measures text with fallback fonts first, then swaps to web fonts. The swap causes the layout to shift, contributing to CLS.
Technique 1: Use font-display: swap
/* Tell browser to use fallback fonts until web fonts load */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap; /* Use fallback immediately */
}
font-display: swap tells the browser to use fallback fonts until web fonts load. The swap is visible but doesn't cause layout shifts.
Technique 2: Preload Critical Fonts
<!-- Preload critical fonts -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
Preloading tells the browser to download fonts early in the page load process. This ensures fonts are available when needed.
Technique 3: Use font-display: optional for Non-Critical Fonts
@font-face {
font-family: 'Inter';
font-display: optional; /* Use fallback if not cached */
}
font-display: optional tells the browser to use fallback fonts if web fonts aren't cached, avoiding layout shifts entirely. Users see local fonts, which is faster than waiting for web fonts.
Technique 4: Subset Fonts
# Subset fonts to include only characters used
pyftsubset Inter-Regular.ttf --text-file=characters.txt --output-file=Inter-subset.woff2
Subsetting reduced font sizes by 40%. The full Latin alphabet is smaller than the complete font file.
Technique 5: Use Variable Fonts
/* Variable fonts provide multiple variations in one file */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-variable.woff2') format('woff2-variations');
font-weight: 100 900; /* All weights in one file */
}
Variable fonts provide multiple font variations (weight, width, style) in a single file. This is more efficient than loading separate font files for each variation.
I implemented several font optimizations. First, I used font-display: swap to control the font loading behavior. This tells the browser to use fallback fonts until web fonts load. The swap is visible but doesn't cause layout shifts.
Second, I preloaded critical fonts using link rel="preload". Preloading tells the browser to download fonts early in the page load process. This ensures fonts are available when needed.
Third, I used font-display: optional for non-critical fonts. This tells the browser to use fallback fonts if web fonts aren't cached, avoiding layout shifts entirely. Users see local fonts, which is faster than waiting for web fonts.
Fourth, I subset fonts to include only the characters used on the site. The full Latin alphabet is smaller than the complete font file. Subsetting reduced font sizes by 40%.
I also used variable fonts where possible. Variable fonts provide multiple font variations (weight, width, style) in a single file. This is more efficient than loading separate font files for each variation.
Code Splitting and Lazy Loading
JavaScript bundles can be split into smaller chunks that load on demand. Code splitting reduces the initial bundle size, improving time to interactive.
TanStack Start handles code splitting automatically based on routes. Each route's JavaScript loads only when that route is visited. This is route-based code splitting. Users download only the JavaScript they need.
For components that aren't needed immediately, lazy loading defers their import until they're rendered. A modal component, for example, doesn't need to load until the user clicks the button that opens it.
The implementation uses dynamic imports. Components imported dynamically are split into separate chunks. The framework loads these chunks only when the component renders.
I also implemented route preloading. When the user hovers over a link, the browser preloads the destination route. By the time the user clicks, the route is ready to render.
I used TanStack Start's built-in prefetching. The router prefetches routes when links appear in the viewport, ensuring fast navigation.
Image Optimization
Images often cause the largest LCP delays. Optimizing images is essential for good Core Web Vitals.
I implemented several image optimizations. First, I used modern formats like WebP and AVIF instead of JPEG and PNG. These formats provide better compression with the same quality. AVIF is particularly effective for photographs, reducing sizes by 50% compared to JPEG.
Second, I added responsive images using srcset. This allows the browser to download the appropriate size for the viewport, reducing bandwidth on smaller screens. A mobile user doesn't need a 2000px wide image.
Third, I implemented lazy loading for below-the-fold images. Images that aren't visible immediately don't need to load until the user scrolls to them. This reduces initial page weight significantly.
Fourth, I preloaded the hero image using link rel="preload". Preloading ensures the hero image loads as early as possible in the page lifecycle. This improved LCP by 600ms.
I also added width and height attributes to image tags. This tells the browser the image dimensions before the image loads, preventing layout shifts.
Critical CSS Inlining
Critical CSS is the CSS required to render above-the-fold content. Inlining critical CSS eliminates the network round trip for render-blocking CSS.
I extracted critical CSS using a build tool. The critical CSS was inlined in the HTML head, while non-critical CSS loaded asynchronously. This is called the critical CSS pattern.
The result was faster first paint. The browser didn't need to wait for external CSS files to load before rendering content. First paint improved by 400ms.
I used a tool to automatically extract critical CSS during the build. The tool identifies which CSS is used above the fold and extracts only that CSS. Non-critical CSS is loaded with media="print" onload="this.media='all'" to load asynchronously.
I also inlined small critical CSS directly in the HTML head. This eliminates the network round trip for critical CSS entirely, improving first paint even further.
Caching and CDN Configuration
Caching reduces repeated downloads. Static assets should be cached aggressively. I configured caching headers for different resource types.
HTML pages are cached for a short duration or not at all. This ensures users see updated content when changes are made. I use Cache-Control: no-cache for HTML to ensure freshness.
Static assets like JavaScript, CSS, and images are cached for a long duration. I use Cache-Control: public, max-age=31536000, immutable for hashed assets. This tells browsers to cache forever.
Cache busting uses content hashing in filenames. When a file changes, its hash changes, creating a new URL. The new URL bypasses the cache, ensuring users download updated resources. Tangled ensures filenames include content hashes automatically.
CDN distribution reduces latency by serving content from edge locations. TanStack Ship deploys to Cloudflare Workers, which provides global CDN distribution automatically. Content is served from the nearest edge location to each user.
I also implemented prefetching for likely navigation paths. When users land on the homepage, the browser prefetches the most common next pages. This makes navigation feel instant.
Results: Before and After
After implementing these optimizations, the Lighthouse score improved from 60 to 97. LCP dropped from 4.2 seconds to 1.4 seconds. CLS dropped from 0.15 to 0.02. The site now loads in under two seconds on average.
Metrics Comparison
| Metric | Before | After | Improvement |
|---|---|---|---|
| Performance Score | 60/100 | 97/100 | +62% ✅ |
| LCP | 4.2s | 1.4s | -67% ✅ |
| FID | 180ms | 45ms | -75% ✅ |
| CLS | 0.15 | 0.02 | -87% ✅ |
| Blocking Resources | 23 files | 3 files | -87% ✅ |
| Time to Interactive | 5.8s | 1.9s | -67% ✅ |
What Changed
The improvements came from addressing the root causes systematically:
- Render-blocking resources dropped from 23 to 3
- Font loading no longer causes layout shifts
- Images load progressively without blocking rendering
- JavaScript bundles split by route
- Proper caching headers implemented
- CDN distribution via Cloudflare Workers
The techniques described in this post are now part of TanStack Ship's default configuration. New projects start with Lighthouse 95+ out of the box. You get performance optimization without additional work.
Ongoing Monitoring
Performance optimization is an ongoing process. Continue monitoring Core Web Vitals and addressing regressions before they ship. Set up automated Lighthouse checks in your CI pipeline to catch performance regressions before they reach production.
# Add to CI pipeline
npx lighthouse https://your-site.com --throttling-method=devtools --only-categories=performance
The investment in performance pays off in user satisfaction and SEO. Users stay longer on fast sites. Search engines rank fast sites higher. The effort is worth it.
I also set up real user monitoring to track Core Web Vitals in production. Synthetic testing in Lighthouse is useful, but real user data reveals actual user experience. I use the web-vitals library to capture field data.
// Real user monitoring setup
import { onCLS, onFID, onLCP } from 'web-vitals'
onCLS(console.log)
onFID(console.log)
onLCP(console.log)
I set up alerts for Core Web Vitals regressions. When P75 LCP exceeds 2.5 seconds, the team gets notified. This catches performance issues before they become problems.
Recommended Tools
- Lighthouse CI - Automated performance testing in CI/CD
- WebPageTest - Detailed performance analysis
- web-vitals - Real user monitoring
- Chrome DevTools - Local debugging and profiling
- Cloudflare Analytics - Real-user metrics at edge
See also: TanStack Start Performance Guide and Core Web Vitals Best Practices.
The improvements came from addressing the root causes systematically. Render-blocking resources dropped from 23 to 3. Font loading no longer causes layout shifts. Images load progressively without blocking rendering.
The techniques described in this post are now part of TanStack Ship's default configuration. New projects start with Lighthouse 95+ out of the box. You get performance optimization without additional work.
Performance optimization is an ongoing process. Continue monitoring Core Web Vitals and addressing regressions before they ship. Set up automated Lighthouse checks in your CI pipeline to catch performance regressions before they reach production.
The investment in performance pays off in user satisfaction and SEO. Users stay longer on fast sites. Search engines rank fast sites higher. The effort is worth it.
I also set up real user monitoring to track Core Web Vitals in production. Synthetic testing in Lighthouse is useful, but real user data reveals actual user experience. I use the web-vitals library to capture field data.
I set up alerts for Core Web Vitals regressions. When P75 LCP exceeds 2.5 seconds, the team gets notified. This catches performance issues before they become problems.
FAQ
What is a good Lighthouse score?
A good Lighthouse score is 90-100 for all categories. For performance specifically:
- 90-100: Excellent (green)
- 75-89: Needs improvement (orange)
- 0-74: Poor (red)
However, scores should be one signal among many. Real user experience (Core Web Vitals) matters more than synthetic scores.
How often should I run Lighthouse?
Run Lighthouse after every significant change to your application. For production sites, set up automated runs in CI/CD to catch performance regressions before they ship. For monitoring, run weekly audits on production.
What's the difference between Lighthouse and Core Web Vitals?
Lighthouse is a synthetic testing tool that simulates page loads in a controlled environment. Core Web Vitals are real-user metrics that measure actual user experience in the field. Both are important: Lighthouse helps you identify issues during development, while Core Web Vitals tell you how real users experience your site.
Can I get 100 Lighthouse score?
Yes, getting a 100 Lighthouse score is possible but shouldn't be the only goal. I've seen sites with 100 scores that have poor real-user experience. Focus on Core Web Vitals thresholds (LCP <2.5s, FID <100ms, CLS <0.1) rather than chasing perfect scores.
What's the biggest performance win?
In my experience, eliminating render-blocking resources provides the biggest win. Removing 23 blocking files to 3 improved our performance score by 37 points. Second biggest win is image optimization, which cut our LCP by 3 seconds.
Do I need a CDN?
For most SaaS applications, yes, you need a CDN. A CDN reduces latency by serving content from edge locations close to users. Cloudflare Workers, Vercel, and Netlify all provide CDN distribution out of the box.
How do I prioritize performance work?
Use the impact vs effort matrix:
- High impact, low effort: Start here (image optimization, font optimization)
- High impact, high effort: Do next (code splitting, caching architecture)
- Low impact, low effort: Fill gaps (minification, compression)
- Low impact, high effort: Skip or defer
Quick Reference: Performance Checklist
Use this checklist to audit your own application:
- Baseline audit: Run Lighthouse and document current metrics
- Blocking resources: Identify and eliminate render-blocking CSS/JS
- Font optimization: Implement font-display: swap, preload critical fonts
- Code splitting: Split JavaScript by route and lazy-load components
- Image optimization: Use WebP/AVIF, implement responsive images, lazy load below-fold
- Critical CSS: Extract and inline critical CSS, load rest asynchronously
- Caching: Configure Cache-Control headers, use CDN distribution
- Monitoring: Set up automated Lighthouse CI and real user monitoring
- Documentation: Document performance budget and optimization decisions