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.
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
# 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
-
Web fonts. A story asserting on text position can race font loading, which is slower in CI. Await
document.fonts.readyinpreVisit, 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 intodocument.body. The query never matches and the story times out — scope todocument.bodyfor 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.
Related
- Test-Runner Automation — the parent section on running stories as an unattended suite
- Interaction Testing — the play functions whose async guards decide whether a story times out
- Async Behaviour and Timers — the underlying rules for waiting on state instead of guessing
- Running the Storybook Test-Runner in GitHub Actions — the CI job these timeouts happen inside