vitest-dev / vitest-dev/vitest

Unify and simplify timeout configuration

Open
#9,751 18 comments 0 reactions 1 assignee View on GitHub

@hi-ogawa is already working on this.

Since Apr 2, 2026.

p2-nice-to-have
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:

  1. expect.element().toBeVisible() uses expect.poll.timeout, but users don't know this because expect.element feels like a browser action, not a poll.
  2. page.getByRole('button').click() uses actionTimeout, but only in Playwright — it's buried under browser.providerOptions.actionTimeout, making it provider-specific for what should be a framework-level concept.
  3. Hidden dynamic adjustment: In browser mode, both expect.element and locator actions secretly compute their timeout from testTimeout - elapsedTime - 100ms (see processTimeoutOptions in packages/browser/src/client/tester/tester-utils.ts). This fires the action timeout before testTimeout to produce better stack traces. Users are unaware this happens.
  4. Timeouts are scattered across the config: testTimeout is top-level, expect.poll.timeout is nested under expect, actionTimeout is under browser.providerOptions — there's no single place to see and reason about all timeouts.
  5. vi.waitFor / vi.waitUntil defaults 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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.