vitest-dev / vitest-dev/vitest
Unify and simplify timeout configuration
@hi-ogawa is already working on this.
Since Apr 2, 2026.
- Dominant language
- TypeScript
- Stars
- 17.1k
- Forks
- 2k
- Avg merge
- 1d 22h
- Merged PRs (30d)
- 94
Description
Clear and concise description of the problem
The text of the issue was generated by Claude Code and verified by me.
Vitest has multiple timeout configurations that interact in non-obvious ways, making it hard for users to understand which timeout controls which behavior:
| Config | Default (Node / Browser) | Governs |
|---|---|---|
testTimeout |
5s / 15s | Entire test body |
hookTimeout |
10s / 30s | Lifecycle hooks |
teardownTimeout |
10s | Process shutdown |
expect.poll.timeout |
1s | expect.poll() retries |
browser.providerOptions.actionTimeout |
0 (Playwright only) | Playwright page actions |
On top of these, vi.waitFor() and vi.waitUntil() have a hardcoded 1s default that is not configurable globally.
The core confusion:
expect.element().toBeVisible()usesexpect.poll.timeout, but users don't know this becauseexpect.elementfeels like a browser action, not a poll.page.getByRole('button').click()usesactionTimeout, but only in Playwright — it's buried underbrowser.providerOptions.actionTimeout, making it provider-specific for what should be a framework-level concept.- Hidden dynamic adjustment: In browser mode, both
expect.elementand locator actions secretly compute their timeout fromtestTimeout - elapsedTime - 100ms(seeprocessTimeoutOptionsinpackages/browser/src/client/tester/tester-utils.ts). This fires the action timeout beforetestTimeoutto produce better stack traces. Users are unaware this happens. - Timeouts are scattered across the config:
testTimeoutis top-level,expect.poll.timeoutis nested underexpect,actionTimeoutis underbrowser.providerOptions— there's no single place to see and reason about all timeouts. vi.waitFor/vi.waitUntildefaults are not configurable, so there's no way to align them with other timeouts without passing{ timeout }on every call.
Existing bug: concurrent tests break dynamic timeout adjustment
The dynamic timeout adjustment stores _currentTaskStartTime and _currentTaskTimeout directly on the singleton runner object (packages/runner/src/context.ts, lines 55–56). When concurrent tests run, each test overwrites these values when it starts, meaning:
- Test A starts at t=0 with timeout=15s → sets
_currentTaskStartTime=0,_currentTaskTimeout=15000 - Test B starts at t=1s with timeout=5s → overwrites to
_currentTaskStartTime=1000,_currentTaskTimeout=5000 - Test A's
expect.element()now computes remaining time from Test B's values → wrong timeout
Fix for Node.js: Use AsyncLocalStorage to scope _currentTaskStartTime and _currentTaskTimeout per-test. The runner already has access to Node APIs, and AsyncLocalStorage correctly propagates through await chains in concurrent tests.
Fix for Browser: In the browser, AsyncLocalStorage is not available. However, concurrent tests with async browser actions (locator interactions, expect.element) are already discouraged due to inherent flakiness — multiple tests driving the same page simultaneously leads to unpredictable results regardless of timeouts. The current singleton approach is acceptable here since browser tests run sequentially in practice. We should document this limitation explicitly and, if a concurrent browser test is detected, either:
- Skip the dynamic adjustment and fall back to the configured fixed timeout, or
- Warn that dynamic timeout adjustment is unreliable with concurrent browser tests
Suggested solution
Group all timeouts under a timeout namespace, and introduce 'auto' as a value that means "derive from remaining test time."
New config shape
export default defineConfig({
test: {
timeout: {
test: 15_000, // was: testTimeout (default: 5s node / 15s browser)
hook: 30_000, // was: hookTimeout (default: 10s node / 30s browser)
teardown: 10_000, // was: teardownTimeout (default: 10s)
action: 'auto', // NEW — browser actions, expect.element, locator ops
// or browser: 'auto'
// replaces: browser.providerOptions.actionTimeout
// 'auto' = remaining test time minus buffer
// number = fixed timeout in ms
poll: 1_000, // was: expect.poll.timeout (default: 1s)
// / 1000ms (node, since there's no test budget concept)
wait: 1_000, // NEW — default for vi.waitFor() / vi.waitUntil()
// / 1000ms (node)
},
},
})
How 'auto' works
'auto' means: "derive the timeout from the remaining test budget at the moment the operation starts."
effective_timeout = min(
startTime + timeout.test - now() - buffer,
<per-call override if provided>
)
The buffer (currently 100ms) ensures the action-level timeout fires before testTimeout, producing a meaningful error with a good stack trace instead of a generic "test timed out" message.
In Node context (no browser), 'auto' falls back to sensible fixed defaults (1s for poll/waitFor) since the "remaining test budget" concept is less useful without async browser operations.
Inheritance & override rules
timeout.test (15s)
│
├── timeout.action: 'auto' | number
│ └── Used by: locator.click(), locator.fill(), expect.element(), screenshots
│
├── timeout.poll: 'auto' | number
│ └── Used by: expect.poll()
│
├── timeout.wait: 'auto' | number
│ └── Used by: vi.waitFor(), vi.waitUntil()
│
└── buffer (300-500ms) ensures children fire before parent
Per-call overrides always win:
// Uses timeout.poll (auto-derived from remaining test time)
await expect.poll(() => count).toBe(5)
// Explicit override — ignores timeout.poll, uses 3000ms
await expect.poll(() => count, { timeout: 3000 }).toBe(5)
Backwards compatibility
The old flat names become aliases:
// These are equivalent:
{ testTimeout: 5000 }
{ timeout: { test: 5000 } }
// These are equivalent:
{ hookTimeout: 10000 }
{ timeout: { hook: 10000 } }
// These are equivalent:
{ expect: { poll: { timeout: 1000 } } }
{ timeout: { poll: 1000 } }
If both old and new are specified, the new timeout.* takes precedence and a deprecation warning is emitted.
browser.providerOptions.actionTimeout continues to work as a provider-level override but is no longer the primary way to configure action timeouts.
What changes for users
Before (current — confusing):
export default defineConfig({
test: {
testTimeout: 15_000,
hookTimeout: 30_000,
expect: {
poll: {
timeout: 5_000, // also affects expect.element... somehow
},
},
browser: {
provider: playwright({
actionTimeout: 5_000, // Playwright-only, overrides dynamic calc
}),
},
},
})
After (proposed — clear):
export default defineConfig({
test: {
timeout: {
test: 15_000,
hook: 30_000,
action: 'auto', // or 5_000 for a fixed value
poll: 'auto', // derives from remaining test time
},
},
})
Today, setting timeouts dynamically requires the verbose vi.setConfig({ testTimeout: 100 }). With the new timeout namespace this would get worse: vi.setConfig({ timeout: { action: 2000 } }).
Introduce vi.setTimeout() as a focused shorthand:
// Number = set test timeout (most common case)
vi.setTimeout(5_000)
// Object = set specific timeouts for the current test/suite
vi.setTimeout({ test: 5_000, action: 2_000, poll: 1_000 })
Signatures:
interface VitestUtils {
setTimeout(ms: number): void
setTimeout(options: {
test?: number
hook?: number
action?: number | 'auto'
poll?: number | 'auto'
wait?: number | 'auto'
}): void
}
vi.setConfig({ testTimeout }) continues to work but the new API is preferred for timeout-only changes.
Alternative
No response
Additional context
No response
Validations
- Follow our Code of Conduct
- Read the Contributing Guidelines.
- Read the docs.
- Check that there isn't already an issue that requests the same feature to avoid creating a duplicate.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.