Playwright & Web Automation Hub

Playwright architecture, selector reliability, and advanced interaction patterns.

Automating Multi-Step Forms with Playwright

A multi-step form wizard packs every hard synchronization problem into one workflow. Each step unmounts and remounts a container, navigation buttons fire validation requests whose timing varies run to run, and the application often advances the UI optimistically before the backend has confirmed the data. A test written as a flat sequence of fills and clicks passes on a fast local machine and fails intermittently in CI, because it interacts with a panel that has not attached yet or clicks "Next" before the server has accepted the previous step. The fix is to treat each transition as an explicit checkpoint: wait for the next step to attach, gate the advance on the validation response, and assert on the URL or panel state rather than an arbitrary delay. This guide extends the field-level techniques in Form Automation & Input Handling and the assertion patterns across Advanced Interactions & Test Assertions.

Multi-step form state machine with validation gates Each step fills fields, waits for the validation response, then waits for the next panel to attach before continuing to submission. Step 1 Account Step 2 Contact Step 3 Confirm Success toHaveURL waitForResponse() waitFor(attached) Each arrow is a validated, attach-checked transition
Treat the wizard as a state machine: every transition gates on the validation response and the next panel attaching before the test fills the following step.

Root cause: optimistic transitions and remounted panels

Two failure modes account for nearly every flaky multi-step form test, and each has a precise, non-timing-based remedy.

The first is panel remounting. Wizards built on component frameworks rarely keep all steps in the DOM; they unmount the current panel and mount the next one during the transition. A locator that resolved to a step-two field a moment ago points at a node the framework has since replaced, so the fill throws a detachment error or silently targets a stale element. Auto-waiting handles standard actionability, but it cannot guess that you intend to wait for a panel that has not been created yet. The remedy is to wait for the next panel to reach the attached (or visible) state before touching any field inside it, anchoring locators to a stable container or a role rather than a generated class.

The second is optimistic navigation. Many wizards enable the "Next" button immediately and only block the real transition once a validation request returns. If the test clicks "Next" and proceeds without waiting for that request, it advances against unvalidated state — the backend may still reject the data, leaving the UI in an inconsistent place or redirecting unexpectedly. The fix is to register page.waitForResponse() before the click and await it after, so the test only advances once the server has confirmed the step. Both problems read as flakiness, yet both are deterministic once you gate transitions on real signals instead of visual guesses.

Minimal reproducible example

The helper below drives a three-step registration wizard. Each transition waits for the validation response and the next panel to attach before interacting, so a slow render or a delayed API can never desynchronize the test.

import { test, expect, type Page } from '@playwright/test';

// A single typed payload keeps the wizard data reviewable in one place.
type RegistrationData = { username: string; email: string };

async function completeMultiStepForm(page: Page, data: RegistrationData) {
  await page.goto('/registration');

  // Step 1: fill account details on the first panel.
  await page.getByRole('textbox', { name: 'Username' }).fill(data.username);

  // Register the validation waiter BEFORE clicking so the response is never missed.
  const step1Validated = page.waitForResponse(
    (resp) => resp.url().includes('/api/validate') && resp.status() === 200,
  );
  await page.getByRole('button', { name: 'Continue' }).click();
  await step1Validated; // advance only after the backend confirms step 1.

  // Step 2: wait for the panel to attach before touching its fields.
  await page.locator('#step-2-panel').waitFor({ state: 'attached' });
  await page.getByRole('textbox', { name: 'Email' }).fill(data.email);
  await page.getByRole('button', { name: 'Next' }).click();

  // Step 3: wait for the final panel, accept terms, and submit.
  await page.locator('#step-3-panel').waitFor({ state: 'visible' });
  await page.getByRole('checkbox', { name: 'Terms' }).check();
  await page.getByRole('button', { name: 'Submit' }).click();

  // Assert on the destination URL — the real end-state the user reaches.
  await expect(page).toHaveURL(/\/success$/);
}

test('completes the registration wizard', async ({ page }) => {
  await completeMultiStepForm(page, { username: 'ada', email: 'ada@example.com' });
});

