Debugging Storybook test-runner timeouts in CI

This page is part of the test-runner automation section of the Storybook isolation workflows guide, which covers running stories unattended in CI.

The specific problem: stories that pass locally in under a second time out in CI with Exceeded timeout of 15000 ms. Raising the timeout makes the job slower and the failures no less frequent.

Problem statement

A test-runner timeout reports a duration, which is misleading — it says how long the runner waited, not what it was waiting for. The instinct is to treat it as “CI is slow”, and the usual response is a larger timeout, which converts a fifteen-second failure into a thirty-second one.

The symptom is a job whose duration grows every time someone touches the config, while the same handful of stories keep failing.

Root cause

Almost every timeout is an awaited query that will never match. A findByText polling for text the component does not render will burn the entire timeout and then report the duration, because the runner has no way to distinguish “not yet” from “never”.

Three things make CI the place this surfaces. Requests that resolve instantly against a local dev server may not be mocked at all, so in CI they fail or hang. Animations that a developer’s prefers-reduced-motion setting disables run at full length on a runner. And workers over-subscribed against available cores make everything slow enough that a marginal query crosses the threshold.

Where the fifteen seconds actually go A timeline of a timing-out story. Navigation and render complete within half a second and the play function dispatches its click. The test then spends nearly fifteen seconds polling for an element that never matches, and the Jest timeout fires, failing the story. Almost the entire duration is the query waiting, not the page loading. navigate 0 s story iframe opens render 0.4 s component mounts play() starts 0.4 s click dispatched still polling 15 s findBy never matches Jest timeout 15 s story fails A timeout is usually a query that will never match, not a slow page

Minimal reproduction

// the query never matches: the component renders "Saved." with a full stop
export const Saves: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button', { name: /save/i }));
    await canvas.findByText('Saved');        // polls for 15 s, then fails
  },
};

Locally the story is rarely run to completion in a browser, so nobody notices. In CI it costs fifteen seconds and one red job.

Step-by-step fix

1. Read the failure before changing any setting

The runner prints the DOM at the moment of failure. That output answers the question directly: if the element is present with different text, the query is wrong; if the component is still showing a spinner, something never resolved.

# reproduce the CI conditions locally rather than guessing
npm run build-storybook && npm run test-storybook:ci -- --maxWorkers=2

What this does: runs against the same static bundle CI uses, which removes dev-server differences from the investigation.

2. Mock whatever the story fetches

// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon';

initialize({ onUnhandledRequest: 'error' });
export const loaders = [mswLoader];

What this does: makes every request resolve deterministically, and turns an unmocked endpoint into an immediate error naming the URL instead of a silent fifteen-second wait.

3. Turn off motion for the whole run

// .storybook/test-runner.ts
async preVisit(page) {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.addStyleTag({
    content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }',
  });
}

What this does: removes waiting on transitions entirely, which also removes a whole class of intermittent failure rather than merely speeding it up.

4. Match workers to the runner’s cores

test-storybook --url http://127.0.0.1:6006 --maxWorkers=2

What this does: stops browser contexts contending for CPU. Over-subscription makes every story slower, which pushes marginal queries over the timeout even when nothing is actually broken.

5. Raise the timeout only with evidence

// jest-like config the runner accepts
export default { testTimeout: 30_000 };

Do this only when the run log shows the story genuinely completing in, say, twenty seconds. A timeout raised without that evidence hides the cause and lengthens every future failure.

Verification

Worker count against per-story time Horizontal bars of average per-story duration. A warm local dev server takes 0.6 seconds. On a two-core CI runner with six workers it rises to 4.8 seconds because the browser contexts contend for CPU. Reducing to two workers on the same runner brings it to 1.1 seconds. Four workers on a four-core runner reach 0.9 seconds. Average per story, same 48-story suite Local, warm dev server 0.6 s per story CI, 2 cores, 6 workers 4.8 s — contention CI, 2 cores, 2 workers 1.1 s CI, 4 cores, 4 workers 0.9 s
# per-story timings make the outlier obvious
npm run test-storybook -- --verbose

After the fix the distribution should be tight, with no story sitting near the timeout. A single story an order of magnitude slower than its neighbours is the next one to look at, whether or not it is currently failing.

Edge cases and caveats

Diagnosing a story timeout A decision node asking what the story is waiting for. A request should be mocked with the MSW addon so it resolves deterministically. An animation should be disabled by emulating reduced motion. A font should be awaited via document.fonts.ready, or pinned in the container image. Most often the story is waiting for nothing at all and the query is simply wrong, which is the common case. Only a genuinely slow render justifies raising the timeout, and then only with evidence. What is the story waiting for? Mock it MSW addon, deterministic a request Disable motion emulateMedia reduce an animation Wait for fonts.ready or pin them in the image a font Fix the query the common case nothing — wrong query Raise the timeout last resort, with evidence a genuinely slow render
  • Web fonts. A story asserting on text position can race font loading, which is slower in CI. Await document.fonts.ready in preVisit, or pin the fonts in the container image so they load from disk.

  • Stories that open a portal. within(canvasElement) cannot see a dialog rendered into document.body. The query never matches and the story times out — scope to document.body for portalled content.

  • Timeouts that only affect the first story. That is usually cold start rather than the story: the first browser context pays the launch cost. Confirm by reordering; if the timeout follows position rather than story, it is start-up.

FAQ

Should I retry flaky stories instead of fixing them?

No. A story that needs a retry is non-deterministic, and every tool that reads stories inherits that — including visual baselines, which will capture whichever state the story happened to reach. Retries also mask a regression that has started failing intermittently, which is precisely when it is cheapest to fix.

Why is the first run in CI always slower?

Cold start: the browser binary is loaded, the page context is created, and the static bundle is read from disk for the first time. It typically costs a few seconds once, not per story. If the cost repeats per story, workers are being recycled — usually because memory pressure is forcing contexts to be torn down.

Can I get a screenshot of the moment a story timed out?

Yes, and it is the single most useful addition to a CI story run. Wrap the assertion in postVisit in a try/catch, call page.screenshot() in the catch, and rethrow. The image goes into the artifact directory and answers “what was on screen” without a local reproduction.

Does --maxWorkers=1 make the run more reliable?

It removes CPU contention, so marginal timeouts stop, and it makes the run several times longer. Treat a suite that only passes at one worker as still broken: the underlying non-determinism is still there, and it will resurface the first time a runner is busier than usual.