DEV Community

DevHelm
DevHelm

Posted on • Originally published at devhelm.io

How to Set Up Browser Monitoring with Playwright

You want to know — continuously, automatically — that a user can complete a critical task in your production application. Not that the server returns 200. Not that the homepage loads HTML. That a real browser can navigate to your checkout page, fill in payment details, click "Pay now," and see "Order confirmed."

This tutorial walks through setting up browser monitoring with Playwright from scratch: writing a check that asserts on user-visible outcomes, capturing forensic evidence on failure, scheduling it against production, and routing alerts to the right channel.

Prerequisites

  • Node.js 18+ with an existing project (or create one: mkdir browser-monitors && cd browser-monitors && npm init -y).
  • Playwright installed: npm install -D @playwright/test && npx playwright install chromium.
  • A production (or staging) URL to monitor.
  • A dedicated synthetic test account — credentials stored in environment variables, never in source.

Step 1 — Write a check that asserts on outcomes

A monitoring check is not a test in the QA sense — it does not cover edge cases or validate business logic. It answers one question: can a user complete this journey right now?

// monitors/checkout.spec.ts
import { test, expect } from "@playwright/test";

test("checkout completes successfully", async ({ page }) => {
  await page.goto(process.env.BASE_URL! + "/products");

  // Add item to cart
  await page.getByRole("button", { name: "Add to cart" }).first().click();
  await page.getByRole("link", { name: "Cart" }).click();

  // Begin checkout
  await page.getByRole("button", { name: "Checkout" }).click();

  // Fill payment (test card — non-charging token)
  await page.getByLabel("Email").fill(process.env.SYNTHETIC_EMAIL!);
  await page.getByLabel("Card number").fill("4242424242424242");
  await page.getByLabel("Expiry").fill("12/28");
  await page.getByLabel("CVC").fill("123");

  // Submit and assert on the user-visible outcome
  await page.getByRole("button", { name: "Pay now" }).click();
  await expect(page.getByText("Order confirmed")).toBeVisible({
    timeout: 15000,
  });
});
Enter fullscreen mode Exit fullscreen mode

The assertion is the key: page.getByText("Order confirmed") proves the entire chain worked — frontend rendering, API call, payment provider round-trip, database write, and confirmation page render. A status code check would miss failures in any of those layers.

Step 2 — Use stable selectors that survive deploys

Browser checks break when the UI changes. Minimize breakage by using selectors that survive visual redesigns:

// Fragile — breaks when CSS classes change
await page.locator(".btn-primary.checkout-submit").click();

// Stable — survives any visual redesign
await page.getByRole("button", { name: "Pay now" }).click();

// Also stable — explicit test IDs for elements without semantic roles
await page.getByTestId("order-total").textContent();
Enter fullscreen mode Exit fullscreen mode

Priority order for selectors: ARIA roles > getByLabel / getByText > data-testid > CSS selectors. The first three are tied to user-visible semantics that rarely change; CSS classes change on every design sprint.

Step 3 — Wait for conditions, never for time

The number-one cause of flaky monitoring checks is fixed sleeps:

// Flaky: assumes 3 seconds is enough, or wastes 3 seconds when it's fast
await page.waitForTimeout(3000);
const text = await page.getByTestId("balance").textContent();
expect(text).toBeTruthy();

// Stable: retries automatically until the condition holds or times out
await expect(page.getByTestId("balance")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("balance")).not.toHaveText("");
Enter fullscreen mode Exit fullscreen mode

Playwright's web-first assertions (expect(locator).toBeVisible(), expect(locator).toHaveText()) retry until the condition is met. They pass in 200ms when the app is fast and only fail when something is genuinely broken.

Step 4 — Capture evidence on failure

When a production check fails at 3 AM, the on-call engineer needs to diagnose why without re-running it. Configure Playwright to preserve forensic evidence on failure:

// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./monitors",
  use: {
    baseURL: process.env.BASE_URL,
    screenshot: "only-on-failure",
    trace: "retain-on-failure",
    video: "retain-on-failure",
  },
  timeout: 30000,
  retries: 1,
});
Enter fullscreen mode Exit fullscreen mode

On failure, you get: a screenshot of the page state at the moment of failure, a trace (full DOM snapshot + network log + console, viewable at trace.playwright.dev), and a video of the entire check execution. The retries: 1 setting re-runs a failed check once before declaring failure — this eliminates most transient network blips without masking real incidents.

Step 5 — Schedule checks against production

A monitoring check runs on a clock, not on commits. The simplest scheduler is a GitHub Actions cron workflow:

# .github/workflows/browser-monitor.yml
name: browser-monitoring
on:
  schedule:
    - cron: "*/5 * * * *"
  workflow_dispatch:

jobs:
  checkout-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci && npx playwright install --with-deps chromium
      - run: npx playwright test monitors/checkout.spec.ts
        env:
          BASE_URL: https://app.yourproduct.com
          SYNTHETIC_EMAIL: ${{ secrets.SYNTHETIC_EMAIL }}
          SYNTHETIC_PASSWORD: ${{ secrets.SYNTHETIC_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: failure-evidence-${{ github.run_id }}
          path: test-results/
          retention-days: 14
Enter fullscreen mode Exit fullscreen mode

This gives you browser monitoring today for free. The limits are honest: cron floors at ~1-minute granularity, GitHub-hosted runners give you one region (US), and you need to wire alerting separately.

Step 6 — Add multi-region coverage

Failures are often regional — a CDN edge cert expires in one zone, DNS propagates unevenly, a deploy rolls out region by region. Running checks from a single location is blind to all of these.

For a self-hosted approach, deploy the same Playwright check as a container to multiple regions and report results to a central collector. For a managed approach, platforms like Checkly run your Playwright scripts from 20+ regions with built-in scheduling and alerting.

At minimum, run critical checks from two geographically distinct locations. If a check fails from one location but passes from others, you have a regional incident — a distinction that changes the severity and response.

Step 7 — Route failures to the right channel

A failed browser check is only useful if it reaches a human who can act on it. Wire failure notifications to match your incident severity levels:

  • Revenue-critical journey failure (checkout, signup) → page on-call via PagerDuty/OpsGenie.
  • Important but not revenue-blocking (settings page, profile update) → Slack channel, business hours.
  • Secondary paths (about page, blog) → logged, reviewed weekly.

For the GitHub Actions approach, add a failure notification step:

      - name: Alert on failure
        if: failure()
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H "Content-Type: application/json" \
            -d '{"text": "Browser check FAILED: checkout journey. Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'
Enter fullscreen mode Exit fullscreen mode

Step 8 — Monitor the layer underneath

A browser journey sits on top of API endpoints that can fail independently. When your checkout check goes red, you want to immediately know whether the problem is in the frontend, the API, or a third-party dependency.

Monitoring those API endpoints directly — with response body assertions, latency thresholds, and multi-region coverage — turns "the whole flow is red" into "the /api/payment-intent endpoint is returning 500 from us-east." That diagnostic specificity is the difference between a fast MTTR and a slow one.

Set up API and uptime monitors for the endpoints your browser checks depend on — with multi-region checks, config-as-code, and a status page that updates from the same monitoring data — at app.devhelm.io. Your first monitor is live in 60 seconds, no credit card.

What to read next


Originally published on DevHelm.

Top comments (0)