Storing visual baselines in Git LFS

This page is part of the baseline management section of the visual regression guide, which covers where reference images live and how they change.

The specific problem: the snapshot directory has grown past a few hundred megabytes, clones take minutes, and every baseline update adds permanently to the repository’s history.

Problem statement

Committed baselines are the right default: the reference lives with the code, a change shows up in the pull request, and a rollback is a revert. The cost is that Git stores every version of every binary forever, and PNGs do not delta-compress — a one-pixel change writes a whole new object.

The symptom is a repository whose .git directory is several times the size of its working tree, a clone measured in minutes, and CI jobs spending longer fetching than testing.

Root cause

Git is content-addressed and immutable. Updating a baseline does not replace the old image; it adds a new object and leaves the previous one reachable from history. A suite of five hundred baselines updated forty times over two years holds twenty thousand images, of which five hundred are current.

The multipliers make this arrive faster than teams expect. Adding viewport widths and browser engines multiplies the count, and each of those baselines is then updated on the same cadence as the rest.

Where the megabytes actually come from Horizontal bars. Forty-two components at one width take 3.4 megabytes. Five widths take 17 megabytes. Three engines on top take 51 megabytes, highlighted as a typical working-tree size. After forty rounds of baseline updates the repository history holds 380 megabytes, because every superseded image is still an object. Working tree, then accumulated history 42 components, 1 width 3.4 MB + 5 widths 17 MB + 3 engines 51 MB after 40 baseline updates 380 MB in history

Minimal reproduction

git count-objects -vH
# size-pack: 380.42 MiB      <- most of it is superseded PNGs
git clone --depth=1# a shallow clone helps CI and not developers

Step-by-step fix

1. Confirm the images are the problem

# the largest objects in history, by size
git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
  | awk '$1=="blob" {print $3, $4}' | sort -rn | head -20

What this does: names the actual offenders. If the list is dominated by a vendored dependency rather than by baselines, LFS is not the fix.

2. Install LFS and declare the pattern

git lfs install
git lfs track "**/__screenshots__/**/*.png"
git add .gitattributes
git commit -m "Track visual baselines with Git LFS"

What this does: from this commit forward, matching files are stored as pointers and the bytes live in LFS storage.

3. Migrate the existing history

# rewrites history — coordinate with everyone before running it
git lfs migrate import --include="**/__screenshots__/**/*.png" --everything
git push --force-with-lease origin main

What this does: converts past baselines to pointers, which is what actually shrinks the clone. Tracking without migrating leaves the historical objects in place and the clone unchanged.

4. Fetch in CI, cheaply

      - uses: actions/checkout@v4
        with:
          lfs: true                # fetch the pointers' contents

      - name: Cache LFS objects
        uses: actions/cache@v4
        with:
          path: .git/lfs
          key: lfs-${{ hashFiles('**/__screenshots__/**') }}

What this does: pulls the baselines the visual job needs and caches them, so repeat runs do not re-download the set.

5. Skip the fetch in jobs that do not need images

      - uses: actions/checkout@v4
        with:
          lfs: false               # the unit-test job never reads a baseline

What this does: keeps LFS bandwidth proportional to the jobs that actually compare images — which matters, because bandwidth is the metered resource.

Verification

Clone cost before and after migrating to LFS A comparison of clone performance. Before migration a clone transfers 380 megabytes in one minute forty-two seconds. After running git lfs migrate over the screenshots directory, rewriting two hundred and fourteen commits and one thousand three hundred and forty objects, a clone transfers 22 megabytes in nine seconds, with an on-demand LFS fetch of 51 megabytes taking fourteen seconds. History is preserved with pointers in every commit. git lfs migrate + clone comparison before: git clone 380 MB 1m 42s git lfs migrate import --include='**/__screenshots__/**/*.png' rewrote 214 commits, 1,340 objects after: git clone 22 MB 9 s + lfs fetch 51 MB 14 s (on demand) history preserved, pointers in every commit

