Optimizing Core Web Vitals in Server-Side Rendered React Apps

Himanshu Tyagi
Last updated on Aug 2, 2026

Our guides are based on hands-on testing and verified sources. Each article is reviewed for accuracy and updated regularly to ensure current, reliable information.Read our editorial policy.

Even the most functional web app can lose users if pages load slowly or the interface is unresponsive. Performance directly affects visitor behavior, search visibility, conversion rates, and the perception of a digital product. Google developed the Core Web Vitals metrics to evaluate not only file loading speed but also the actual user experience.

For React projects, this presents new challenges. Modern libraries let you build complex and interactive interfaces, but large amounts of JavaScript, the hydration process, and numerous client-side computations can badly impact performance metrics.

Server-Side Rendering (SSR) helps reduce the time to first content display, but it doesn’t guarantee good Core Web Vitals results by itself. If the application architecture is built without addressing the specifics of rendering, even server-side HTML generation will not compensate for the excessive load on the browser. Treat optimization as a holistic process that covers both the server-side and client-side parts of the app, and start by knowing exactly what you’re being measured against.

Core Web Vitals Thresholds: What “Good” Actually Means

Before optimizing anything, it helps to know the actual numbers Google grades against. As of 2026, there are three Core Web Vitals, and Google scores each of them into Good, Needs Improvement, or Poor bands:

Metric Measures Good Needs Improvement Poor
LCP (Largest Contentful Paint) Loading speed ≤ 2.5s 2.5s – 4s > 4s
INP (Interaction to Next Paint) Responsiveness ≤ 200ms 200ms – 500ms > 500ms
CLS (Cumulative Layout Shift) Visual stability ≤ 0.1 0.1 – 0.25 > 0.25

Two details in that scoring model matter more than the thresholds themselves. First, Google grades at the 75th percentile of real visits over a rolling 28-day window, using Chrome User Experience Report (CrUX) data.

A page only “passes” once at least 75% of real visits hit the good threshold for all three metrics, so a fast machine on your desk means nothing if a quarter of your mobile visitors are stuck on a slow connection.

Second, INP replaced First Input Delay (FID) as the responsiveness metric in March 2024, and it’s a stricter test: instead of grading only the first interaction on a page, it grades the latency of every interaction and reports close to the worst one.

This is also why INP tends to be the metric that catches SSR-heavy React apps off guard: a page can look fast on FID and still score poorly on INP if hydration or a heavy handler blocks the main thread partway through a session.

Why SSR Doesn’t Guarantee Successful Core Web Vitals Optimization

Server-Side Rendering is often viewed as a universal way to improve React application performance. In reality, its main benefit is quickly sending pre-rendered HTML before JavaScript is executed in the browser. After receiving that HTML, the browser still needs to:

  • Load JavaScript,
  • Perform hydration,
  • Make the page interactive.

If this process is too heavy or includes unnecessary operations, LCP, INP, and CLS may remain far from the desired values even with SSR. That’s why optimizing Core Web Vitals can’t be limited to simply choosing a rendering method.

When analyzing performance, consider how well the React app development process itself is organized. Professional ReactJS development services include architectural design, optimization of client-side and server-side rendering, JavaScript bundle size control, code reviews, testing, and ongoing app support. Combined, these efforts help eliminate the causes of poor Core Web Vitals performance during development, rather than after issues arise in production.

The browser’s path from HTML to interactivity

After receiving the finished HTML, the browser still cannot interact with the page. React must:

  1. Rebuild the component tree,
  2. Attach all event handlers,
  3. Synchronize the UI state.

On small projects, this process is almost imperceptible. But when a page contains large tables, complex forms, or numerous third-party libraries, the browser is put under heavy load.

As a result, the user sees the content but cannot interact normally with the site for some time. That’s why more and more teams focus not only on fast SSR but also on gradual or selective hydration of individual UI elements, which reduces the load on the browser’s main thread.

React 18 and 19 handle this with streaming SSR combined with <Suspense> boundaries. Instead of hydrating the entire tree in one blocking pass, React can start hydrating a boundary as soon as its chunk of HTML and JavaScript have arrived, in priority order, rather than waiting for the whole page. A minimal server setup looks like this:

code
// server.jsx
import { renderToPipeableStream } from "react-dom/server";
import App from "./App";

app.get("/", (req, res) => {
  let didError = false;

  const { pipe } = renderToPipeableStream(<App />, {
    bootstrapScripts: ["/client.js"],
    onShellReady() {
      // The shell (everything above your Suspense boundaries) is ready.
      res.statusCode = didError ? 500 : 200;
      res.setHeader("Content-type", "text/html");
      pipe(res);
    },
    onError(err) {
      didError = true;
      console.error(err);
    },
  });
});

