If your Playwright suite mixes public pages and authenticated areas, or you test multiple roles like user and admin, the standard “setup project” approach for auth can make your runs slower than they need to be.
Here’s a simpler pattern that authenticates on demand, plays nicely with sharding, and keeps your config to one project per browser.
The usual way: a “setup” project
The Playwright docs on authentication recommend authenticating in a dedicated setup project and declaring it as a dependency for your browser projects. The dependency project runs first and prepares storageState that other projects reuse.
That’s convenient and visible in reports, but there’s a cost.
The big drawback: setup runs even when not needed
From the docs:
The setup project will always run and authenticate before all the tests
In practice this means:
- You have public and authenticated tests. Even if you run only public tests, the setup still authenticates.
- You have multiple roles. The docs show multiple signed in roles where the setup logs in both admin and user. If you run only admin tests, the user login still happens because the setup runs in full.
- 
It gets worse with sharding. Imagine 2 shards: - Shard 1 executes user.spec.ts
- Shard 2 executes admin.spec.ts
 With project dependencies, both shards still execute the full setup up front, repeating both logins unnecessarily. 
- Shard 1 executes 
You can split the config into separate projects per role, each with its own setup. That works, but once you add different browsers, the matrix grows and your config becomes hard to reason about. I prefer one project per browser and nothing else.
A simpler idea
What I really wanted:
- The first test that actually needs auth does the sign-in while others wait.
- Once done, the same storage state is shared across workers/tests that need it.
- Optionally, persist the state for a while on disk so local runs become instant.
That’s exactly what I use today with @global-cache/playwright, which I built after running into these downsides. Here’s how.
Code: multi-role auth on demand (no setup project)
I’ll use the example from the docs with admin and user roles. But instead of putting both logins into a setup project, I create a helper function and wrap authentication steps in globalCache.get():
// tests/helpers/auth.ts
import type { Browser } from '@playwright/test';
import { expect } from '@playwright/test';
import { globalCache } from '@global-cache/playwright';
/**
 * Performs sign-in for a given role and caches the auth state for the whole run.
 */
export async function signIn(browser: Browser, role: 'admin' | 'user') {
  return globalCache.get(`auth-state-${role}`, async () => {
    console.log(`Signing-in as: ${role}`);
    const page = await browser.newPage();
    // Perform authentication steps. Replace these actions with your own.
    await page.goto('https://github.com/login');
    await page.getByLabel('Username or email address').fill(role);
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    // Wait until the page reaches a state where all cookies are set.
    await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();
    // Return authenticated state (cookies + localStorage)
    return page.context().storageState();
  });
}
Now each spec calls this helper inside a storageState fixture to authenticate only the role it needs, and only if a test actually runs:
  
  
  tests/admin.spec.ts
import { test } from '@playwright/test';
import { signIn } from './helpers/auth';
// Make all tests in this file run as "admin"
test.use({
  storageState: async ({ browser }, use) => {
    const state = await signIn(browser, 'admin');
    await use(state);
  },
});
test('admin: sees dashboard', async ({ page }) => {
  await page.goto('/admin');
  // ...
});
  
  
  tests/user.spec.ts
import { test } from '@playwright/test';
import { signIn } from './helpers/auth';
// Make all tests in this file run as "user"
test.use({
  storageState: async ({ browser }, use) => {
    const state = await signIn(browser, 'user');
    await use(state);
  },
});
test('user: can view profile', async ({ page }) => {
  await page.goto('/me');
  // ...
});
Running the tests
Below are 4 common runs to check the authentication setup:
1) Run everything (user + admin)
$ npx playwright test
Running 2 tests using 2 workers
  ✓  1 test/admin.spec.ts:11:5 › admin: sees dashboard (2.7s)
  ✓  2 test/user.spec.ts:11:5 › user: can view profile (2.8s)
Signing in as: admin
Signing in as: user
  2 passed (3.5s)
What happens: user and admin authenticate once each, then all tests reuse their storage states.
2) Run only "user" tests
$ npx playwright test tests/user.spec.ts
Running 1 test using 1 worker
  ✓  1 test/user.spec.ts:11:5 › user: can view profile (2.8s)
Signing in as: user
What happens: only the user role authenticates; admin never runs.
3) Run only "admin" tests
$ npx playwright test tests/admin.spec.ts
Running 1 test using 1 worker
  ✓  1 test/admin.spec.ts:11:5 › admin: sees dashboard (2.7s)
Signing in as: admin
What happens: only the admin role authenticates; user never runs.
4) Run on two shards (split by files)
Shard 1:
$ npx playwright test --shard=1/2
Running 1 test using 1 worker, shard 1 of 2
  ✓  1 test/admin.spec.ts:11:5 › admin: sees dashboard (2.7s)
Signing in as: admin
  1 passed (3.7s)
Shard 2:
$ npx playwright test --shard=2/2
Running 1 test using 1 worker, shard 2 of 2
  ✓  1 test/user.spec.ts:11:5 › user: can view profile (2.7s)
Signing in as: user
  1 passed (3.6s)
What happens: the first shard authenticates admin; the second shard authenticates user. Each shard pays only for its role, which is why this setup executes faster than a multi-role dependency project.
In all examples, authentication runs only for roles that your tests actually touch. In practice this cuts setup time and makes your tests run faster.
Bonus: persistent auth for local dev
For local development, you can keep login state on disk for a limited time. For example, to cache for 1 hour, add a { ttl: '1 hour' } parameter to the authentication call:
await globalCache.get(`auth-state-${role}`, { ttl: '1 hour' }, async () => {
  // ...perform login and return storageState()
});
The cache files live under .global-cache by default. You can inspect them and delete them to force a cache update.
How to enable Global Cache
To enable Global Cache, wrap your Playwright config with globalCache.wrap():
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { globalCache } from '@global-cache/playwright';
const config = defineConfig({
  projects: [
    { name: 'chromium' }, // keep it simple: one project per browser
  ],
  // ...any other options
});
export default globalCache.wrap(config);
Notes
- If tests modify server-side state and you need isolation per worker, scope the cache key by worker:
  const key = `auth-state-${role}-${testInfo.workerIndex}`;
- Make cache keys role-aware and environment-aware. Include tenant/locale and base URL if you test multiple envs:
  const key = `auth-${role}-${locale}-${envName}`;
- Prefer API-based login for speed and stability. See the example: auth via API with global cache.
Side-by-side: dependency project vs Global Cache
| Aspect | Dependency project | Global Cache | 
|---|---|---|
| When auth runs | Always before tests | Only when a test needs it | 
| Multiple roles | All roles log in up front | Only selected roles log in | 
| Sharding | Each shard pays full setup | Each shard computes only its role | 
| Config | Projects × roles × browsers | One project per browser | 
| Persist across runs | Roll your own | TTL for local runs | 
Wrap-up
Project dependencies are solid and officially recommended. But when you mix public pages, multiple roles, or heavy sharding, paying the authentication cost every time adds up. By authenticating on demand and sharing that state, you keep config lean, you only log in when a test truly needs it, and your local loop becomes fast with a short TTL.
If that sounds familiar, give this pattern a try and share your feedback ❤️
 
 
              
 
    
Top comments (0)