DEV Community

sweet
sweet

Posted on

Core Web Vitals Deep Dive: LCP, CLS, and INP Optimization for SaaS

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>
  ),
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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" }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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"] })
  },
})
Enter fullscreen mode Exit fullscreen mode

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 }),
  })
}
Enter fullscreen mode Exit fullscreen mode
// 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()
  }
)
Enter fullscreen mode Exit fullscreen mode

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 }
  }
)
Enter fullscreen mode Exit fullscreen mode

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
  }
)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Measure Web Vitals in production with real user monitoring
  2. Set budgets for each metric and alert when they are exceeded
  3. Address LCP with server-side rendering, preloading, and image optimization
  4. Eliminate CLS with explicit dimensions, reserved space, and predictable layouts
  5. 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.

Related Resources

Top comments (0)