Vitest Browser Mode runs your component tests inside a real browser, driven by Playwright or WebdriverIO, instead of jsdom’s JavaScript reimplementation of the DOM. It went stable in Vitest 4.0, which dropped the experimental tag. Use it for anything that depends on layout, focus or real CSS. Keep jsdom for pure logic, because browsers cost you start-up time.
That is the short version. The longer version is about which of your existing tests were quietly lying to you.
What does jsdom actually get wrong?
jsdom has no layout engine. Every element is zero by zero pixels, getBoundingClientRect() returns zeroes, and anything that measures the page gets nonsense back. Intersection observers, virtualised lists, sticky headers, focus traps that check whether an element is on screen: all of them either need a shim or a test that asserts something weaker than the thing you care about.
It also has no CSS cascade worth the name. A component hidden by display: none from a stylesheet is still “visible” to a jsdom query, so a test can pass against markup no user could reach.
Then there is the drift. jsdom implements the DOM by hand, so new platform features arrive late or never. If you have written anything against <dialog>, the Popover API or CSS anchor positioning, you have probably already hit the wall and reached for a polyfill in your setup file. Our write-up on anchor positioning covers a feature that jsdom cannot model at all, because there is nothing to position.
None of that makes jsdom useless. It makes it a fast approximation, and the approximation stops holding exactly where UI bugs live.
How do you set up Vitest Browser Mode?
Two packages and a browser binary. As of Vitest 4.1, the provider ships separately from the core:
npm install -D vitest @vitest/browser-playwright
npx playwright install --with-deps chromium
Then the config. The provider is a function call, not a string:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
plugins: [react()],
test: {
setupFiles: ['./vitest.setup.ts'],
browser: {
provider: playwright(),
enabled: true,
// at least one instance is required
instances: [{ browser: 'chromium' }],
},
},
})
There is a scaffold, npx vitest init browser, if you would rather not hand-write it. The Browser Mode guide lists three providers: Playwright (Chromium, Firefox, WebKit, parallel), WebdriverIO (Chrome, Firefox, Edge, Safari), and Preview, which the docs explicitly do not recommend for CI.
For React, add vitest-browser-react and import it once in your setup file so cleanup is registered.
What does a browser-mode test look like?
Locators, not queries. The big shift from Testing Library is that getByRole returns a locator you await, rather than an element you assert against synchronously:
import { expect, test } from 'vitest'
import { render } from 'vitest-browser-react'
import { Counter } from './Counter'
test('counter button increments the count', async () => {
const screen = await render(<Counter count={1} />)
await screen.getByRole('button', { name: 'Increment' }).click()
await expect.element(screen.getByText('Count is 2')).toBeVisible()
})
expect.element retries until the assertion passes or times out, which removes most of the waitFor scaffolding you have accumulated. Interactions go through userEvent from vitest/browser, with click, dblClick, tripleClick, keyboard, type, fill and dragAndDrop, and they are real browser events rather than synthesised ones.
The same module exports page for viewport control (page.viewport(width, height)), screenshots, page.frameLocator for iframes on the Playwright provider, and cdp() for a Chrome DevTools Protocol session when you need to fake offline or throttle the network.
What breaks when you move from Vitest 3?
Four things catch people, and three of them are not about the browser at all.
The provider changed shape. provider: 'playwright' as a string is gone, replaced by the object returned from playwright(), and the old @vitest/browser package is no longer what you install. Import paths moved too: @vitest/browser/context became vitest/browser, and @vitest/browser/utils went the same way. The migration guide has the rest.
workspace was renamed to projects and now lives in vitest.config.ts rather than a separate vitest.workspace.js. The pool options were consolidated: maxThreads and maxForks collapsed into maxWorkers, singleThread and singleFork became maxWorkers: 1, isolate: false, and poolOptions disappeared in favour of top-level settings.
Mocking shifted slightly. vi.restoreAllMocks now restores only spies you created by hand, not automocks, and vi.fn().getMockName() returns vi.fn() rather than spy. If you have a test that asserts on mock names, it will fail loudly, which is the good outcome.
In the browser you also cannot spy on an imported module object the way you can in Node. You need vi.mock() with the { spy: true } option instead. Native alert and confirm are blocked as well, since a thread-blocking dialog would hang the runner.
If you are weighing this against leaving Vitest altogether, our look at Node’s own test runner versus Jest covers the other end of the decision, and the Rolldown upgrade notes cover the bundler half of the same jump.
Is the visual regression testing worth turning on?
Sometimes. Vitest 4 added toMatchScreenshot, which stores references in __screenshots__ folders next to your tests, named like __screenshots__/hero.test.ts/hero-section-chromium-darwin.png:
import { page } from 'vitest/browser'
await expect(page.getByTestId('hero')).toMatchScreenshot('hero-section')
You commit those files and update them with vitest --update. There is also toBeInViewport, which uses IntersectionObserver and is far less fussy.
The catch is in the docs, in bold: visual regression tests are inherently unstable across different environments. Font rendering differs between Windows, macOS and Linux. GPU drivers, headless versus headed, and browser versions all move the pixels. The suffix in that filename is doing real work. If your laptop is macOS and your CI is Linux, you are maintaining two sets of references, or you are running the whole thing in a container so that everyone renders identically. Decide which before you write the first screenshot assertion, not after the third red build.
Vitest 4 also wired in Playwright traces, with a trace option accepting off, on, on-first-retry, on-all-retries or retain-on-failure, surfaced in reporters as annotations. That one is worth turning on straight away. Debugging a flaky CI-only failure from a trace beats debugging it from a stack trace.
What does it cost?
Start-up, mostly. Vitest has to launch the provider and the browser before anything runs, so a suite that took four seconds in jsdom will not take four seconds here. That cost is paid once per run rather than per test, which makes it tolerable in CI and annoying in watch mode on a laptop.
Browser support has a floor: Chrome 87, Edge 88, Firefox 78, Safari 15.4. Not a constraint for most teams, but worth knowing if you are testing against something embedded.
And it is not an end-to-end tool. Browser Mode renders components in an iframe against your Vite pipeline. Full user journeys across routes, auth and a real server still belong in Playwright proper.
Run both, in one config
The answer for most codebases is not a migration. It is two projects in one file, so pure logic stays fast and UI gets a real browser:
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
projects: [
{
extends: true,
test: {
name: 'unit',
environment: 'node',
include: ['src/**/*.test.ts'],
},
},
{
extends: true,
test: {
name: 'browser',
include: ['src/**/*.browser.test.tsx'],
browser: {
provider: playwright(),
enabled: true,
instances: [{ browser: 'chromium' }],
},
},
},
],
},
})
vitest --project unit gives you the fast loop while you work. CI runs both. New component tests get the .browser.test.tsx suffix, old ones move only when they break or when you touch them anyway.
Turn it on if your component tests are propped up by polyfills, getBoundingClientRect stubs or assertions you softened to get green. That is the signal, and it is the case where a real browser earns back its start-up cost inside a week. Skip it if your suite is mostly reducers, hooks with no DOM measurement and API clients, where jsdom or plain environment: 'node' is already telling the truth.
If you are on Vitest 3 already, the move is a config change and an import rename rather than a rewrite. If you are still on Jest, do it in two steps: Vitest first, browser second. Doing both at once means every failure has two possible causes, and untangling a runner change from a rendering change is how these migrations stall.