Step-by-step fix

  1. Wait for each step's panel to attach. After every transition, call await page.locator('#step-N-panel').waitFor({ state: 'attached' }) before interacting with its fields. This eliminates detachment errors caused by the framework remounting the panel during the transition.
  2. Anchor locators to stable handles. Prefer getByRole() and getByLabel(), or a data-testid on the panel, over generated class names. Role- and label-based locators survive markup churn between steps and across redesigns.
  3. Register the validation waiter before the click. Create const validated = page.waitForResponse(...) immediately before clicking "Next", then await validated after. Registering after the click loses the race when the response returns first.
  4. Gate the advance on the response, not a delay. Await the validation response and assert its status before proceeding. Never substitute page.waitForTimeout() — a fixed sleep is both slower than needed and still flaky under load.
  5. Confirm arrival at the next step. After advancing, assert the new state with page.waitForURL() or by checking the active step indicator, so a failed transition surfaces immediately instead of one step later.
  6. Isolate session and state per worker. For authenticated wizards, load a saved session with test.use({ storageState: 'state.json' }) so logins do not repeat and cookies do not bleed across parallel workers. Combine this with Browser Contexts & Isolation for clean separation.

Troubleshooting variants

Filling a field throws "element is not attached to the DOM"

The previous transition remounted the panel after your locator resolved. Re-resolve the field locator immediately before the fill and wait for the panel container with waitFor({ state: 'attached' }) first. If the wizard recreates nodes on every keystroke or validation pass, wrap the interaction in expect(async () => { /* fill + assert */ }).toPass() so Playwright re-resolves and retries until the panel settles. Anchor to a stable #step-N-panel rather than an inner element that the framework regenerates.

The test advances before the backend validates the step

Optimistic UI enabled the button before the request returned, and the click outran the validation. Register page.waitForResponse() before the click and await it after, asserting the expected status. If a transient 5xx can occur under load, wrap the advance in expect().toPass() so the suite retries the navigation rather than failing on a single blip:

import { expect, type Page } from '@playwright/test';

async function advanceWithRetry(page: Page) {
  // Retry the whole advance so a transient 5xx or slow response self-heals.
  await expect(async () => {
    await page.getByRole('button', { name: 'Next Step' }).click();
    await page.waitForURL(/\/step-\d+$/, { timeout: 5000 });
    await expect(page.locator('.step-indicator.active')).toBeVisible();
  }).toPass({ timeout: 15000, intervals: [1000, 2000, 3000] });
}

You need to test the error path without a flaky backend

Drive the validation endpoint yourself so the failing branch is deterministic. Intercept the request and return a controlled error, then assert the wizard surfaces the right message and does not advance. This reuses the technique from Mocking API Responses with Playwright:

import { test, expect } from '@playwright/test';

test('shows a validation error on a 422 response', async ({ page }) => {
  // Answer the validation call with a controlled failure, no real backend needed.
  await page.route('**/api/validate', (route) =>
    route.fulfill({
      status: 422,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Email already in use' }),
    }),
  );

  await page.goto('/registration');
  await page.getByRole('textbox', { name: 'Email' }).fill('taken@example.com');
  await page.getByRole('button', { name: 'Next' }).click();

  // The UI must surface the error and stay on the same step.
  await expect(page.getByRole('alert')).toContainText('Email already in use');
});

Verification

Confirm the wizard test is stable on three fronts. First, run it with npx playwright test --repeat-each=10; gating on the response and the attach state should yield ten clean passes with no detachment or premature-advance failures. Second, assert the end-state explicitly with await expect(page).toHaveURL(/\/success$/) so a half-completed run cannot pass. Third, open the run in the Playwright Trace Viewer with --trace on and step through the timeline — each validation request should resolve before its transition, which proves the test advanced on real signals rather than timing luck. For the individual controls inside each step, see the sibling guide on Handling Dropdowns, Checkboxes, and Radio Buttons.

Frequently Asked Questions

Why does my test fail when clicking Next on a multi-step form?

The button is enabled optimistically and your click outruns the validation request, so the test advances against unconfirmed state. Register page.waitForResponse() before the click and await it after, asserting the expected status, so the test only proceeds once the backend has accepted the step.

How do I handle a step panel that disappears between transitions?

The framework remounts the panel during the transition, detaching your locator. Wait for the next panel with waitFor({ state: 'attached' }) before interacting, anchor locators to a stable container or role, and re-resolve fields immediately before each fill.

Should I use waitForTimeout to wait for the next step?

No. A fixed sleep is slower than necessary and still flaky under variable load. Gate transitions on real signals: await the validation response and assert the next panel attached or the URL changed with page.waitForURL().

Back to overview