Optimizing NextJS Performance with Core Web Vitals

Introduction

Core Web Vitals are Google's real-world metrics used to measure user experience regarding loading speed, interactivity and visual stability of a website. Optimizing these metrics not only helps retain users longer but also serves as an important ranking factor in SEO, helping your website achieve higher positions on search engines.

This set of metrics includes 3 main components:

  • LCP (Largest Contentful Paint): Measures loading performance, specifically the time it takes for the largest content element to become visible.
  • FID (First Input Delay): Measures interactivity, evaluating the response time when a user first interacts.
  • CLS (Cumulative Layout Shift): Measures visual stability, preventing situations where elements unexpectedly jump positions.

Impact from a Technical Perspective
  • SEO and Ranking (Search Signal): Google has officially made Core Web Vitals (CWV) one of its ranking signals.
    • If two websites have equivalent content (Relevance), the one with better CWV metrics will be prioritized for a higher rank.
    • Note that while good CWV cannot rescue poor content, great content with poor CWV will unfortunately suffer a drop in rankings.
  • User Experience (Retention Rate): From a product development standpoint, CWV directly impacts the user funnel:
    • High LCP: Users rarely have the patience to wait and will abandon the page immediately, leading to an increased bounce rate.
    • High INP: This creates a perception that the system is slow to respond. For example, if a user clicks "Add to Cart" and the UI only responds a second later, it makes them suspect the system is glitching.
    • High CLS: When the UI is unstable and shifts too frequently, it creates a frustrating user experience. For instance, if a user intends to click a certain button but a sudden layout shift causes them to misclick another, it severely damages their trust in the product—especially if this happens during critical actions like payments.
  • Infrastructure and Resources: Optimizing CWV typically requires comprehensive coordination across the client, server, and DevOps:
    • Improving LCP: Forces you to implement CDNs, optimize the size of static assets (bundle size, CSS, images), or correctly utilize Server-Side Rendering (SSR) to reduce Time to First Byte (TTFB).
    • Optimizing INP: Requires you to manage the Main Thread and avoid long-running JavaScript tasks (Long Tasks) that block the Event Loop.
    • Optimizing CLS: Requires minimizing layout thrashing to prevent DOM mutations from causing continuous reflows, keeping the CLS metric to a minimum. Always set dimensions (width/height) for assets that take time to load (such as images and videos).

Detail

Because a specific guide on how to optimize all 3 metrics would make the article very long, I will first guide you through LCP, while the remaining metrics will be discussed specifically in subsequent articles.

Methods to optimize for LCP include:

  • Parallel Fetching and Caching: Use Route Handlers to load data concurrently using Promise.allSettled or Promise.all.
  • stale-while-revalidate is a caching mechanism to minimize server response wait times, applied to static data or data that changes infrequently to prevent server overload when multiple requests occur simultaneously.
  • Streaming with Suspense: Use Server Components to return data directly to the client, while heavy data requiring long processing times utilizes Re-Partial Rendering to stream that data gradually to the client.
  • Image Optimization: Use the priority attribute in next/image for the main banner so the browser prioritizes immediate loading, while using loading="lazy" for products below to save resources.
  • Note that using priority is only applied to main images directly visible to the user.
  • Applying it to every image will lead to bandwidth resource contention.

Create file app/api/dashboard-summary/route.ts

import {NextResponse} from 'next/server'

export interface DashboardSummaryResponse {
  stats: {
    totalUsers: number
    activeNow: number
  } | null
  banner: {
    imageUrl: string
    title: string
  } | null
  errors: string[]
}

async function fetchUserStatsFromThirdParty() {
  await new Promise(resolve => setTimeout(resolve, 1000))
  return {totalUsers: 24500, activeNow: 1230}
}

async function fetchBannerFromThirdParty() {
  await new Promise(resolve => setTimeout(resolve, 800))
  return {
    imageUrl: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe',
    title: 'Next-Gen Technology Solutions',
  }
}

export async function GET() {
  const errors: string[] = []

  const [statsResult, bannerResult] = await Promise.allSettled([
    fetchUserStatsFromThirdParty(),
    fetchBannerFromThirdParty(),
  ])

  const stats =
    statsResult.status === 'fulfilled'
      ? statsResult.value
      : (errors.push(`Stats API Error: ${statsResult.reason}`), null)

  const banner =
    bannerResult.status === 'fulfilled'
      ? bannerResult.value
      : (errors.push(`Banner API Error: ${bannerResult.reason}`), null)

  return NextResponse.json<DashboardSummaryResponse>(
    {stats, banner, errors},
    {
      status: 200,
      headers: {
        'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30',
      },
    }
  )
}

Use Promise.allSettled to perform parallel fetching. This helps optimize performance because requests do not have to wait for each other.

Create file app/dashboard/types.ts

export interface Product {
  id: number
  title: string
  price: number
  thumbnail: string
}

export interface UserStats {
  totalUsers: number
  activeNow: number
}

Create file app/dashboard/loading.tsx to define Skeletons for components during the data streaming process.

import {Card, Skeleton} from 'antd'

export default function DashboardLoading() {
  return (
    <div className="p-6 max-w-7xl mx-auto space-y-6">
      <div className="h-10 w-48 bg-gray-200 rounded animate-pulse mb-6" />

      <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
        <Card>
          <Skeleton active paragraph={{rows: 1}} />
        </Card>
        <Card>
          <Skeleton active paragraph={{rows: 1}} />
        </Card>
      </div>

      <div className="w-full h-[400px] bg-gray-200 rounded-lg animate-pulse" />
    </div>
  )
}