// App.jsx - wrap slow, non-critical regions in Suspense (+ an error boundary)
function App() {
  return (
    <Layout>
      <ProductDetails />
      <ErrorBoundary fallback={<p>Couldn't load reviews.</p>}>
        <Suspense fallback={<ReviewsSkeleton />}>
          <Reviews productId={id} />
        </Suspense>
      </ErrorBoundary>
    </Layout>
  );
}

The product details ship and hydrate immediately; the reviews section streams in and hydrates on its own schedule without blocking the rest of the page.

Next.js does this automatically for you at the route level through loading.js files, so if you’re on the App Router you get a version of this for free, but it’s still worth wrapping genuinely slow, non-critical components (a comments section, a recommendations widget) in their own Suspense boundary rather than relying only on the route-level one.

A common mistake is assuming that a fast server automatically means a fast app. After the browser receives the HTML, it still must execute all the JavaScript required for the interface to function.

If the client-side bundle contains code that isn’t used when the page first loads, it increases script execution time and delays user interaction with the app. That’s why most recommendations on how to fix Core Web Vitals start with an analysis of JavaScript bundles.

React Performance Best Practices for SSR Projects

 

Among the most effective solutions is sending only the code that is truly essential for the initial page rendering. The following techniques can greatly reduce the load on the browser:

  • On-demand component imports,
  • Splitting code into separate chunks,
  • Lazy loading.

In practice, this means reaching for React.lazy and Suspense for anything that isn’t needed on first paint, such as a modal, a chart library, or a below-the-fold widget:

code
import { lazy, Suspense } from "react";

// This chunk is only downloaded when it's actually rendered
const AnalyticsChart = lazy(() => import("./AnalyticsChart"));

function Dashboard() {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <AnalyticsChart />
    </Suspense>
  );
}

These techniques not only improve Core Web Vitals but also make the app more scalable as it grows. It’s worth pairing them with a quick pass through a JavaScript minifier and CSS minifier before deployment, since trimming unused whitespace and comments from your production bundles further reduces what the browser has to download on first paint.

For a fuller picture of what’s actually bloating a bundle, run @next/bundle-analyzer (or plain webpack-bundle-analyzer outside Next.js) before deciding what to split, rather than guessing.

Framework-Specific Fixes for Next.js Apps

If you’re building on Next.js, most of the highest-impact fixes are already built into the framework, and skipping them is the single most common reason a Next.js app still scores poorly. Here’s what to actually configure, not just install.

Images (LCP and CLS)

Images are the most common LCP bottleneck, and unsized images are the most common CLS cause. next/image handles both if you use it correctly:

code
import Image from "next/image";

<Image
  src="/hero.jpg"
  alt="Product hero image"
  width={1200}
  height={600}
  priority
  sizes="(max-width: 768px) 100vw, 1200px"
/>

priority preloads and skips lazy-loading for your actual LCP image (use it once, on the hero image, not on every image on the page). width and height let the browser reserve the correct space before the image loads, which is what actually prevents CLS. sizes stops the browser from downloading a desktop-sized image on a phone.

Fonts (CLS)

Late-loading web fonts are one of the most common causes of layout shift, because the page renders with a fallback font and then jumps when the real font swaps in. next/font self-hosts and preloads fonts at build time, removing the external request entirely:

code
import { Inter } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  preload: true,
});

Third-party scripts (INP and LCP)

Analytics tags, chat widgets, and ad scripts are frequent culprits behind both slow LCP and poor INP, because they compete with your own code for the main thread. next/script gives you explicit control over when each script loads:

  • beforeInteractive: loads before any page JavaScript, for scripts the page truly can’t function without.
  • afterInteractive (default): loads right after the page becomes interactive, good for most analytics.
  • lazyOnload: loads during idle time, best for chat widgets and anything non-critical.
code
import Script from "next/script";

<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />

As a default rule of thumb: if a script isn’t needed for the page to render or for the user’s first interaction, it should not be beforeInteractive.

React Server Components (INP)

If you’re on the App Router, components are Server Components by default, meaning they render on the server and ship zero JavaScript to the client unless you explicitly mark them with "use client". Every component you can legitimately keep as a Server Component is JavaScript your users never have to parse, execute, or hydrate, which is a direct, structural win for INP rather than a runtime optimization you have to tune.