Two checks. A fresh clone should transfer megabytes rather than hundreds of megabytes. And git lfs ls-files | wc -l should match the number of baseline images, confirming nothing was missed by the pattern.

Edge cases and caveats

Three storage models compared Six rows. Committed PNGs make clone cost grow with history, show the baseline in the pull request, work offline, need no CI setup, are not billed for bandwidth, and suit repositories below roughly two hundred megabytes. Git LFS clones pointers only, still shows the change through the pointer, does not work offline, needs an install and fetch step in CI, bills bandwidth by plan, and works at any size. A hosted service adds no clone cost, does not show the baseline in the diff, needs credentials, bills per snapshot, and works at any size. Committed PNGs Git LFS Hosted service Clone cost grows with history pointers only nothing Baseline in the PR diff yes yes, via pointer no Works offline yes no no CI setup none lfs install + fetch credentials Bandwidth billed no yes, per plan yes, per snapshot Good below ~200 MB any size any size
  • History rewriting. migrate import changes every commit hash. Everyone must re-clone, and open pull requests need rebasing. Do it deliberately, announced, and not on a Friday.

  • Bandwidth quotas. LFS bills for transfer, and a CI matrix that fetches the full baseline set in every job can exhaust a quota quickly. Cache the objects and fetch only where needed.

  • Reviewing a pointer. A pull request shows the pointer’s hash changing rather than the image. Most hosting platforms still render the image in the rich diff, but a plain git diff will not — which is worth knowing before someone concludes the baseline did not change.

Keeping the baseline set from growing in the first place

Migrating to LFS solves a storage problem and leaves the underlying question untouched: whether the suite needed that many baselines. It is worth asking before and after, because a smaller set is cheaper in every dimension — storage, runtime, and review attention.

Are any of these baselines duplicates? A component snapshotted alone and again inside a composite records the same rendering twice, and a regression in it produces two diffs. Keeping the standalone capture and dropping the nested one usually loses nothing.

Do all the widths differ? A component that does not reflow produces five identical images at five widths. Comparing the files directly — md5sum across a width’s directory against another’s — finds these quickly, and each pair found is a baseline that can be deleted along with the runtime that produced it.

Are the images larger than they need to be? Capturing the story root rather than the page, and pinning the device scale factor to 1, typically cuts each file by an order of magnitude compared with a full-page retina capture. That is a change to the capture rather than the storage, and it compounds across every future update.

Is anything being captured that nobody reviews? A baseline that has never produced a diff anyone acted on is paying storage and review cost for no return. This is harder to measure than the others and worth doing once: correlate the diff history against the components, and the residue of never-useful baselines is usually obvious.

Doing this audit first often removes the need for LFS entirely, and doing it afterwards keeps the LFS bandwidth bill proportionate.

FAQ

At what size is LFS worth the complexity?

Roughly when the repository passes a couple of hundred megabytes, or when a clone becomes slow enough that people start using shallow clones to avoid it. Below that, plain committed PNGs are simpler in every respect and the added CI configuration is not repaying anything. The trigger to watch is the growth rate rather than the current size: a suite adding widths and engines will cross the threshold far sooner than its current figure suggests.

Does LFS change how baselines are reviewed?

Not on the platforms most teams use — the rich diff still renders both images side by side, because the platform resolves the pointer. What changes is the local experience: a developer who has not run git lfs pull has pointer files rather than images, and a visual run against those produces confusing failures. Making the fetch part of the standard setup script avoids it.

Is a hosted service a better answer than LFS?

It solves the storage problem more completely and gives up something real: the baseline is no longer in your version control, so reconstructing what a component looked like at a given commit means asking the service, and the approval trail lives outside the pull request. LFS keeps both properties while removing the clone cost, which is why it is usually the better step for a team that already commits its baselines.

Can I move only some baselines into LFS?

Yes — the .gitattributes pattern is arbitrary, so a directory of large full-page captures can go to LFS while small component baselines stay committed normally. It is a reasonable middle ground when one part of the suite dominates the size, and it keeps the everyday review experience unchanged for the images people look at most.