Why performance matters for your SaaS
Speed isn't a luxury — it's a competitive advantage. Google's research shows that 53% of mobile users abandon a page if it takes longer than 3 seconds to load. For SaaS products, the impact is even greater: slow applications lead to higher churn, lower conversions, and user frustration.
Core Web Vitals — Google's set of metrics that measure real user experience — aren't just important for SEO. They give you an objective view of how your application performs in the real world.
In this article, we'll dive deep into the three Core Web Vitals, show you how to measure them in your SaaS, and provide concrete optimizations you can apply today.
The three Core Web Vitals
1. Largest Contentful Paint (LCP)
LCP measures how long it takes for the largest visible element on the page to render. This is typically a hero image, a large heading, or a data table.
Target: < 2.5 seconds
Common problems in SaaS:
- Heavy dashboards fetching lots of data on initial load
- Unoptimized images on marketing pages
- Render-blocking JavaScript bundles
Solutions:
// ❌ Bad: load everything on mount
useEffect(() => {
const data = await fetch('/api/dashboard/all-widgets');
setWidgets(data);
}, []);
// ✅ Better: critical data first, rest lazy
useEffect(() => {
// Load only the top widget immediately
const hero = await fetch('/api/dashboard/hero-stats');
setHeroStats(hero);
// Rest after interaction or with IntersectionObserver
}, []);
Server-side optimizations:
// Next.js: use streaming SSR for faster TTFB
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { HeroStats } from './hero-stats';
import { WidgetGrid } from './widget-grid';
export default function Dashboard() {
return (
<div>
{/* Critical path - rendered immediately */}
<Suspense fallback={<HeroSkeleton />}>
<HeroStats />
</Suspense>
{/* Secondary - streamed later */}
<Suspense fallback={<WidgetSkeleton />}>
<WidgetGrid />
</Suspense>
</div>
);
}
2. Interaction to Next Paint (INP)
INP replaced the old First Input Delay (FID) metric in March 2024. It measures the responsiveness of your entire page — not just the first interaction, but all interactions throughout the visit.
Target: < 200 milliseconds
Common problems in SaaS:
- Heavy client-side filtering/sorting of large datasets
- Complex form validation blocking the main thread
- Too many re-renders in React/Vue components
Solutions:
// ❌ Bad: filtering on the main thread
function FilterableTable({ data }: { data: Row[] }) {
const [filter, setFilter] = useState('');
// This blocks the main thread with 10,000+ rows
const filtered = data.filter(row =>
row.name.toLowerCase().includes(filter.toLowerCase())
);
return <Table data={filtered} />;
}
// ✅ Better: use useDeferredValue or Web Workers
function FilterableTable({ data }: { data: Row[] }) {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
const filtered = useMemo(
() => data.filter(row =>
row.name.toLowerCase().includes(deferredFilter.toLowerCase())
),
[data, deferredFilter]
);
return (
<>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
// Input responds immediately, table updates deferred
/>
<Table data={filtered} />
</>
);
}
For truly heavy computations, use a Web Worker:
// worker.ts
self.onmessage = (event) => {
const { data, filter } = event.data;
const result = data.filter(row =>
row.name.toLowerCase().includes(filter.toLowerCase())
);
self.postMessage(result);
};
// component.tsx
const worker = new Worker(new URL('./worker.ts', import.meta.url));
worker.postMessage({ data, filter });
worker.onmessage = (e) => setFiltered(e.data);
3. Cumulative Layout Shift (CLS)
CLS measures how much the layout unexpectedly shifts during loading. Nothing is more frustrating than trying to click a button that suddenly jumps away.
Target: < 0.1
Common problems in SaaS:
- Dynamic banners or notifications inserted at the top
- Images without explicit dimensions
- Late-loading fonts causing text to shift (FOUT)
- Skeleton loaders with incorrect dimensions
Solutions:
/* Reserve space for dynamic content */
.notification-bar {
min-height: 48px; /* Prevent shift when banner appears */
}
/* Use aspect-ratio for images */
.dashboard-chart {
aspect-ratio: 16 / 9;
width: 100%;
}
// Next.js Image component handles this automatically
import Image from 'next/image';
<Image
src="/chart-placeholder.png"
width={800}
height={450}
alt="Dashboard chart"
priority // For above-the-fold content
/>
Font optimization:
// next.config.js / layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Shows fallback font immediately, swaps when loaded
preload: true,
});
Measure to improve: setting up performance monitoring
Real User Monitoring (RUM)
Core Web Vitals in your local dev environment are meaningless — you need to measure what real users experience. Here are three approaches, from simple to comprehensive:
1. Vercel Analytics (if you host on Vercel):
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
2. Custom web-vitals reporting:
// lib/vitals.ts
import { onCLS, onINP, onLCP } from 'web-vitals';
function sendToAnalytics(metric) {
// Send to your own endpoint or analytics service
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
navigationType: metric.navigationType,
url: window.location.pathname,
}),
});
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
3. Performance budget in your CI/CD pipeline:
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: treosh/lighthouse-ci-action@v11
with:
urls: |
http://localhost:3000/
http://localhost:3000/dashboard
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true
// lighthouse-budget.json
[{
"path": "/*",
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "interactive", "budget": 3500 }
],
"resourceSizes": [
{ "resourceType": "script", "budget": 300 },
{ "resourceType": "total", "budget": 800 }
]
}]
SaaS-specific optimizations
Bundle splitting per role
Most SaaS applications have different user roles (admin, user, viewer). Don't load admin modules for regular users:
// Lazy load admin-only features
const AdminPanel = lazy(() => import('./admin/AdminPanel'));
const BillingSettings = lazy(() => import('./admin/BillingSettings'));
function AppRouter() {
const { role } = useUser();
return (
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
{role === 'admin' && (
<Route
path="/admin/*"
element={
<Suspense fallback={<Loading />}>
<AdminPanel />
</Suspense>
}
/>
)}
</Routes>
);
}
Database query optimization
Slow APIs are the #1 cause of poor LCP in SaaS dashboards:
// ❌ N+1 problem
const tenants = await prisma.tenant.findMany();
for (const tenant of tenants) {
tenant.users = await prisma.user.findMany({
where: { tenantId: tenant.id }
});
}
// ✅ Eager loading
const tenants = await prisma.tenant.findMany({
include: {
users: {
select: { id: true, name: true, email: true },
take: 10, // Paginate in the query, not in the frontend
},
_count: { select: { users: true } }
},
});
Edge caching for static and semi-dynamic content
// Next.js route handler with ISR
// app/api/public/pricing/route.ts
export const revalidate = 3600; // Cache for 1 hour
export async function GET() {
const plans = await prisma.plan.findMany({
where: { active: true }
});
return Response.json(plans);
}
// For per-tenant data: use stale-while-revalidate
export async function GET(request: Request) {
const tenantId = getTenantFromRequest(request);
return Response.json(data, {
headers: {
'Cache-Control': 'private, s-maxage=60, stale-while-revalidate=300',
'Vary': 'Authorization', // Cache per user
},
});
}
Building a performance culture
Technical optimizations are only half the battle. The other half is building a culture where performance is a first-class citizen:
- Make performance visible: display a dashboard with Core Web Vitals p75 in your Slack/Teams channel
- Set budgets: block PRs that increase bundle size by more than 10%
- Test on real devices: your M3 MacBook Pro isn't representative of your users
- Monitor per page: your marketing site and dashboard have very different profiles
- Review regularly: schedule a monthly "performance review" session
Conclusion
Performance optimization for your SaaS isn't a one-time project — it's an ongoing process. Start by measuring (you can't improve what you don't measure), focus on the three Core Web Vitals, and implement the optimizations that have the most impact for your specific application.
The investment pays for itself many times over: better SEO rankings, higher conversions, lower churn, and — perhaps most importantly — users who actually enjoy using your product.
Start today: add web-vitals to your application, measure for a week, and tackle your biggest bottleneck. Your users will thank you.