Fixing INP Specifically

INP is worth its own section because it’s newer, less intuitive than LCP or CLS, and consistently the metric teams struggle with most, since it isn’t fixed by a single setting but by how your JavaScript is structured around user interactions.

The core idea: when a user clicks, types, or taps, the browser needs to run your handler and paint the next frame quickly. If that handler does expensive synchronous work, the interaction feels stuck. Two tools help:

useTransition / startTransition tells React an update isn’t urgent, so it won’t block more urgent updates like the next keystroke. It’s commonly used for filtering, searching, or any state update triggered by typing:

code
import { useState, useTransition } from "react";

function SearchResults() {
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value); // urgent: keep the input responsive
    startTransition(() => {
      filterResults(value); // non-urgent: can be deferred
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
    </>
  );
}

One caveat worth knowing before you reach for this everywhere: startTransition deprioritizes React state updates, but it does not automatically break up a long synchronous function into smaller chunks.

If filterResults itself takes 400ms to run, wrapping it in startTransition won’t make that 400ms disappear, it just makes React less eager to run it ahead of more urgent work.

For a genuinely long-running function, you need to yield control back to the browser mid-task using the Scheduler API’s yield() method (with a fallback for browsers that don’t support it yet):

code
async function filterResults(query) {
  const items = getAllItems();
  const results = [];

  for (let i = 0; i < items.length; i++) {
    results.push(matchItem(items[i], query));

    // Yield every 50 items so the browser can handle input in between
    if (i % 50 === 0) {
      if (globalThis.scheduler?.yield) {
        await scheduler.yield();
      } else {
        await new Promise((r) => setTimeout(r, 0));
      }
    }
  }

  renderResults(results);
}

Beyond these two patterns, the same rules from earlier compound directly into INP: fewer client components (React Server Components), smaller JavaScript bundles (code-splitting), and fewer third-party scripts competing for the main thread (deliberate next/script strategies) are the structural fixes. startTransition and scheduler.yield() are what you reach for once those are already in place and a specific interaction is still slow.

Optimize React Apps Without Losing the SSR Benefits

The answer to how to optimize React apps shouldn’t be limited to isolated technical tricks. In server-rendered React apps, it’s critical to lower the number of operations the browser performs after receiving the page. The less work remains on the client side, the faster the user can interact with the interface.

Use caching where it is appropriate

Caching can greatly reduce server response time as well as ease the load on your infrastructure. In modern SSR frameworks, you can cache fully formed pages as well as the results of individual requests. If data is updated every few minutes or even less frequently, there’s no need to generate the same HTML for every new visitor.

Caching should still be applied selectively, though. For personalized pages or real-time information, keeping data up-to-date is more valuable than maximizing delivery speed.

Send less data upfront

If a page receives large JSON responses, a lot of time is spent processing them in the browser. It’s much more effective to transmit only the info needed for initial rendering.

The rest can be loaded after the user starts to interact with the page or goes to the relevant section. This reduces the load on the network and the processor without sacrificing functionality.

Streaming SSR for faster content rendering

The latest versions of React support streaming server-side rendering, in which the browser begins to receive HTML even before the entire page has finished loading. The result is that the user sees individual parts of the interface much sooner.

This approach is especially useful for pages with a large number of independent blocks, where certain sections may be ready faster than others. Instead of waiting for a full response, the server gradually sends the ready HTML, and the browser renders it immediately, using the same renderToPipeableStream and Suspense pattern shown earlier in this guide.

Measuring Core Web Vitals in a React App

Optimization without measurement is guesswork, and the biggest measurement mistake teams make is treating a good Lighthouse score in DevTools as proof the app is fast.

It isn’t, because Lighthouse produces lab data: a single run, on a single machine, usually a fast one. Google grades your site on field data instead, meaning real visits from real devices on real networks, aggregated through CrUX.

A page can score 100 in Lighthouse locally and still fail Core Web Vitals in Search Console because the 25th percentile of your real mobile traffic is on a three-year-old phone with a weak connection.

Use both, but for different jobs:

  • Lighthouse / Chrome DevTools: fast, local, great for catching regressions during development, before they ship.
  • PageSpeed Insights: shows both lab data and field data for a URL side by side, useful for sanity-checking whether your local numbers match reality.
  • Search Console’s Core Web Vitals report: shows your actual CrUX pass/fail status by URL group, the closest thing to “what Google sees.”
  • The web-vitals library: for real user monitoring (RUM) inside your own app, so you’re not waiting weeks for CrUX data to catch up after a fix ships.

