Stabilising avatar and random images in snapshots
This page is part of the dynamic content masking section of the visual regression guide, which covers making a capture deterministic before it is compared.
The specific problem: a user list renders avatars from a CDN, seeded from user ids. Every run pulls slightly different images, one occasionally fails to load, and the story’s diff percentage swings from two to twelve.
Problem statement
An image is a large block of pixels, so a change to one contributes far more to a diff than a change to text of the same visual weight. An avatar that differs between runs will therefore dominate a component’s diff percentage, and a failed load — replacing the image with a broken-image glyph or a blank box — produces a diff large enough to look like a catastrophic regression.
The symptom is a story whose failures are almost always about the images and almost never about the component, which is the fastest way to teach a team to stop reading its diffs.
Root cause
There are two independent sources of variance, and they need different fixes. The source is remote, so what arrives depends on the network, the CDN’s cache, and whether the service is up. The selection is random, so which image is requested depends on a generated id that changes per run.
Masking the avatars removes both at the cost of never checking the avatar’s size, shape, border or fallback again — and the fallback is precisely the state most likely to break.
Minimal reproduction
// UserList.tsx — the URL is derived from the id, which the fixture generates
export function UserList({ users }: { users: User[] }) {
return (
<ul>
{users.map((u) => (
<li key={u.id}>
<img src={`https://cdn.example.com/avatars/${hash(u.id)}.png`} alt="" width={40} height={40} />
<span>{u.name}</span>
</li>
))}
</ul>
);
}
// a new id each run, so a new avatar each run
export const Loaded: Story = { args: { users: makeUsers(3) } };
Step-by-step fix
1. Fix the ids in the fixture
// fixtures/users.ts — literals, not a generator
export const threeUsers: User[] = [
{ id: 'u-0001', name: 'Ada Lovelace' },
{ id: 'u-0002', name: 'Grace Hopper' },
{ id: 'u-0003', name: 'Katherine Johnson' },
];
What this does: makes the derived URL a constant, which removes the selection half of the problem.
2. Serve the images from the repository
// .storybook/preview.ts — intercept the CDN and answer from a local file
import { http, HttpResponse } from 'msw';
import avatar1 from './fixtures/avatar-1.png';
export const parameters = {
msw: {
handlers: [
http.get('https://cdn.example.com/avatars/*', async () => {
const buf = await fetch(avatar1).then((r) => r.arrayBuffer());
return HttpResponse.arrayBuffer(buf, { headers: { 'Content-Type': 'image/png' } });
}),
],
},
};
What this does: removes the network from the capture entirely. The image is now a committed file, so it is identical on every machine and cannot fail to load.
3. Seed any generator that remains
// injecting the PRNG turns "random" into "reproducible"
export const Shuffled: Story = { args: { users: threeUsers, random: mulberry32(42) } };
What this does: keeps randomised behaviour under test while making its output a constant.
4. Give the fallback its own story
export const AvatarFailed: Story = {
parameters: {
msw: { handlers: [http.get('https://cdn.example.com/avatars/*', () => new HttpResponse(null, { status: 404 }))] },
},
};
What this does: turns the state that used to arrive by accident into a deliberate, baselined story — which is where a fallback’s spacing and initials rendering actually get checked.
5. Wait for images before capturing
await page.waitForFunction(() =>
Array.from(document.images).every((img) => img.complete && img.naturalWidth > 0));
await expect(page.locator('#storybook-root')).toHaveScreenshot('user-list.png');
What this does: removes the remaining race, where a capture lands before a local image has decoded.
Verification
Two checks confirm the fix. Repeat the run and compare the images — identical bytes mean the variance is gone. Then run with the network blocked entirely; the story should still render exactly the same, which proves nothing is reaching outside the repository at capture time.
Edge cases and caveats
-
Image size. Committing large photographs to serve as fixtures bloats the repository for no benefit. Use small, obviously-fake images at the exact dimensions the component renders.
-
CDN transforms. A URL with query parameters that resize or reformat on the fly can return a different encoding for the same source image. Pinning the parameters explicitly, rather than relying on defaults, keeps the bytes stable.
-
srcsetand responsive images. The browser picks a candidate based on the device pixel ratio, so an unpinned DPR changes which file is fetched. PindeviceScaleFactoralongside the fixtures.
Fixtures as part of the repository, not the environment
The change this page describes is smaller than it looks: it moves the definition of what a story renders from the environment into the repository. Everything else follows from that one move.
An image served from a CDN is defined by the environment — by what that service holds today, by whether the runner can reach it, and by which cache node answers. An image imported from fixtures/avatar-1.png is defined by the commit, which means two people running the suite a year apart get the same bytes, and a regression is unambiguously a code change rather than possibly an infrastructure one.
The same reasoning applies past images. A font loaded from a font service, an icon sprite fetched at runtime, a locale file pulled from a translations API — each is a piece of the rendered output whose definition lives outside your version control. Any of them can move without a commit, which makes the resulting diff unexplainable by looking at the code.
The practical test is simple and worth running once: disconnect the network and capture the suite. Whatever changes was coming from outside the repository. Some of those will be legitimate — a genuinely remote resource in a screenshot of a real environment — and the rest are fixtures waiting to be committed.
Repository-defined captures also make bisecting possible. Checking out a commit from six months ago and regenerating a baseline gives the image that commit produced, rather than that commit’s markup filled with today’s remote content — which is what makes “when did this break?” an answerable question.
FAQ
Should I mask avatars instead of committing fixtures?
Only for genuine user-uploaded content in a screenshot of a real environment. In a component library the avatar is a fixture like any other, and committing a small placeholder keeps its size, border radius, ring and alignment under comparison — all of which are things a regression can break and a mask would hide.
What about images that are decorative background art?
Same rule: if it ships with the component, it is part of what the snapshot should check. If it comes from a content system and varies by design, a mask is reasonable — but scope it to the image itself so the surrounding layout is still compared.
Does blocking the network during capture cause other failures?
It surfaces them, which is useful. A story that breaks with the network blocked was depending on something outside the repository, and that dependency would have failed in CI eventually. Running the visual job with external requests blocked is a cheap way to make that dependency visible on the day it is introduced rather than the day it breaks.
Related
- Dynamic Content Masking — the parent section on stabilising a snapshot before it is compared
- Disabling CSS Animations Before Visual Snapshots — removing the other main source of frame-to-frame variance
- Mocking Network Requests in Storybook with the MSW Addon — serving fixed images from a handler instead of the network
- Baseline Management — keeping the reference honest once captures are stable