+1 (415) 779-8456

Fixing INP and LCP on a Gatsby Site: A Field-Data Playbook

Two of the three Core Web Vitals are things a Gatsby site is supposed to be good at by default. Static HTML on a CDN should mean a fast Largest Contentful Paint, and a build-time-rendered page should mean nothing shifts. Yet the sites we get called about are usually failing field data anyway — and since Interaction to Next Paint (INP) replaced First Input Delay as the responsiveness metric, a lot of previously "green" Gatsby sites went amber or red without a single line of their code changing.

The reason is structural. Gatsby ships fast HTML and then hydrates the entire page with React, so the cost of interactivity arrives right after the content does. If a user taps something during hydration, or the page carries a heavy third-party script, INP is what suffers. This tutorial is the sequence we actually work through on a client site, in order, with the measurement steps that stop you from optimising the wrong thing.

0. Measure field data before you touch anything

Lighthouse in your browser is a lab test on your laptop over your fibre connection. INP is a field metric — it is the 75th percentile of real interactions on real devices, and you cannot infer it from a lab run. Start with real data:

  • Chrome UX Report (CrUX), via PageSpeed Insights, gives you the last 28 days of field data for the origin and, if it has enough traffic, per URL.
  • Your own RUM, which for a Gatsby site is about ten lines of code.

Collect it yourself so you get per-page and per-interaction attribution:

npm install web-vitals
// gatsby-browser.js
export const onClientEntry = () => {
  import('web-vitals/attribution').then(({ onINP, onLCP, onCLS }) => {
    const send = (metric) => {
      const body = JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating,
        path: window.location.pathname,
        // attribution is the part that makes this actionable
        target: metric.attribution?.interactionTarget,
        loadState: metric.attribution?.loadState,
        element: metric.attribution?.element,
        subpart: metric.attribution?.largestShiftTarget,
      });
      // sendBeacon survives the page unload; fetch often does not
      navigator.sendBeacon?.('/api/vitals', body);
    };
    onINP(send, { reportAllChanges: false });
    onLCP(send);
    onCLS(send);
  });
};

The attribution build is the whole point. interactionTarget tells you the CSS selector of the element whose tap was slow, and loadState tells you whether the interaction happened before hydration finished — which is the single most common cause of bad INP on a Gatsby site.

If you have no analytics endpoint, log to the console in a staging build and click around on a throttled mobile profile. Even that is more honest than a desktop Lighthouse score.

1. Reduce what hydrates

Gatsby has no partial hydration story in v5, so the lever you have is making the hydrated tree smaller and its work cheaper.

Find the expensive components. Build with a bundle report:

npm install --save-dev webpack-bundle-analyzer
// gatsby-node.js
exports.onCreateWebpackConfig = ({ stage, actions, plugins }) => {
  if (stage === 'build-javascript' && process.env.ANALYSE) {
    const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
    actions.setWebpackConfig({
      plugins: [new BundleAnalyzerPlugin({ analyzerMode: 'static', openAnalyzer: false })],
    });
  }
};
ANALYSE=1 npx gatsby build

Typical offenders we find, in rough order of frequency: a full icon library imported as a namespace (import * as Icons from 'react-icons'), moment plus all locales, a charting library on a page with no chart above the fold, a carousel used on the home page only but imported into the shared layout, and two versions of the same date library pulled in by different plugins.

Defer components that are not needed for first paint. Gatsby supports React.lazy inside a <Suspense> boundary in client-side code, but for anything below the fold the more robust pattern on a static site is to render the markup at build time and only attach behaviour when it is visible:

// src/components/lazy-mount.js
import React, { useEffect, useRef, useState } from 'react';

export default function LazyMount({ children, fallback = null, rootMargin = '200px' }) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el || visible) return;
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setVisible(true);
          io.disconnect();
        }
      },
      { rootMargin },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [visible, rootMargin]);

  return <div ref={ref}>{visible ? children : fallback}</div>;
}

Wrap the comment widget, the map, the "related posts" carousel. The saving is not in the download — it is in the main-thread time spent constructing and hydrating components nobody scrolled to.

Break up long tasks. Any single task over 50 ms blocks the main thread, and if it overlaps an interaction it lands directly in your INP. For work you cannot delete, yield:

// src/utils/yield.js
export const yieldToMain = () =>
  'scheduler' in window && 'yield' in window.scheduler
    ? window.scheduler.yield()
    : new Promise((resolve) => setTimeout(resolve, 0));

export async function processInChunks(items, fn, chunkSize = 50) {
  for (let i = 0; i < items.length; i += chunkSize) {
    items.slice(i, i + chunkSize).forEach(fn);
    await yieldToMain();
  }
}

The classic Gatsby version of this problem is client-side search: a 2 MB Lunr or FlexSearch index built synchronously on mount. Build the index in a Web Worker, or fetch a prebuilt index and query it off the main thread.

2. Get third-party scripts off the critical path

