perf hacks
Core Web VitalsFCP

FCP - First Contentful Paint

Optimize the time to first meaningful content for better perceived performance

First Contentful Paint (FCP) measures the time from when the page starts loading to when any part of the page's content is rendered on screen. While not a Core Web Vital, it's a critical supporting metric.

Understanding FCP

Score Thresholds

ScoreRatingUser Perception
≤ 1.8sGoodQuick start
1.8s - 3sNeeds ImprovementNoticeable wait
> 3sPoorFeels broken

FCP vs LCP

MetricWhat It MeasuresExample Element
FCPFirst content visibleLoading spinner, header text
LCPLargest content visibleHero image, main heading

A fast FCP with a slow LCP can still feel slow. Both metrics matter, but LCP is the Core Web Vital.

What Triggers FCP?

FCP is triggered by the first paint of:

  • Text (any font, any size)
  • Images (including background images)
  • SVG content
  • Non-white <canvas> elements
// These all trigger FCP:
<h1>Hello World</h1>           // Text
<img src="/logo.svg" />        // Image
<div style={{ background: 'url(bg.jpg)' }} /> // Background
<svg>...</svg>                 // SVG

FCP Optimization Strategies

Eliminate Render-Blocking Resources

// ❌ Bad: Blocking CSS in head
<link rel="stylesheet" href="/heavy-styles.css" />

// ✅ Good: Inline critical CSS, defer rest
<style>{criticalCSS}</style>
<link
  rel="stylesheet"
  href="/heavy-styles.css"
  media="print"
  onload="this.media='all'"
/>

Optimize Server Response Time

// ❌ Bad: Slow API call delays everything
export async function getServerSideProps() {
  const data = await fetch('https://slow-api.com/data');
  return { props: { data } };
}

// ✅ Good: Use static generation
export async function getStaticProps() {
  const data = await fetch('https://slow-api.com/data');
  return {
    props: { data },
    revalidate: 60,
  };
}

// ✅ Or stream with Suspense
export default function Page() {
  return (
    <>
      <Header /> {/* Paints immediately */}
      <Suspense fallback={<Skeleton />}>
        <SlowContent />
      </Suspense>
    </>
  );
}

Preload Critical Resources

app/layout.tsx
export default function Layout({ children }) {
  return (
    <html>
      <head>
        {/* Preload critical font */}
        <link
          rel="preload"
          href="/fonts/inter-var.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
        {/* Preconnect to third-party domains */}
        <link rel="preconnect" href="https://api.example.com" />
        <link rel="dns-prefetch" href="https://cdn.example.com" />
      </head>
      <body>{children}</body>
    </html>
  );
}

Optimize JavaScript Loading

next.config.js
module.exports = {
  experimental: {
    optimizePackageImports: ['lodash', 'date-fns', '@mui/material'],
  },
};

// Component level
import dynamic from 'next/dynamic';

// Defer non-critical components
const Analytics = dynamic(() => import('./Analytics'), {
  ssr: false,
  loading: () => null,
});

Use Edge Runtime

// Faster TTFB means faster FCP
export const runtime = 'edge';

export default function Page() {
  return <div>Edge-rendered content</div>;
}

FCP and Streaming SSR

app/page.tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <>
      {/* Header paints immediately for fast FCP */}
      <header className="h-16 bg-white border-b">
        <nav>Site Navigation</nav>
      </header>

      {/* Main content can stream in */}
      <Suspense fallback={<MainSkeleton />}>
        <MainContent />
      </Suspense>

      {/* Footer also streams */}
      <Suspense fallback={<FooterSkeleton />}>
        <Footer />
      </Suspense>
    </>
  );
}

Shell Pattern for Fast FCP

components/AppShell.tsx
export function AppShell({ children }) {
  return (
    <div className="min-h-screen">
      {/* Static shell renders instantly */}
      <header className="h-16 border-b">
        <Logo />
        <NavLinks />
      </header>

      <div className="flex">
        {/* Static sidebar */}
        <aside className="w-64 border-r">
          <SidebarLinks />
        </aside>

        {/* Dynamic content streams in */}
        <main className="flex-1 p-4">
          {children}
        </main>
      </div>
    </div>
  );
}

// app/layout.tsx
export default function Layout({ children }) {
  return (
    <AppShell>
      <Suspense fallback={<PageSkeleton />}>
        {children}
      </Suspense>
    </AppShell>
  );
}

Measuring FCP

import { onFCP, type FCPMetric } from 'web-vitals';

export function measureFCP() {
  onFCP((metric: FCPMetric) => {
    console.log('FCP:', metric.value, 'ms');

    // Get the FCP entry for more details
    const entry = metric.entries[0];
    console.log('FCP Element:', entry?.name);

    sendToAnalytics({
      name: 'FCP',
      value: metric.value,
      rating: metric.rating,
    });
  });
}

FCP Optimization Checklist

Server Response

Use static generation where possible Pre-rendered HTML is served instantly from CDN.

Implement ISR for dynamic content Static speed with periodic updates.

Consider Edge Runtime Reduce latency by running closer to users.

Optimize database queries Slow queries delay server response.

Use CDN caching Cache responses at the edge for faster delivery.

Critical Resources

Inline critical CSS Above-the-fold styles should be in the HTML.

Preload fonts Start font download early to prevent FOUT.

Preconnect to origins Establish connections to third-party domains early.

Defer non-critical CSS Load below-fold styles after initial paint.

Minimize CSS size Smaller CSS files parse and apply faster.

JavaScript

Code split by route Each page loads only its required code.

Defer non-critical scripts Load analytics after main content renders.

Use dynamic imports Load heavy components on demand.

Tree shake dependencies Remove unused code from bundles.

Minimize bundle size Smaller bundles download and parse faster.

Real-World Impact

OptimizationFCP Improvement
Static generation-200ms to -800ms
Edge runtime-100ms to -400ms
Inline critical CSS-100ms to -300ms
Font preloading-50ms to -200ms
Code splitting-100ms to -300ms

On this page