An expect() assertion without await in Playwright always passes, even when the condition is false: the assertion runs but never waits for the check to complete. That's the sharpest edge of async in test code. This guide covers how Promises work, why missing await causes flaky tests rather than immediate failures, Promise.all for parallel fixture setup, Promise.allSettled for collecting partial results, and the race condition pattern that correctly handles navigation after a button click.

What Is a Promise?

A Promise represents a value that isn't ready yet — it's a placeholder for something that will arrive in the future.

// A Promise in three states:
// 1. Pending    — waiting for the result
// 2. Fulfilled  — the operation succeeded, value is available
// 3. Rejected   — the operation failed, error is available

const promise = fetch('/api/users');
// At this point, promise is PENDING

// Some time later, it's either:
// FULFILLED — response arrived
// REJECTED  — network error, server error

.then() — The Old Way

Before async/await, Promises were consumed with .then():

fetch('/api/users')
  .then(response => response.json())
  .then(users => {
    console.log(users);  // Use the data
  })
  .catch(error => {
    console.error('Failed:', error);
  });

This chains fine for simple cases, but gets messy with complex logic. Async/await was designed to fix this.

async/await — The Modern Way

async function getUsers() {
  try {
    const response = await fetch('/api/users');
    const users = await response.json();
    return users;
  } catch (error) {
    console.error('Failed:', error);
    throw error;
  }
}

await pauses execution until the Promise resolves. The code reads like synchronous code but runs asynchronously. Rules:
  • await can only be used inside async functions
  • async functions always return a Promise
  • If an async function returns a value, it's wrapped in a resolved Promise

What Happens When You Forget await

This is the most common async bug in Playwright:

// BUG: forgot await
test('login test', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'user@test.com');
  
  page.click('[data-testid="submit"]');  // ← No await!
  
  // This assertion runs BEFORE the click completes
  await expect(page).toHaveURL('/dashboard');  // Might fail intermittently
});

Without await, page.click() starts but doesn't wait to finish. The next line runs while the click is still in progress. In Playwright, this causes flaky tests — sometimes the click finishes in time, sometimes it doesn't.

Fix:

await page.click('[data-testid="submit"]');

Parallel vs Sequential Execution

By default, await runs things sequentially:

// Sequential — total time: sum of all waits
const user = await getUser(1);    // Wait for user
const orders = await getOrders(1); // Then wait for orders
const profile = await getProfile(1); // Then wait for profile

If the operations are independent, run them in parallel:

// Parallel — total time: longest single wait
const [user, orders, profile] = await Promise.all([
  getUser(1),
  getOrders(1),
  getProfile(1),
]);

In Playwright fixtures, this pattern is used to set up multiple things at once:

// Set up test data in parallel
const [adminToken, testUser] = await Promise.all([
  loginAsAdmin(request),
  createTestUser(request),
]);

Promise.all — Run Multiple in Parallel

// All three requests fire simultaneously
const results = await Promise.all([
  request.get('/api/users'),
  request.get('/api/products'),
  request.get('/api/orders'),
]);

// results is an array of responses
const [usersResp, productsResp, ordersResp] = results;

Important: If ANY promise in Promise.all rejects, the whole thing rejects:

try {
  const [a, b, c] = await Promise.all([
    fetch('/api/users'),
    fetch('/api/will-fail-with-404'),  // This fails
    fetch('/api/products'),
  ]);
  // Never reaches here if any fail
} catch (error) {
  // One of them failed
  console.error(error);
}

Promise.allSettled — Wait for All, Even Failures

const results = await Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/might-fail'),
  fetch('/api/products'),
]);

// Each result has status + value or reason
results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log('Success:', result.value);
  } else {
    console.log('Failed:', result.reason);
  }
});

Use allSettled when you want all results regardless of individual failures.

Promise.race — First One Wins

// Timeout pattern using Promise.race
const fetchWithTimeout = async (url, timeoutMs) => {
  const timeout = new Promise((_, reject) => 
    setTimeout(() => reject(new Error('Timeout')), timeoutMs)
  );
  
  return Promise.race([fetch(url), timeout]);
};

try {
  const response = await fetchWithTimeout('/api/slow', 5000);
} catch (error) {
  if (error.message === 'Timeout') {
    console.log('Request took too long');
  }
}

Error Handling

try/catch with async/await

async function createUser(data: UserData) {
  try {
    const response = await request.post('/api/users', { data });
    
    if (!response.ok()) {
      const body = await response.json();
      throw new Error(`API error: ${body.message}`);
    }
    
    return await response.json();
  } catch (error) {
    console.error('Failed to create user:', error);
    throw error;  // Re-throw so the caller knows it failed
  }
}

In Playwright tests

test('handles API error gracefully', async ({ page }) => {
  // Mock the API to fail
  await page.route('/api/users', route => route.fulfill({ status: 500 }));
  
  await page.goto('/users');
  
  await expect(page.getByTestId('error-message')).toBeVisible();
  await expect(page.getByTestId('error-message')).toContainText('Something went wrong');
});

Common Async Bugs in Playwright Tests

1. Missing await on expectations

// BUG
expect(page.getByTestId('button')).toBeVisible();  // No await — always passes!

// FIX
await expect(page.getByTestId('button')).toBeVisible();

2. Awaiting inside loops incorrectly

// BUG — all run in parallel but errors might be unhandled
const items = ['a', 'b', 'c'];
items.forEach(async (item) => {
  await processItem(item);  // These run in parallel, not awaited
});

// FIX — sequential
for (const item of items) {
  await processItem(item);
}

// OR parallel with proper handling
await Promise.all(items.map(item => processItem(item)));

3. Race conditions

// BUG — click and navigation race
await page.click('[data-testid="submit"]');
// Submit might navigate, or might show validation error
// The next line might run before we know which happened

// FIX — explicitly wait for what you expect
await Promise.all([
  page.waitForURL('/dashboard'),
  page.click('[data-testid="submit"]'),
]);
// Or: wait for the response
const [response] = await Promise.all([
  page.waitForResponse('/api/auth/login'),
  page.click('[data-testid="submit"]'),
]);

Async/Await in Page Objects

Page object methods should be async when they wait for something:

class LoginPage {
  constructor(private page: Page) {}

  // Async because it navigates
  async navigate(): Promise<void> {
    await this.page.goto('/login');
  }

  // Async because it performs actions
  async login(email: string, password: string): Promise<void> {
    await this.page.fill('[data-testid="email"]', email);
    await this.page.fill('[data-testid="password"]', password);
    await this.page.click('[data-testid="submit"]');
  }

  // Async because it reads from the page
  async getErrorMessage(): Promise<string | null> {
    return this.page.getByTestId('error').textContent();
  }
}

// Usage — everything awaited
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login('user@test.com', 'ValidPass1');
const error = await loginPage.getErrorMessage();

Summary

| Concept | What it does |

|---------|-------------|

| Promise | Placeholder for future value |

| async | Marks a function as asynchronous |

| await | Pauses until Promise resolves |

| Promise.all() | Run multiple in parallel, wait for all |

| Promise.allSettled() | Like all(), but keeps going on failures |

| Promise.race() | Returns first to resolve/reject |

| try/catch | Handle async errors |

The most important rule: always await Playwright actions and assertions. Missing an await is the #1 cause of flaky Playwright tests. If your test is intermittently failing for no obvious reason, look for missing await keywords first.

→ See also: Async/Await in Plain English (for Testers Who Get Tripped Up by Promises) | Flaky Tests: Why They Happen and How to Eliminate Them | JavaScript Error Handling with try/catch for QA Engineers