Create file app/dashboard/ProductList.tsx

import {Badge, Card} from 'antd'
import {Meta} from 'antd/es/list/Item'
import Image from 'next/image'
import type {Product} from './types'

async function getProducts(): Promise<Product[]> {
  await new Promise(resolve => setTimeout(resolve, 2500))

  const res = await fetch('https://dummyjson.com/products?limit=4', {
    next: {revalidate: 60},
  })

  if (res.ok) {
    const data = await res.json()
    return data.products
  }
  return []
}

export default async function ProductList() {
  const products = await getProducts()

  return (
    <div className="space-y-6">
      <h2 className="text-2xl font-bold text-gray-800 tracking-tight">
        Featured Products
      </h2>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
        {products.map(product => (
          <Card
            key={product.id}
            hoverable
            className="overflow-hidden border-gray-100 shadow-sm transition-all duration-300 hover:shadow-lg"
            cover={
              <div className="relative w-full h-52 bg-gray-50">
                <Image
                  src={product.thumbnail}
                  alt={product.title}
                  fill
                  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
                  className="object-cover"
                  loading="lazy"
                />
              </div>
            }
          >
            <Meta
              title={
                <span className="text-lg font-semibold text-gray-900 block truncate">
                  {product.title}
                </span>
              }
              description={
                <div className="mt-2">
                  <Badge
                    status="success"
                    text={
                      <span className="text-green-600 font-bold">
                        ${product.price}
                      </span>
                    }
                  />
                </div>
              }
            />
          </Card>
        ))}
      </div>
    </div>
  )
}

Cache data for 60 seconds so even with a large volume of simultaneous access, NextJS only needs to return cached results instead of calling data too many times.

Create file app/dashboard/page.tsx

import {DashboardSummaryResponse} from '@/app/api/dashboard-summary/route'
import {ArrowUpOutlined, UserOutlined} from '@ant-design/icons'
import {Alert, Card, Skeleton, Statistic} from 'antd'
import Image from 'next/image'
import {Suspense} from 'react'
import ProductList from './ProductList'

async function getDashboardSummary(): Promise<DashboardSummaryResponse> {
  const baseUrl = process.env.HOST
  const res = await fetch(`${baseUrl}/api/dashboard-summary`, {
    next: {revalidate: 60},
  })
  if (!res.ok) {
    throw new Error('Failed to fetch dashboard aggregate data')
  }
  return res.json()
}

export default async function DashboardPage() {
  const {stats, banner, errors} = await getDashboardSummary()

  return (
    <div className="p-8 max-w-7xl mx-auto space-y-10 antialiased min-h-screen bg-gray-50/30">
      <header className="flex flex-col space-y-4">
        <h1 className="text-4xl font-black text-gray-900 tracking-tight">
          System Dashboard
        </h1>
        {errors.length > 0 && (
          <div className="space-y-2">
            {errors.map((err, idx) => (
              <Alert
                key={idx}
                title={err}
                type="error"
                showIcon
                closable
                className="rounded-lg shadow-sm"
              />
            ))}
          </div>
        )}
      </header>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        <Card className="border-none shadow-md rounded-2xl">
          <Statistic
            title={
              <span className="text-gray-500 font-medium">Total Members</span>
            }
            value={stats?.totalUsers || 0}
            prefix={<UserOutlined className="text-blue-500 mr-2" />}
            loading={!stats}
          />
        </Card>
        <Card className="border-none shadow-md rounded-2xl">
          <Statistic
            title={
              <span className="text-gray-500 font-medium">Active Users</span>
            }
            value={stats?.activeNow || 0}
            styles={{content: {color: '#10b981', fontWeight: 800}}}
            prefix={<ArrowUpOutlined className="mr-2" />}
            loading={!stats}
          />
        </Card>
      </div>

      {banner && (
        <div className="relative w-full h-[400px] rounded-3xl overflow-hidden shadow-2xl border border-white/20">
          <Image
            src={banner.imageUrl}
            alt={banner.title}
            fill
            priority
            sizes="(max-width: 1280px) 100vw, 1280px"
            className="object-cover"
          />
          <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent flex items-end p-10">
            <h2 className="text-white text-4xl md:text-6xl font-extrabold max-w-3xl leading-tight drop-shadow-lg">
              {banner.title}
            </h2>
          </div>
        </div>
      )}

      <div className="pt-4">
        <Suspense
          fallback={
            <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
              {[1, 2, 3, 4].map(i => (
                <Skeleton
                  key={i}
                  active
                  className="p-4 bg-white rounded-xl shadow-sm"
                />
              ))}
            </div>
          }
        >
          <ProductList />
        </Suspense>
      </div>
    </div>
  )
}
  1. Parallel Fetching and Caching: At the Route Handler, data is loaded in parallel using Promise.allSettled and applies a stale-while-revalidate strategy to minimize server response wait times.
  2. Streaming with Suspense: The Dashboard page separates critical data (Stats, Banner) to render on the server side and uses Suspense for the product list which has high latency. This helps display the page layout faster, improving the perception of speed.
  3. Image Optimization: Use the priority attribute in next/image for the main banner (LCP element) so the browser prioritizes immediate loading, while using loading="lazy" for products below to save resources.


Before you can use it, you need to configure the settings to allow loading images from cross-origins in next.config.ts

import type {NextConfig} from 'next'

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.dummyjson.com',
      },
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
    ],
  },
}

export default nextConfig


You can check Core Web Vitals metrics in the Performance tab of Chrome Dev Tools as follows:


Happy coding!

See more articles here.

Comments