DEV Community

Ankit Patel
Ankit Patel

Posted on

Why Your Playwright Suite Gets Slower Every Sprint (And the Fix Nobody Talks About)

Every Playwright suite starts fast. Forty tests, ninety seconds, green on every push. Then eighteen months pass and the same suite takes twenty-six minutes, half the team has stopped reading the CI output, and somebody suggests "maybe we just run E2E nightly."

I have watched this happen across enough client codebases now that I no longer think it's a discipline problem. Teams that are careful about test quality still end up here. The slowdown is structural, and most of the advice you'll find treats the symptom.

Here's the part that actually matters: your suite doesn't get slower because you added tests. It gets slower because you added setup.

The thing that actually grows

Pull up your slowest spec and time the assertions. On most suites I've profiled, the assertions are a rounding error. The time goes to getting the browser into the state where the assertion makes sense.

A concrete example. This is a checkout test, lightly anonymised, that took 41 seconds:

test('applies a discount code at checkout', async ({ page }) => {
  await page.goto('/signup');
  await page.fill('[name=email]', `user-${Date.now()}@example.com`);
  await page.fill('[name=password]', 'hunter2');
  await page.click('button[type=submit]');
  await page.waitForURL('/onboarding');

  await page.click('text=Skip for now');
  await page.waitForURL('/dashboard');

  await page.goto('/products/sku-1024');
  await page.click('text=Add to cart');
  await page.goto('/cart');
  await page.click('text=Checkout');

  await page.fill('[name=discount]', 'SAVE20');
  await page.click('text=Apply');
  await expect(page.locator('.total')).toHaveText('$80.00');
});
Enter fullscreen mode Exit fullscreen mode

One assertion. Thirteen lines of getting there. And every one of those thirteen lines is a full round trip through your app — real HTTP, real render, real navigation.

Now multiply. You have sixty tests that need a logged-in user. Sixty signups. If signup takes six seconds, that's six minutes of your suite doing nothing but proving that signup works, which you already have a dedicated test for.

Why the usual fixes plateau

Sharding. Genuinely useful, and you should do it. But sharding divides wall-clock time, not work. Four shards turn 26 minutes into 7, and your CI bill goes up roughly fourfold. You've bought time with money, and in another eighteen months you'll buy it again.

Trimming "flaky" tests. Usually this means deleting coverage in exchange for a green dashboard. The test was often telling you something true about a race condition in your app.

fullyParallel: true. Good default. Doesn't help when the bottleneck is that sixty workers are each independently signing up a user against the same backend.

These aren't wrong. They're just operating downstream of the actual problem.

Move setup out of the browser

The fix is to stop performing setup through the UI and start injecting it.

Playwright's storageState handles authentication. Sign in once, globally, save the cookies and localStorage, reuse everywhere:

// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';

async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto('http://localhost:3000/login');
  await page.fill('[name=email]', 'seeded-user@example.com');
  await page.fill('[name=password]', process.env.TEST_PASSWORD!);
  await page.click('button[type=submit]');
  await page.waitForURL('**/dashboard');

  await page.context().storageState({ path: 'storage/user.json' });
  await browser.close();
}

export default globalSetup;
Enter fullscreen mode Exit fullscreen mode
// playwright.config.ts
export default defineConfig({
  globalSetup: require.resolve('./global-setup'),
  use: { storageState: 'storage/user.json' },
});
Enter fullscreen mode Exit fullscreen mode

Sixty signups collapse into one. That alone usually takes a third off a mature suite.

Then push further. Application state — a cart with items, an account mid-onboarding, an order awaiting refund — belongs in a fixture that talks to your API, not your UI:

// fixtures.ts
import { test as base } from '@playwright/test';

type Fixtures = { cartWithItems: { cartId: string } };

export const test = base.extend<Fixtures>({
  cartWithItems: async ({ request }, use) => {
    const res = await request.post('/api/test/carts', {
      data: { items: [{ sku: 'sku-1024', qty: 1 }] },
    });
    const cart = await res.json();
    await use(cart);
    await request.delete(`/api/test/carts/${cart.cartId}`);
  },
});
Enter fullscreen mode Exit fullscreen mode

The checkout test becomes:

test('applies a discount code at checkout', async ({ page, cartWithItems }) => {
  await page.goto(`/cart/${cartWithItems.cartId}/checkout`);
  await page.fill('[name=discount]', 'SAVE20');
  await page.click('text=Apply');
  await expect(page.locator('.total')).toHaveText('$80.00');
});
Enter fullscreen mode Exit fullscreen mode

41 seconds to about 4. Same coverage of the thing the test is named after.

The objection worth taking seriously

"If you don't sign up through the UI, you're not testing signup."

Correct — and you shouldn't be, in a test called applies a discount code at checkout. You should be testing signup in a test called signup. One of them. Running the signup flow sixty times as a side effect doesn't give you sixty times the confidence; it gives you one test's worth of confidence and fifty-nine copies of the runtime.

Keep a small set of full-journey tests that go through the UI end to end, no shortcuts. Five or six, covering your critical paths. Those are your integration canaries. Everything else injects state and tests one thing.

The real cost of injected setup is a different one: you now have test-only API endpoints, and they need to be gated so they can't run in production. That's a genuine tradeoff. In my experience it's worth it, but go in knowing you've taken on that responsibility.

What to do on Monday

Don't refactor the suite. Do this instead:

  1. Run with --reporter=json and sort specs by duration.
  2. Take the slowest five. For each, count the lines before the first meaningful assertion.
  3. Pick the one with the worst ratio and convert just that one to a fixture.
  4. Measure again.

You'll have a real number for your codebase within an afternoon, and a much better basis for arguing about where the remaining time goes.

The suites that stay fast aren't the ones with fewer tests. They're the ones where each test does the minimum work required to reach the thing it's actually asserting.


I'm Ankit Patel, Director of Test Automation at QAble. I spend most of my time inside other teams' test suites. Currently building Testbo-X, a Playwright framework focused on intelligent test selection and parallel CI execution.

Top comments (0)