Most of the Gatsby sites we get called into have the same slowest step in their build, and it is not GraphQL. It is images. gatsby-plugin-sharp resizing a few thousand source files into four widths and three formats each is arithmetic you cannot argue with, and while Gatsby Cloud existed you could largely ignore it: Image CDN deferred the work to a hosted service and your build just emitted URLs. Gatsby Cloud shut down, Image CDN went with it, and the processing came home to whatever CI runner you moved to.
This tutorial is the setup we land on for image-heavy Gatsby 5 sites now. It covers making local processing survivable, adding AVIF without doubling build time, keeping the cache warm in CI, and the point at which you should stop processing images in the build at all.
First, find out what images are actually costing you
Before tuning anything, measure. Gatsby's build already tells you, if you ask:
GATSBY_CPU_COUNT=logical_cores npx gatsby build --verbose 2>&1 | tee build.log
grep -E "Generating image thumbnails|Processing images" build.log
And count the output:
find public/static -type f \( -name '*.jpg' -o -name '*.webp' -o -name '*.avif' -o -name '*.png' \) | wc -l
du -sh public/static
du -sh .cache
A site with 800 source images, four breakpoints and three formats is 9,600 derivative files. That is the number that determines your build time, not the source count. Write both numbers down; every change below should move one of them.
Also check what is being processed needlessly. The single most common finding: 6000×4000 pixel originals straight out of a camera or a stock library, being resized to a 720px-wide blog column.
# requires imagemagick; useful as a one-off audit
find src/images content -type f \( -name '*.jpg' -o -name '*.png' \) \
-exec identify -format '%w %h %f\n' {} \; 2>/dev/null | sort -rn | head -30
Downsizing originals to a sane maximum (2400px on the long edge is plenty for a 2x retina hero) before they enter the repo cuts sharp's decode cost more than any plugin option will.
Step 1: get the gatsby-plugin-image query right
Half of "images are slow" is over-generation. gatsby-plugin-image's defaults are generous, and a lot of codebases still carry v2-era fluid/fixed fragments that were migrated mechanically.
The two layouts worth understanding:
CONSTRAINED— renders at up towidth, scales down responsively. Correct for almost everything in a content column.FULL_WIDTH— generates a wide ladder of breakpoints for edge-to-edge heroes. Expensive. Use it for the hero and nothing else.FIXED— one size, two densities. Correct for logos, avatars, icons.
Be explicit about breakpoints instead of accepting the default ladder:
{
hero: file(relativePath: { eq: "hero.jpg" }) {
childImageSharp {
gatsbyImageData(
layout: FULL_WIDTH
breakpoints: [750, 1080, 1440, 1920]
formats: [AUTO, WEBP, AVIF]
placeholder: DOMINANT_COLOR
quality: 72
)
}
}
thumb: file(relativePath: { eq: "author.jpg" }) {
childImageSharp {
gatsbyImageData(layout: FIXED, width: 96, height: 96, formats: [AUTO, WEBP], placeholder: NONE)
}
}
}
Things that matter here:
placeholder.BLURREDgenerates an extra tiny image and base64-inlines it into your HTML and page-data JSON, which inflates both.DOMINANT_COLORextracts one colour — nearly free, and visually fine for photographs. For small images,NONE.quality: 72. The default of 50 is aggressive for AVIF and conservative for JPEG. 70–75 is the range where most people stop being able to tell, and file sizes are still well under the originals.- Avoid
PNGinformatsunless you need transparency. It is the largest output and the slowest to encode.
Set defaults once so individual queries stay short:
// gatsby-config.js
module.exports = {
plugins: [
{
resolve: 'gatsby-plugin-sharp',
options: {
defaults: {
formats: ['auto', 'webp'],
placeholder: 'dominantColor',
quality: 72,
breakpoints: [750, 1080, 1440, 1920],
backgroundColor: 'transparent',
},
failOn: 'none',
},
},
'gatsby-transformer-sharp',
],
};
failOn: 'none' deserves a note. It means a single corrupt or zero-byte image will not abort a production build. That is usually what you want in CI, but log it — a silently missing hero is a bug, just a less disruptive one than a red build.
Step 2: add AVIF deliberately, not globally
AVIF is genuinely smaller than WebP at equivalent perceived quality, often 20–30% on photographic content, and it is supported across current browsers. It is also the most expensive thing in your build: encoding is an order of magnitude slower than WebP.
So do not put AVIF in your global defaults. Put it on the images that actually affect Largest Contentful Paint — the hero, the top of the article, the product shot above the fold — and leave the long tail on WebP with a JPEG fallback. Ten AVIF images will win you real LCP milliseconds; nine hundred of them will cost you twenty minutes of CI and win you nothing a user notices.
Measure the trade before committing:
time npx gatsby build # with AVIF in defaults
# then remove AVIF from defaults, clear cache, and:
npx gatsby clean && time npx gatsby build
Also check that browsers are picking it up. gatsby-plugin-image renders a <picture> with sources in order, so the raw HTML tells you:
grep -o 'type="image/avif"' public/index.html | wc -l
Step 3: keep sharp's cache alive between builds
This is the change that actually restores the Image-CDN-era experience: image derivatives are content-addressed and cached, so a build that reuses the cache skips processing entirely. The cache lives in .cache/ (plus the emitted files under public/static/), and most CI providers throw both away between runs.
GitHub Actions:
- uses: actions/cache@v4
with:
path: |
.cache
public
key: gatsby-build-${{ github.ref_name }}-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
gatsby-build-${{ github.ref_name }}-${{ hashFiles('package-lock.json') }}-
gatsby-build-${{ github.ref_name }}-
- run: npm ci
- run: npx gatsby build
env:
CI: true
GATSBY_CPU_COUNT: logical_cores
On Netlify, netlify-plugin-gatsby does the equivalent; on Vercel, the build cache covers .cache and public if you leave the framework preset alone. On a self-hosted runner, mount .cache and public as a persistent volume keyed by branch.
Two cautions from experience. First, a stale public/ can serve deleted pages, so make sure your deploy step uploads with delete semantics (aws s3 sync --delete, or a provider that diffs the whole directory) rather than merging into the previous deploy. Second, when a Gatsby or sharp major version changes, invalidate the cache — that is what including hashFiles('package-lock.json') in the key buys you. If a build ever behaves inexplicably, npx gatsby clean first and only then start debugging.
Step 4: parallelism and memory on the runner
sharp is CPU-bound and Gatsby will use every core you give it, which on a small runner means the OOM killer:
# 2 vCPU / 4 GB runner, image-heavy site
export GATSBY_CPU_COUNT=2
export NODE_OPTIONS="--max-old-space-size=3072"
npx gatsby build
If builds die with exit code 137, that is the kernel, not Gatsby — reduce GATSBY_CPU_COUNT, raise the runner size, or move image work off the build entirely (next section). Raising --max-old-space-size above the container's real memory limit makes the crash later and harder to read, not less likely.
One more knob: sharp ships prebuilt binaries per platform, and npm ci on a different architecture than your lockfile was generated on will either rebuild from source (slow) or fail. Pin your CI image architecture, and if you develop on Apple Silicon and deploy on x86 Linux, commit the lockfile from a build that includes both optional dependency sets — or install with --include=optional on a matching container.
Step 5: know when to stop processing images in the build
All of the above has a ceiling. Past roughly a few thousand source images, or when non-technical editors upload originals through a CMS, build-time processing is the wrong architecture — every new image means a full rebuild, and your deploy time is coupled to your media library.
The replacement is an image service that transforms on request, at the edge, and caches the result. Practical options:
- Your CMS's own pipeline. Contentful, Sanity, Storyblok and Prismic all expose transform parameters on their asset URLs. If your images already live there, this is free and requires no new vendor.
- The host's image optimizer. Netlify Image CDN and Vercel's image optimization both take a source URL and query parameters. Convenient, but check the pricing metric — usually source images or transformations per month.
- A dedicated service — Cloudinary, imgix, Cloudflare Images. More features, more configuration, and the option to keep originals in your own object storage.
What you write in Gatsby is then a plain component, not a sharp query:
// src/components/cms-image.js
const srcFor = (id, width, format) =>
`https://images.example.com/${id}?w=${width}&fm=${format}&q=72&fit=max`;
const WIDTHS = [480, 768, 1080, 1440, 1920];
export const CmsImage = ({ id, alt, sizes = '100vw', priority = false, width, height }) => (
<picture>
<source
type="image/avif"
sizes={sizes}
srcSet={WIDTHS.map((w) => `${srcFor(id, w, 'avif')} ${w}w`).join(', ')}
/>
<source
type="image/webp"
sizes={sizes}
srcSet={WIDTHS.map((w) => `${srcFor(id, w, 'webp')} ${w}w`).join(', ')}
/>
<img
src={srcFor(id, 1080, 'jpg')}
alt={alt}
width={width}
height={height}
sizes={sizes}
loading={priority ? 'eager' : 'lazy'}
fetchPriority={priority ? 'high' : 'auto'}
decoding="async"
style={{ width: '100%', height: 'auto' }}
/>
</picture>
);
The details that make this as good as gatsby-plugin-image rather than worse than it:
widthandheightare mandatory. They give the browser an aspect ratio and prevent layout shift. Store the source dimensions in your CMS query; do not guess.loading="eager"plusfetchPriority="high"on the LCP image only. Lazy-loading your hero is the single most common self-inflicted LCP regression, and it is whatgatsby-plugin-imagedoes by default unless you passloading="eager".- Honest
sizes.100vwon an image that renders in a 720px column makes the browser download the 1920px variant on a desktop. Use something like(min-width: 900px) 720px, 100vw. - A
preloadfor the hero, emitted fromHeadso it lands in the static HTML:
export const Head = ({ data }) => (
<link
rel="preload"
as="image"
href={`https://images.example.com/${data.hero.id}?w=1440&fm=webp&q=72`}
imageSrcSet={/* same srcSet string as the <source> */ undefined}
fetchPriority="high"
/>
);
A hybrid setup is completely reasonable and is what we most often ship: repo-committed images (logos, illustrations, page furniture) stay on gatsby-plugin-image where the cache works well and there is no runtime dependency; editor-uploaded media goes through the CMS or host image service so a new photo never triggers a rebuild.
A short verification checklist
Run this after any change to the image pipeline:
npx gatsby clean && time npx gatsby build # cold build time
time npx gatsby build # warm build time (should be far lower)
find public/static -type f | wc -l # derivative count
du -sh public # total payload
Then on the deployed site: load the busiest page in Chrome DevTools with the network throttled, confirm the LCP element is the image you intended, confirm it is served as AVIF or WebP at a width close to its rendered size, and confirm no image below the fold is being fetched eagerly. Lighthouse's "Properly size images" and "Serve images in modern formats" audits will name the offenders directly.
The trade you are making
Build-time processing gives you zero runtime dependencies and no per-image cost, at the price of build duration and coupling deploys to your media library. A request-time image service gives you fast builds and instant new media, at the price of a vendor in your critical path and a bill that scales with traffic. Neither is wrong. What is wrong is the default many sites drift into: a full sharp pipeline, AVIF on every image, no cache in CI, and a forty-minute deploy for a typo fix.
If you would like a look at an image pipeline that has outgrown its build — or help planning the split between build-time and request-time processing on an existing Gatsby site — get in touch.