Written by Huifer, solo developer and maintainer of TanStack Ship. I have shipped the same Core Web Vitals playbook across five production TanStack Start apps in the last 14 months — a B2B billing dashboard at ~3,200 customers, a multi-tenant analytics tool, a developer dashboard, and two marketplaces. Every app now sits at 95+ Lighthouse Performance, with p75 LCP under 1.4 s and p75 INP under 120 ms on simulated mobile 4G. This article is the playbook, the numbers, and the regressions.
Verified sources: web.dev — Web Vitals · Google Search Central — Core Web Vitals · Chrome for Developers — INP · TanStack Start documentation · TanStack Router SSR streaming
Last updated: 2026-08-28 · Changelog
TL;DR: Core Web Vitals in 2026 are LCP, CLS, and INP — Google's ranking-aligned metrics that measure real-user loading, layout stability, and interaction responsiveness. The 75th percentile targets that hold across Google's published thresholds are LCP under 2.5 seconds, CLS under 0.1, and INP under 200 milliseconds. The playbook that gets a TanStack Start SaaS to 95+ Lighthouse Performance in production is eight steps: ship a strict performance budget, defer non-critical JS, stream SSR through TanStack Router, reserve layout space for fonts and images, compress and preconnect assets, use Cloudflare Cache API at the edge, isolate hydration cost, and monitor p75 RUM continuously. For the full stack see TanStack Ship features; for a deeper routing optimization read see TanStack Router best practices.
Core Web Vitals 2026: The 8-Step Playbook That Took Five TanStack Start SaaS Apps to 95+
Google's ranking system has been Core Web Vitals-aware since 2021, but 2026 is the year the thresholds stopped being a tiebreaker and started being a hard floor. The Page Experience signal still complements relevance, but sites that fail the p75 thresholds are now seeing measurable position drops in competitive SERPs — I watched three of my clients move from page 2 to page 1 after a single Lighthouse-focused rewrite. This piece is the playbook I now ship by default.
What Core Web Vitals Are in 2026 — and Why They Matter Now
The Core Web Vitals trio measures the three dimensions a user actually feels: how fast the largest content shows up, how much the page jumps around while loading, and how quickly the page responds once a user touches it. According to Google's Web Vitals documentation, the official thresholds at the 75th percentile of real-user measurements are:
- LCP (Largest Contentful Paint) — under 2.5 s for "good"
- CLS (Cumulative Layout Shift) — under 0.1 for "good"
- INP (Interaction to Next Paint) — under 200 ms for "good"
The 75th percentile rule is important. Google does not look at averages; it looks at the user experience at the third quartile of your traffic. That means 25% of your users can have a terrible experience and your "page experience" signal can still be "good" — but no further. If your median user is on a slow phone, your p75 user is on a worse phone, and that is who Google is measuring.
I learned this the hard way on my analytics app in Q4 2025. Lighthouse was showing 92 Performance on desktop, but CrUX field data flagged the site as "needs improvement" because p75 mobile LCP was 3.1 s. Desktop was a vanity metric; field p75 was the truth.
The 8-Step Playbook I Ship on Every TanStack Start SaaS
The playbook below is what I now apply by default in TanStack Ship. None of the steps are exotic; they are the boring, repeatable patterns that compound.
Step 1: Set a performance budget before you write code
Performance budgets are the most leveraged decision you will make. The budget I use on every new app:
| Metric | Target | Hard fail |
|---|---|---|
| LCP (p75 mobile 4G) | ≤ 1.8 s | > 2.4 s |
| CLS (p75) | ≤ 0.05 | > 0.09 |
| INP (p75) | ≤ 150 ms | > 195 ms |
| Initial JS bundle | ≤ 90 KB gzip | > 110 KB gzip |
| LCP image transfer size | ≤ 60 KB | > 100 KB |
The budget is enforced in CI via Lighthouse CI on every PR. A PR that pushes the JS bundle past 90 KB gzip is auto-blocked. This single rule has saved me from four performance regressions that would have shipped unnoticed.
Step 2: Defer every non-critical JavaScript
The single biggest LCP win on a TanStack Start app is shipping less JavaScript to the first paint. The default TanStack Start template ships the entire route graph and all route-loaders' code-split boundaries eagerly; the trick is to keep that promise while moving the actual loader functions and their dependencies to dynamic imports.
// src/routes/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({
// Loader runs on server during SSR — code does NOT ship to client
loader: async ({ context }) => {
const { getDashboardData } = await import('~/server/dashboard')
return getDashboardData(context.tenantId)
},
component: DashboardPage,
})
function DashboardPage() {
// useLoaderData() is type-safe; data is already in the cache after SSR
const { stats } = Route.useLoaderData()
return <StatsGrid stats={stats} />
}
The loader function only runs on the server during SSR streaming — it is removed from the client bundle entirely. The client receives only the JSON payload. On my B2B billing app, this single change took the dashboard route's client JS from 184 KB gzip to 71 KB gzip.
Step 3: Stream SSR through TanStack Router's loader pipeline
TanStack Router's loader pipeline streams HTML progressively as data resolves. The browser sees the <head>, then the shell, then each section as its loader completes. According to the TanStack Router documentation, this streaming behavior is on by default when the response is sent with Transfer-Encoding: chunked.
The LCP win is that the LCP element — usually a hero image or a headline rendered from loader data — can ship in the first chunk instead of waiting for the full document. On the analytics app, streaming reduced p75 LCP from 2.6 s to 1.4 s.
Step 4: Reserve layout space for fonts and images
CLS is almost always caused by three things: web fonts loading after text renders, images without explicit dimensions, and dynamic content injected above the viewport. The fix is mechanical:
<!-- Always declare width and height on images -->
<img
src="/hero.webp"
width="1200"
height="600"
alt="Dashboard preview"
fetchpriority="high"
/>
<!-- Preload the critical font -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!-- Match the fallback's metrics to the web font -->
<style>
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
}
html { font-family: 'Inter', 'Inter Fallback', sans-serif; }
</style>
The size-adjust and ascent-override values come from the Google Fonts CSS API v2 — matching the fallback's metrics to the web font's metrics means the browser does not need to reflow when the font swaps in. On five apps, this pattern took CLS from 0.12 to under 0.03 without changing the design.
Step 5: Compress, preconnect, and serve modern formats
The hero image on a SaaS landing page is almost always the LCP element. Three settings compress that image's transfer size by 60–80%:
- Serve
aviforwebpinstead ofpng/jpg(the<picture>element falls back automatically) - Preconnect to the asset host so TLS negotiation completes before the image request is even sent
- Set
fetchpriority="high"on the LCP image andloading="lazy"on everything else
<head>
<link rel="preconnect" href="https://imgs.tanstackship.com" crossorigin />
<link rel="dns-prefetch" href="https://imgs.tanstackship.com" />
</head>
Combined, these three changes took the LCP image on the analytics landing page from 240 KB to 48 KB and dropped p75 LCP by another 200 ms.
Step 6: Cache HTML at the edge with Cloudflare Cache API
A SaaS landing page is the same for 95% of visitors. The HTML should be cached at the edge. The Cloudflare Cache API on a Workers handler is the simplest way:
// src/server/index.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const cache = caches.default
const cached = await cache.match(request)
if (cached) return cached
const response = await renderToStream(request, env)
// Only cache GETs that are HTML and don't have a session cookie
if (
request.method === 'GET' &&
response.headers.get('content-type')?.includes('text/html') &&
!request.headers.get('cookie')?.includes('session=')
) {
response.headers.set('Cache-Control', 'public, max-age=300, s-maxage=3600')
await cache.put(request, response.clone())
}
return response
},
}
Edge-cached HTML takes TTFB to under 30 ms in the same region, which is the floor of any LCP improvement. For pages that must be personalized, set the cache key to include the session ID and let the cache work per-tenant.
Step 7: Isolate hydration cost on heavy routes
INP is the metric I underestimated most in 2025. INP measures the latency between a user interaction and the next paint that reflects the result. On a TanStack Start app, the first INP hit often comes from hydration — the moment after page load when React attaches event handlers and processes deferred client work.
The fix is to defer hydration of below-the-fold components until they scroll into view. TanStack Start integrates cleanly with the IntersectionObserver pattern documented here:
// src/components/LazyHydrate.tsx
import { useEffect, useRef, useState } from 'react'
export function LazyHydrate({ children, fallback }: { children: React.ReactNode; fallback: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setVisible(true) },
{ rootMargin: '200px' }
)
if (ref.current) observer.observe(ref.current)
return () => observer.disconnect()
}, [])
return <div ref={ref}>{visible ? children : fallback}</div>
}
On the developer dashboard, this pattern took INP from 280 ms (over Google's threshold) to 110 ms.
Step 8: Monitor p75 RUM, not Lighthouse lab data
Lighthouse runs on a simulated throttled connection in a virtual machine. It is a useful CI check, but it is not what users see. The only metric that reflects user experience is the 75th percentile of real-user measurements, captured by a RUM tool and reported to you in production. The RUM implementation is the topic of my next post — Real User Monitoring for SaaS 2026 — but the key principle is: ship RUM on day one, set alerts at p75 90% of the budget, and treat every regression as a P1.
What the Numbers Looked Like After the Playbook
Five production apps, p75 mobile 4G, measured by CrUX field data and a Cloudflare Analytics Engine RUM pipeline:
| App | Pre-playbook LCP | Pre-playbook INP | Pre-playbook CLS | Post-playbook LCP | Post-playbook INP | Post-playbook CLS |
|---|---|---|---|---|---|---|
| B2B billing dashboard | 3.4 s | 280 ms | 0.14 | 1.2 s | 95 ms | 0.02 |
| Multi-tenant analytics | 2.9 s | 240 ms | 0.11 | 1.1 s | 88 ms | 0.02 |
| Developer dashboard | 2.6 s | 220 ms | 0.09 | 1.0 s | 80 ms | 0.01 |
| Marketplace A | 3.1 s | 260 ms | 0.13 | 1.3 s | 102 ms | 0.03 |
| Marketplace B | 2.8 s | 235 ms | 0.12 | 1.2 s | 96 ms | 0.02 |
Every metric now sits comfortably inside Google's "good" thresholds, and every app sits at 95+ Lighthouse Performance in CI. The honest caveats: I have not tested on 3G fallback networks, and all five apps run on the same stack family (TanStack Start + Cloudflare Workers + D1), so the playbook's transferability to other runtimes is unverified.
The Regressions I Hit and What They Taught Me
The playbook is not a one-time fix. Performance regresses constantly as features ship. Three regressions that bit me in the last year:
- A marketing site added a third-party chat widget in Q1 2026. The widget added 47 KB of blocking JS to the critical path. p75 INP jumped from 95 ms to 240 ms in 48 hours. Fix: lazy-load after first interaction.
- A dashboard added a heavy charting library. Its initial chunk pushed the dashboard route's JS from 71 KB to 138 KB. p75 LCP regressed from 1.2 s to 2.1 s. Fix: swap to a lighter chart library and dynamic-import on hover.
- A marketplace added dynamic ad slots above the fold. CLS jumped from 0.02 to 0.18. Fix: reserve a fixed-height container for every slot.
Every one of these regressions was caught by the RUM alert, not by Lighthouse. The lab number stayed green; the field number went red. If you only run Lighthouse in CI, you will miss all three.
What TanStack Ship Ships Out of the Box
TanStack Ship is the TanStack Start boilerplate I built for solo developers who want this playbook applied without rewriting it. Every template ships with:
- All eight steps of this playbook pre-configured
- A strict performance budget enforced in CI via Lighthouse CI
- The TanStack Router SSR streaming pattern wired up
- Cloudflare Cache API integration with sensible defaults
- The LazyHydrate component above ready to use
- A RUM pipeline already pointing at Cloudflare Analytics Engine
For a side-by-side comparison with other TanStack Start templates, see the TanStack Ship review or the vs Shipfast comparison. For Cloudflare edge cache strategy specifically, the Cloudflare Workers SSR writeup covers streaming in depth.
The 2026 floor is simple: ship a TanStack Start SaaS that fails the CWV p75 thresholds and you will lose ranking ground to competitors that pass them. The playbook above is the minimum viable work to pass them; TanStack Ship is the same work pre-applied so you can ship features instead.