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.
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
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
-
History rewriting.
migrate importchanges 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 diffwill 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.
Related
- Baseline Management — the parent section on storing and updating reference images
- Updating Visual Baselines Without Approving Regressions — reviewing a baseline change before it becomes the reference
- Responsive Viewport Testing — the axis that multiplies how many baselines you store
- Cross-Browser Matrix — the other multiplier on baseline count