On most sites we audit, the majority of INP-relevant main-thread time is not the site's own code. Use Gatsby's built-in Script component rather than dropping tags into Head or a useEffect:

import { Script, ScriptStrategy } from 'gatsby';

// Loads after hydration — correct default for analytics
<Script src="https://example.com/analytics.js" strategy={ScriptStrategy.postHydrate} />

// Loads when the main thread is idle — correct for chat widgets, heatmaps, A/B tools
<Script src="https://widget.example.com/chat.js" strategy={ScriptStrategy.idle} />

// Only when you truly need it before hydration (rare: consent managers, anti-flicker)
<Script src="https://consent.example.com/cmp.js" strategy={ScriptStrategy.offMainThread} />

Rules of thumb that have survived a lot of client arguments:

  • A chat widget belongs on idle, and ideally behind a click on your own lightweight button that then loads the real widget.
  • Tag managers are the worst offenders because their cost is invisible in your repo. Audit the container: every tag someone added in 2021 for a campaign that ended is still executing.
  • Measure the delta honestly. Comment the script out, build, run the same interaction, compare. If a vendor costs you 120 ms of INP, that is a number a marketing stakeholder can weigh against the tool's value.

3. Fix LCP properly (it is almost always an image or a font)

Gatsby's static HTML gives you a good Time to First Byte; LCP problems on top of that are nearly always the hero image or the headline font.

Use gatsby-plugin-image correctly. The plugin is only fast if you tell it which image is the important one:

import { GatsbyImage, getImage } from 'gatsby-plugin-image';

const Hero = ({ data }) => {
  const image = getImage(data.hero);
  return (
    <GatsbyImage
      image={image}
      alt="..."
      loading="eager"          // do not lazy-load the LCP element
      fetchPriority="high"     // tell the browser this one first
    />
  );
};
{
  hero: file(relativePath: { eq: "hero.jpg" }) {
    childImageSharp {
      gatsbyImageData(
        layout: FULL_WIDTH
        placeholder: NONE          # a blurred base64 placeholder inflates the HTML
        formats: [AVIF, WEBP, AUTO]
        breakpoints: [750, 1080, 1366, 1920]
        quality: 72
      )
    }
  }
}

Two details that matter more than they should: loading="eager" with fetchPriority="high" on the LCP image alone (setting it everywhere is the same as setting it nowhere), and placeholder: NONE or DOMINANT_COLOR for the hero — BLURRED embeds a data URI in the HTML, which delays the real image on slow connections.

Preload the font, and never let it block text.

export const Head = () => (
  <>
    <link
      rel="preload"
      href="/fonts/inter-var-latin.woff2"
      as="font"
      type="font/woff2"
      crossOrigin="anonymous"
    />
  </>
);
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var-latin.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;   /* text paints immediately in the fallback */
  font-style: normal;
}

Self-host. A fonts.googleapis.com reference costs a DNS lookup, a TLS handshake, and a CSS round trip before the font request even starts. Subset to the character ranges you use; a variable font subset for Latin is usually 20-40 KB.

Then check CLS. Swapping fonts and mounting deferred components are both ways to reintroduce layout shift. Match fallback metrics with size-adjust, and always reserve space for anything lazy-mounted:

@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
}

4. Verify with a repeatable lab test, then wait for the field

Lock in the wins with a scripted Lighthouse run so a future PR cannot silently undo them:

npm install --save-dev lighthouse
npx gatsby build && npx gatsby serve --port 9000 &
npx lighthouse http://localhost:9000/ \
  --preset=desktop=false \
  --throttling-method=simulate \
  --only-categories=performance \
  --output=json --output-path=./lh-home.json --chrome-flags="--headless"
node -e "
const r = require('./lh-home.json');
const a = r.audits;
const m = {
  LCP: a['largest-contentful-paint'].numericValue,
  TBT: a['total-blocking-time'].numericValue,
  CLS: a['cumulative-layout-shift'].numericValue,
};
console.table(m);
const fail = m.LCP > 2500 || m.TBT > 200 || m.CLS > 0.1;
process.exit(fail ? 1 : 0);
"

Total Blocking Time is your lab proxy for INP — it is not the same metric, but a TBT regression reliably predicts an INP regression. Run this in CI on the pages that matter (home, top landing page, a template-driven detail page) and fail the build on a regression.

Then be patient. CrUX is a 28-day rolling window, so a fix shipped today shows up in field data over the following month. Judge the change with your own RUM, where you can see it within days, and use CrUX to confirm.

The order that matters

If you only do four things on a slow Gatsby site: collect field data with attribution, move third-party scripts to idle, set fetchPriority="high" on the real LCP image, and stop shipping code for components below the fold. That sequence fixes most of what we see, and it costs no framework migration.

If your Gatsby site is failing Core Web Vitals and you would rather have someone measure it properly than guess, get in touch — a performance audit is usually a few days of work with a written before/after.