Google's Core Web Vitals — LCP (Largest Contentful Paint), CLS (Cumulative Layout Shift), and INP (Interaction to Next Paint, replacing FID in 2024) — directly impact your SaaS product's search rankings and user conversion rates. This guide covers advanced optimization techniques for each metric, measurement strategies on Cloudflare Workers, and a Build vs Buy analysis for implementing Web Vitals improvements. See these optimizations in practice at tanstackship.com.
Why Core Web Vitals Matter for SaaS
| Metric | Impact | Google Threshold | SaaS Conversion Impact |
|---|---|---|---|
| LCP | Perceived load speed | < 2.5s | 1% conversion drop per 100ms delay |
| CLS | Visual stability | < 0.1 | 0.3% conversion drop per 0.01 increase |
| INP | Responsiveness | < 200ms | 0.5% conversion drop per 100ms increase |
| TTFB | Server response | < 800ms | SEO ranking factor |
A SaaS that improves CWV from "needs improvement" to "good" typically sees 5-15% improvement in organic traffic and 2-5% improvement in conversion rate.
LCP: Largest Contentful Paint
LCP measures when the largest visible element (hero image, heading, or video) becomes visible. Target: < 2.5 seconds.
LCP Optimization for SaaS
1. Preload Critical Resources
// In your TanStack Router root route, preload LCP elements
export const Route = createRootRoute({
component: () => (
<head>
<link rel="preload" href="/hero.webp" as="image" />
<link rel="preload" href="/fonts/inter-var.woff2" as="font" crossOrigin="anonymous" />
<link rel="preconnect" href="https://api.tanstackship.com" />
</head>
),
})
2. Server-Side Render Above-the-Fold Content
TanStack Start's streaming SSR ensures the LCP element is part of the initial HTML:
export const Route = createFileRoute("/")({
loader: async ({ context }) => {
// Fetch hero content server-side — no client waterfall
return {
heroContent: await context.env.DB.prepare(
"SELECT heading, subheading, cta_text FROM hero_content WHERE active = 1"
).first(),
}
},
component: HeroSection,
})
function HeroSection() {
const { heroContent } = Route.useLoaderData()
// render heroContent — it's already in the HTML stream
}
3. Optimize Images
// Use Cloudflare Image Resizing for automatic optimization
function HeroImage() {
return (
<img
src="/cdn-cgi/image/width=1200,format=webp,quality=80/hero.jpg"
alt="SaaS Dashboard"
width={1200}
height={600}
loading="eager" // LCP images should not be lazy
fetchPriority="high"
/>
)
}
LCP Score Impact
| Optimization | Typical Improvement | Effort |
|---|---|---|
| Preload hero image | 200-400ms reduction | Low |
| Server-side render content | 300-600ms reduction | Medium |
| Image optimization (WebP/AVIF) | 200-800ms reduction | Low |
| Font preloading | 100-300ms reduction | Low |
| CDN/caching | 100-500ms reduction | Low |
| Reduce render-blocking CSS | 200-500ms reduction | Medium |
CLS: Cumulative Layout Shift
CLS measures unexpected layout shifts. Target: < 0.1.
CLS Fixes for SaaS
1. Set Explicit Dimensions for Media
// Always set width and height on images and iframes
function SaaScreenshot() {
return (
<img
src="/dashboard-preview.webp"
alt="Dashboard preview"
width={1200}
height={675}
style={{ aspectRatio: "16/9" }}
/>
)
}
2. Reserve Space for Dynamic Content
// Reserve space for content that loads asynchronously
.dynamic-content-placeholder {
width: 100%;
min-height: 200px;
background: var(--skeleton-bg);
border-radius: 8px;
}
function DynamicDashboard() {
return (
<Suspense fallback={<div className="dynamic-content-placeholder" />}>
<DashboardContent />
</Suspense>
)
}
3. Prevent Layout Shift from Web Fonts
// Use font-display: swap with adjusted fallback metrics
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2");
font-display: swap;
size-adjust: 100%; /* Adjust for consistent metrics */
}
4. Avoid Inserting Content Above Existing Content
// Bad: inserting a banner above existing content
// document.body.prepend(banner) // ← causes layout shift
// Good: inserting below the fold
function CookieBanner() {
const [visible, setVisible] = useState(true)
useEffect(() => {
// Measure initial layout, then show banner
requestAnimationFrame(() => setVisible(true))
}, [])
if (!visible) return null
return <div className="cookie-banner">...</div>
}
INP: Interaction to Next Paint (Replacing FID)
INP measures the time from a user interaction (click, tap, keypress) to the next frame update. Target: < 200ms.
INP Optimization
1. Break Up Long Tasks
// Before: Blocking the main thread
function processAllRows(rows: Row[]) {
rows.forEach((row) => expensiveOperation(row)) // blocks UI for 500ms
}
// After: Yielding to the event loop
async function processRowsProgressive(rows: Row[]) {
const chunkSize = 50
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize)
chunk.forEach((row) => expensiveOperation(row))
await new Promise((resolve) => setTimeout(resolve, 0)) // yield
}
}
2. Defer Non-Critical JavaScript
// Lazy load heavy components
const AnalyticsChart = lazy(() => import("./AnalyticsChart"))
const ExportButton = lazy(() => import("./ExportButton"))
function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
)
}
3. Use isPending for Optimistic UI
const mutation = useMutation({
mutationFn: updateEmail,
onMutate: async (newEmail) => {
// Optimistic update — UI responds immediately
queryClient.setQueryData(["user"], (old) => ({ ...old, email: newEmail }))
},
onError: () => {
// Rollback on failure
queryClient.invalidateQueries({ queryKey: ["user"] })
},
})
Measuring Web Vitals in Production
// src/lib/web-vitals.ts
import { onLCP, onCLS, onINP, onTTFB } from "web-vitals"
export function reportWebVitals() {
onLCP((metric) => sendMetric("LCP", metric.value))
onCLS((metric) => sendMetric("CLS", metric.value))
onINP((metric) => sendMetric("INP", metric.value))
onTTFB((metric) => sendMetric("TTFB", metric.value))
}
async function sendMetric(name: string, value: number) {
// Send to your analytics engine
await fetch("/api/vitals", {
method: "POST",
body: JSON.stringify({ name, value, url: window.location.pathname }),
})
}
// Server-side — store Web Vitals data in D1
export const reportVital = createServerFn({ method: "POST" }).handler(
async ({ data, context }) => {
await context.env.DB.prepare(`
INSERT INTO web_vitals (id, name, value, path, user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).bind(
crypto.randomUUID(),
data.name,
data.value,
data.url,
context.session?.userId ?? null,
Date.now()
).run()
}
)
Web Vitals Dashboard
export const getVitalsDashboard = createServerFn({ method: "GET" }).handler(
async ({}, { context }) => {
const lcp = await context.env.DB.prepare(`
SELECT AVG(value) as avg, PERCENTILE(value, 75) as p75,
PERCENTILE(value, 95) as p95
FROM web_vitals
WHERE name = 'LCP' AND created_at > datetime('now', '-7 days')
`).first()
const cls = await context.env.DB.prepare(`...`).first()
const inp = await context.env.DB.prepare(`...`).first()
const passingRate = await context.env.DB.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN name = 'LCP' AND value < 2500 THEN 1 ELSE 0 END) as good_lcp,
SUM(CASE WHEN name = 'CLS' AND value < 0.1 THEN 1 ELSE 0 END) as good_cls,
SUM(CASE WHEN name = 'INP' AND value < 200 THEN 1 ELSE 0 END) as good_inp
FROM web_vitals
WHERE created_at > datetime('now', '-7 days')
`).first()
return { lcp, cls, inp, passingRate }
}
)
Performance Budget Compliance
// CI check — ensure Web Vitals meet thresholds
const BUDGETS = {
LCP: 2500,
CLS: 0.1,
INP: 200,
TTFB: 800,
}
export const checkPerformanceBudget = createServerFn({ method: "GET" }).handler(
async ({}, { context }) => {
const results = await Promise.all(
Object.entries(BUDGETS).map(async ([metric, budget]) => {
const row = await context.env.DB.prepare(`
SELECT AVG(value) as avg
FROM web_vitals
WHERE name = ? AND created_at > datetime('now', '-1 day')
`).bind(metric).first()
return {
metric,
current: row.avg,
budget,
passing: row.avg <= budget,
}
})
)
return results
}
)
SaaS-Specific Optimization Summary
| Component | Affected Metric | SaaS-Specific Fix |
|---|---|---|
| Dashboard charts | LCP, INP | Skeleton loading, lazy render below fold |
| Data tables | CLS, INP | Fixed column widths, virtual scrolling |
| Auth redirect | LCP | Prefetch auth state, show content immediately |
| Pricing tables | CLS | Fixed height cards, reserve space for yearly toggle |
| Search results | INP | Debounced input, virtual list |
| Notifications | CLS | Fixed-position toast, no DOM insertion shift |
| Modals | CLS | Fixed positioning, prevent background scroll |
Conclusion
Core Web Vitals are not just an SEO ranking factor — they directly impact user satisfaction and conversion rates. For SaaS applications, optimizing these metrics requires a systematic approach:
- Measure Web Vitals in production with real user monitoring
- Set budgets for each metric and alert when they are exceeded
- Address LCP with server-side rendering, preloading, and image optimization
- Eliminate CLS with explicit dimensions, reserved space, and predictable layouts
- Optimize INP with progressive computation, lazy loading, and optimistic UI
The advantage of TanStack Start on Cloudflare Workers: streaming SSR reduces LCP by delivering content faster, and the edge-native architecture ensures low TTFB globally.
For a SaaS product that scores "good" on all Core Web Vitals, see tanstackship.com.
Top comments (0)