A deep dive into the caching, image pipeline, and bundle-splitting strategies behind a real-world Gatsby performance win.
A few years ago I inherited a Gatsby site that took 27 seconds to build a single page in development and lost a noticeable amount of LCP in production. Over six weeks of focused work, our team brought build times down by 40% and shaved 1.2s off LCP. Here's the actual playbook.
Step 1: Measure first, optimize second
Before touching code, we instrumented every layer. Gatsby has the perfect built-in tools — just nobody uses them.
GATSBY_PROFILE=true gatsby build --profile
# Generates a flamegraph at .cache/profiles/build-XX.json
# Open in Chrome DevTools → Performance tab
# Plus the runtime bundle:
GATSBY_PROFILE_BUILD_VITALS=true gatsby buildThe flamegraph revealed our actual bottleneck: 60% of build time was Sharp processing 4MB hero images that ultimately rendered at 800px. We were optimizing the wrong things.
Step 2: Fix the image pipeline
gatsby-plugin-image with the right configuration was the single biggest win. The old site used <img> tags pointing at raw originals. We migrated to GatsbyImage with proper responsive variants.
// gatsby-config.js
plugins: [{
resolve: 'gatsby-plugin-sharp',
options: {
defaults: {
formats: ['auto', 'webp', 'avif'],
placeholder: 'blurred',
quality: 70, // 70 looks identical to 90 to humans
breakpoints: [400, 768, 1024], // matches our 3 layouts only
backgroundColor: 'transparent',
},
},
}]- AVIF + WebP fallbacks cut hero payload by 64%
- Blurred placeholders eliminated LCP shift
- Constraining breakpoints to the 3 we actually used dropped build artifacts by 18%
Step 3: Aggressive build caching
Gatsby's incremental builds work great — when you let them. The default GitHub Actions cache config wasn't restoring `.cache/` correctly, so every CI build was cold.
- uses: actions/cache@v4
with:
path: |
.cache
public
node_modules/.cache
key: gatsby-${{ hashFiles('**/yarn.lock') }}-${{ github.sha }}
restore-keys: |
gatsby-${{ hashFiles('**/yarn.lock') }}-
gatsby-Result: incremental CI builds went from 4m20s to 1m45s. Local hot reload from 27s to 8s.
Step 4: Bundle splitting that pays for itself
Audit the bundle before adding splitting. We ran webpack-bundle-analyzer and found one route imported moment.js for a single date format — a 230KB cost. Replacing it with date-fns/format saved 220KB. Often the best 'optimization' is just deleting something.
// Before — pulls all of moment
import moment from 'moment'
const pretty = moment(d).format('MMM DD, YYYY')
// After — tree-shakable
import { format } from 'date-fns'
const pretty = format(d, 'MMM dd, yyyy')
// 230KB → 4KBStep 5: Smarter GraphQL queries
Gatsby's GraphQL layer is powerful but easy to abuse. Page queries that pull every field of every node make the data layer crawl. Two patterns helped:
- Page query → request ONLY the fields the page renders. Use fragments to share.
- Static query → for components that need data on every page (nav, footer), use a single static query, not a page query × N.
The 40% number
Combining all five steps:
- Production build: 6m12s → 3m41s (–40.6%)
- Largest Contentful Paint (p75): 2.8s → 1.6s (–43%)
- Lighthouse Performance score: 71 → 96
- Total bundle (gzipped): 412KB → 248KB (–40%)
Performance work isn't a sprint. It's a slow, measured grind where each 5% win compounds. Profile first. Always.