A minimal RUM setup that reports each metric to your own analytics endpoint looks like this:

code
// reportWebVitals.js
import { onCLS, onINP, onLCP } from "web-vitals";

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,       // "LCP" | "INP" | "CLS"
    value: metric.value,
    rating: metric.rating,   // "good" | "needs-improvement" | "poor"
    id: metric.id,
  });

  // sendBeacon survives page unload, unlike a regular fetch
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", { body, method: "POST", keepalive: true });
  }
}

onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);

Wire this into your root layout once, and you’ll have your own field data within days instead of waiting on the 28-day CrUX rolling window to confirm a fix actually worked.

Common Mistakes That Hinder Improvements to Core Web Vitals

  • JavaScript bundles that are too large: run a bundle analyzer and code-split anything not needed on first paint.
  • Repeated requests for the same data: cache at the request or page level instead of refetching per component.
  • Loading all components at once, regardless of whether the user needs them: use React.lazy and route-level splitting.
  • Failure to reserve space for images, ad units, or late-loading content: always set explicit width and height, or a CSS aspect-ratio.
  • Excessive re-rendering of components after hydration: memoize expensive components and check for unstable prop references.
  • Running resource-intensive computations on the browser’s main thread: move them server-side, or yield with scheduler.yield().
  • Using third-party scripts without assessing their impact on performance: audit each one and load it with the least aggressive next/script strategy that still works.

Core Web Vitals metrics can change after every feature update. A new module, library, or even a single widget can affect the speed of the entire app. That’s why performance testing should be part of the regular development process, not just the final step before release.

Quick Action Checklist

  • Set the LCP image’s priority prop and give every image explicit width/height
  • Load fonts through next/font with display: "swap"
  • Wrap slow, non-critical UI in its own Suspense boundary, not just the route-level one
  • Code-split anything not needed on first paint with React.lazy
  • Run a bundle analyzer before guessing what to split
  • Load every third-party script with the least aggressive next/script strategy that still works
  • Keep components as Server Components by default; add "use client" only when you need interactivity
  • Reach for useTransition for non-urgent state updates, and scheduler.yield() for genuinely long synchronous work
  • Ship a web-vitals RUM snippet so you have your own field data, not just CrUX’s 28-day lag
  • Re-check Search Console’s Core Web Vitals report after every major release, not just at launch

Conclusion

Optimizing Core Web Vitals in server-side rendered React apps is not simply a matter of choosing SSR or a specific framework. High performance is achieved through a combination of solutions:

  1. Well-thought-out architecture
  2. Efficient data handling
  3. Controlled hydration
  4. Moderate use of JavaScript
  5. Regular performance analysis

The best results are achieved by projects that consider performance from the very beginning, rather than after the first issues arise. This approach helps ensure stable app performance across different devices, faster user interaction with content, and a better overall user experience.

Himanshu Tyagi

About Himanshu Tyagi

At CodeItBro, I help professionals, marketers, and aspiring technologists bridge the gap between curiosity and confidence in coding and automation. With a dedication to clarity and impact, my work focuses on turning beginner hesitation into actionable results. From clear tutorials on Python and AI tools to practical insights for working with modern stacks, I publish genuine learning experiences that empower you to deploy real solutions—without getting lost in jargon. Join me as we build a smarter tech-muscle together.

Comments

Questions, corrections, and useful tips are welcome. Comments are reviewed before publication.

Loading comments...

Comments are stored and moderated using Cusdis Cloud. Email is optional. Privacy Policy

Free Online Tools

Try These Related Tools

Free browser-based tools that complement what you just read — no sign-up required.

Keep Reading

Related Posts

Explore practical guides and fresh insights that complement this article.

7 Best Online Agentic AI Courses with Certification in 2026
Programming

7 Best Online Agentic AI Courses with Certification in 2026

Artificial Intelligence is evolving rapidly, and one of the biggest advancements in the field is agentic AI. Unlike traditional generative AI models that simply respond to prompts, agentic AI systems can plan, reason, use external tools, and execute multi-step tasks with minimal human intervention. These capabilities are changing industries ranging from software development and customer […]

Proxy Configuration Guide: Common Mistakes and How to Fix Them
Programming

Proxy Configuration Guide: Common Mistakes and How to Fix Them

Proxy configuration involves setting the proxy protocol, server address, port, authentication details, routing rules, and session behavior used by an application or device. A proxy can connect successfully and still return the wrong data. The exit IP may be in the wrong country. A reused cookie can mix two sessions. Aggressive rotation can break a […]