Synthetic Monitoring with Playwright: Complete 2026 Guide
Build synthetic monitoring and uptime checks with Playwright. Schedule monitors via cron and GitHub Actions, add alerting, and wire Grafana/Datadog in 2026.
Synthetic Monitoring with Playwright: Complete 2026 Guide
Your status page can say everything is green while your customers cannot log in. Server health checks measure whether a process is up, not whether a human can actually complete the journey that makes you money. Synthetic monitoring closes that gap. It runs a scripted browser through a real user flow, on a schedule, from outside your infrastructure, and tells you the moment that flow breaks, often before a single support ticket arrives. And because the script is just a browser walking through your app, Playwright, the same tool you already use for end-to-end tests, is an excellent engine for it.
The insight that makes this practical is that a synthetic monitor and an end-to-end test are almost the same artifact. Both navigate, click, type, and assert. The differences are operational: a monitor runs continuously rather than on every commit, it targets production rather than a preview environment, it is ruthlessly focused on the handful of journeys that matter most, and when it fails it pages a human instead of failing a build. With a little structure you can reuse your Playwright skills and even some of your test code to stand up monitoring that actually reflects user experience.
This guide shows you how to build synthetic monitoring with Playwright in 2026, end to end and with runnable TypeScript on Playwright 1.55+: what synthetic monitoring is and how it differs from real-user monitoring, how to write a check that asserts on a critical journey, how to schedule it with cron, GitHub Actions, and Checkly-style platforms, how to capture latency and uptime, how to alert on failure, and how to feed results into Grafana and Datadog. If you are coming from a testing background, read this alongside the Playwright end-to-end complete guide and the testing in CI patterns. The playwright-e2e skill packages the underlying browser patterns for AI coding agents.
What synthetic monitoring is
Synthetic monitoring runs a predefined, scripted interaction against a live system on a schedule and records whether it succeeded, how long it took, and what broke. It is synthetic because the traffic is generated by a robot, not a real person, which means you get signal even at 3 a.m. when no humans are using the site. It is the proactive complement to real-user monitoring, which only sees problems after real users hit them.
| Dimension | Synthetic monitoring | Real-user monitoring (RUM) |
|---|---|---|
| Traffic source | Scripted robot | Actual visitors |
| Coverage | Predefined critical journeys | Whatever users happen to do |
| Detects issues | Before users, 24/7 | After users hit them |
| Works with no traffic | Yes | No |
| Geographic control | Run from chosen regions | Wherever users are |
| Typical tool | Playwright, Checkly | PostHog, browser RUM SDKs |
The two are complementary. RUM tells you what real users experience across your whole app; synthetic monitoring guarantees that the journeys you care most about are continuously verified, even on low-traffic pages like a rarely used admin login or a seasonal checkout path.
A check is a focused end-to-end test
A synthetic check is structurally a Playwright test, but written with monitoring discipline: one journey, hard assertions, production target, no reliance on seeded test data you cannot guarantee exists in prod. Here is a login monitor.
import { test, expect } from '@playwright/test';
test('production login journey', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.getByLabel('Email').fill(process.env.MONITOR_USER!);
await page.getByLabel('Password').fill(process.env.MONITOR_PASS!);
await page.getByRole('button', { name: 'Sign in' }).click();
// The assertion that proves the journey actually worked.
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
The discipline that separates a monitor from a normal test: use a dedicated, stable monitor account, never mutate production data destructively, and assert on something that genuinely proves the flow worked, not just that a page loaded. A 200 status with a broken React bundle is still a broken login.
Asserting on a critical user journey
The highest-value monitors cover the journeys whose failure costs you money or trust: sign up, log in, search, add to cart, checkout, submit a form. Write one monitor per journey and keep each one short so a failure points at a specific flow. Here is a read-only checkout-readiness check that stops short of placing a real order.
import { test, expect } from '@playwright/test';
test('checkout flow reaches payment step', async ({ page }) => {
await page.goto('https://shop.example.com');
await page.getByPlaceholder('Search products').fill('wireless mouse');
await page.keyboard.press('Enter');
await page.getByRole('link', { name: /wireless mouse/i }).first().click();
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();
// Stop at the payment step; do not submit a real charge.
await expect(page.getByText('Payment details')).toBeVisible();
});
For flows that would create real records or charges, point the monitor at a sandbox tenant or use a test payment token so the journey is exercised without side effects. The principle from the end-to-end guide applies: assert on user-visible outcomes, not implementation details.
Capturing latency and performance
Uptime is binary, but slow is also broken. Wrap the journey in timing so you record how long each step takes, and assert a budget so a degradation pages you before users complain. Playwright exposes navigation timing through the browser's performance API.
import { test, expect } from '@playwright/test';
test('home page loads within budget', async ({ page }) => {
const start = Date.now();
await page.goto('https://app.example.com', { waitUntil: 'load' });
const loadMs = Date.now() - start;
// Pull a real navigation metric from the browser.
const ttfb = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
return nav.responseStart - nav.requestStart;
});
console.log(JSON.stringify({ metric: 'home_load', loadMs, ttfb }));
// Budget assertions: page this if the site gets slow, not just if it is down.
expect(loadMs).toBeLessThan(4000);
expect(ttfb).toBeLessThan(800);
});
Emitting metrics as structured JSON to stdout is the simplest integration point: a log shipper or the monitoring platform can parse those lines into time series without any extra SDK.
Running headless in CI
Monitors run unattended, so headless is the default and reliability is everything. Use a dedicated Playwright config for monitoring with retries, a tight timeout, and a machine-readable reporter so failures are easy to route.
// playwright.monitor.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './monitors',
timeout: 30_000,
expect: { timeout: 10_000 },
retries: 2, // ride out a single transient blip before alerting
reporter: [['list'], ['json', { outputFile: 'monitor-results.json' }]],
use: {
headless: true,
trace: 'retain-on-failure', // capture a trace only when something breaks
screenshot: 'only-on-failure',
},
});
Retries matter here in a way they do not for CI tests. A single failed run can be a network hiccup rather than an outage; two retries before declaring failure dramatically cuts false alerts. The retain-on-failure trace gives an on-call engineer a full recording of exactly what the browser saw when the journey broke.
Scheduling with cron and GitHub Actions
The cheapest way to schedule monitors is a GitHub Actions workflow on a cron trigger. It runs in the cloud, needs no servers, and can fan out across regions with a matrix. Here is a workflow that runs the monitor suite every five minutes.
# .github/workflows/synthetic-monitor.yml
name: synthetic-monitor
on:
schedule:
- cron: '*/5 * * * *' # every 5 minutes
workflow_dispatch: {}
jobs:
monitor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run monitors
env:
MONITOR_USER: \${{ secrets.MONITOR_USER }}
MONITOR_PASS: \${{ secrets.MONITOR_PASS }}
run: npx playwright test --config=playwright.monitor.config.ts
- name: Alert on failure
if: failure()
run: |
curl -X POST "\$SLACK_WEBHOOK" \\
-H 'Content-Type: application/json' \\
-d '{"text":"Synthetic monitor failed: login or checkout journey is down."}'
env:
SLACK_WEBHOOK: \${{ secrets.SLACK_WEBHOOK }}
The smallest GitHub cron interval is five minutes, which is fine for many apps. If you need sub-minute frequency or true multi-region probing, run the same Playwright suite on a self-hosted scheduler or a managed platform.
| Scheduler | Min interval | Multi-region | Cost model | Best for |
|---|---|---|---|---|
| GitHub Actions cron | 5 min | Via matrix runners | CI minutes | Small teams, no infra |
| Self-hosted cron + Docker | Seconds | Deploy per region | Your servers | Full control |
| Checkly-style platform | Seconds | Built in | Per-check SaaS | Teams wanting turnkey ops |
| Kubernetes CronJob | Seconds | Per-cluster | Cluster cost | Existing k8s shops |
A Checkly-style managed approach
Managed synthetic platforms run your Playwright scripts on their infrastructure from many regions, handle scheduling and alerting, and give you dashboards out of the box. The appeal is that you keep writing the same Playwright code while offloading the operational burden of regions, retries, and paging. The trade-off is per-check cost and less control over the runtime.
// A monitor written to run on a managed Checkly-style runner.
// The body is ordinary Playwright; the platform supplies scheduling and regions.
import { test, expect } from '@playwright/test';
test('api status endpoint healthy', async ({ request }) => {
const res = await request.get('https://api.example.com/health');
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.status).toBe('ok');
});
Because the script is portable Playwright, you can start on GitHub Actions to validate your monitors, then lift the exact same files onto a managed runner when you need more regions or tighter frequency, with no rewrite.
Alerting that does not cry wolf
A monitor that pages on every transient blip gets muted, and a muted monitor is worse than none. Good alerting needs three properties: retries to absorb single-run noise, a clear severity so the right people are paged, and rich context so the on-call engineer can act fast. Send the failing journey name, the region, and a link to the trace.
// notify.ts: called from a custom reporter or a CI step on failure.
export async function alert(opts: {
journey: string;
region: string;
error: string;
traceUrl?: string;
}) {
await fetch(process.env.ALERT_WEBHOOK!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: \`Synthetic FAIL: \${opts.journey} from \${opts.region}\nError: \${opts.error}\`,
trace: opts.traceUrl,
}),
});
}
Route severities deliberately. A failed checkout journey should page on-call immediately; a slow-but-passing home page can post to a channel for review. Tie alert thresholds to the budgets you assert in the monitor so the alerting rules and the test code never drift apart. For reducing false positives at the test layer, the flaky tests guide applies directly.
Feeding results into Grafana and Datadog
Monitors become far more useful when their results are time series you can chart and alert on. The pattern is the same regardless of backend: emit a metric per run, ship it to your observability platform, and build dashboards and alert rules on top. For Datadog, push a custom metric via their API or agent.
// Push pass/fail and duration to a StatsD-compatible endpoint (Datadog agent).
import { StatsD } from 'hot-shots';
const dogstatsd = new StatsD({ prefix: 'synthetic.' });
export function recordResult(journey: string, ok: boolean, durationMs: number) {
dogstatsd.gauge('journey.up', ok ? 1 : 0, [\`journey:\${journey}\`]);
dogstatsd.histogram('journey.duration', durationMs, [\`journey:\${journey}\`]);
}
For Grafana, the simplest path is to write metrics to Prometheus via a pushgateway or expose them for scraping, then build panels for uptime percentage and p95 latency per journey.
| Platform | Ingestion path | What to chart |
|---|---|---|
| Datadog | StatsD / agent / API | journey.up gauge, duration histogram |
| Grafana + Prometheus | Pushgateway or scrape | uptime ratio, p95 latency |
| Grafana Cloud Synthetics | Native Playwright checks | Built-in dashboards |
| Generic log pipeline | Structured JSON stdout | Parsed metrics and error rates |
With per-journey up and duration series you can compute an uptime SLO directly: the fraction of runs where up equaled 1 over a rolling window is your availability number, and the p95 of duration is your performance SLO.
Multi-step API and browser checks combined
The most realistic monitors mix browser and API steps in one journey, because real user flows do both: the browser renders a page, then a background fetch loads data. A combined check verifies the full path. Use the request fixture for the API legs and the page fixture for the UI legs within the same test, sharing authentication through storage state.
import { test, expect } from '@playwright/test';
test('search returns results through UI and API', async ({ page, request }) => {
// API leg: the search backend is healthy and returns data.
const api = await request.get('https://api.example.com/search?q=mouse');
expect(api.status()).toBe(200);
const json = await api.json();
expect(json.results.length).toBeGreaterThan(0);
// Browser leg: the UI renders those results for a real user.
await page.goto('https://shop.example.com/search?q=mouse');
await expect(page.getByRole('listitem')).not.toHaveCount(0);
});
A combined check catches a class of failures that neither a pure API probe nor a pure browser check would: the API is fine but the frontend fails to render its response, or vice versa. That mismatch is one of the most common real-world outages and the hardest to catch with naive health endpoints.
Multi-region monitoring
Where you run a monitor from changes what it can see. A check from the same cloud region as your servers will miss problems caused by a broken CDN edge, a regional DNS failure, or geo-routing rules. Running the same Playwright suite from several regions reveals issues that affect only some of your users. With GitHub Actions you approximate this with a matrix of self-hosted runners in different regions; managed platforms offer it natively.
# Fan the same monitor suite across regions with a matrix.
jobs:
monitor:
strategy:
matrix:
region: [us-east, eu-west, ap-south]
runs-on: [self-hosted, "\${{ matrix.region }}"]
steps:
- uses: actions/checkout@v4
- run: npx playwright test --config=playwright.monitor.config.ts
env:
REGION: \${{ matrix.region }}
Tagging each run with its region, as the workflow above does through the REGION variable, lets your dashboard break uptime and latency down per region. A journey that is 100 percent up from us-east but flapping from ap-south points straight at a regional infrastructure problem rather than an application bug.
Reusing end-to-end tests as monitors
You almost certainly already have end-to-end tests that exercise your critical journeys. Rather than duplicating them, tag the ones safe to run against production and select them with a dedicated config. The discipline is to mark only read-mostly, side-effect-free tests as monitorable so a scheduled run never corrupts production data.
import { test, expect } from '@playwright/test';
// Tag tests that are safe to run continuously against production.
test('login journey @monitor', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.MONITOR_USER!);
await page.getByLabel('Password').fill(process.env.MONITOR_PASS!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
Run only the tagged subset in the monitoring pipeline:
npx playwright test --grep @monitor --config=playwright.monitor.config.ts
This keeps one source of truth for journey logic. When the login flow changes, you update one test and both your CI suite and your production monitor stay correct. The trade-off to watch is that production monitors must never mutate data, so audit your tagged set carefully.
Frequently Asked Questions
Can I use Playwright for synthetic monitoring?
Yes, and it is one of the best tools for it. A synthetic monitor is structurally a Playwright end-to-end test that runs on a schedule against production and pages a human on failure. You reuse the same navigate, click, type, and assert API. Run it headless with retries in CI or on a managed platform, emit metrics, and alert when a critical journey breaks or exceeds its latency budget.
What is the difference between synthetic monitoring and real-user monitoring?
Synthetic monitoring runs scripted robot traffic through predefined critical journeys on a schedule, so it catches problems 24/7 even with no real users online. Real-user monitoring observes actual visitors and reports what they experience, but only after they hit an issue. They are complementary: synthetic guarantees your key flows always work, while RUM shows the full breadth of real-world experience across your app.
How often should synthetic monitors run?
It depends on the journey's importance and your tolerance for detection lag. Critical revenue flows like login and checkout often run every one to five minutes; lower-stakes journeys can run every fifteen minutes or hourly. GitHub Actions cron caps at five-minute intervals, which suits many teams. For sub-minute frequency or multi-region coverage, use a self-hosted scheduler or a managed Checkly-style platform that runs the same Playwright scripts.
How do I schedule Playwright monitors without paying for a platform?
Use a GitHub Actions workflow with a schedule cron trigger, install Chromium, run your monitor config, and call a Slack or webhook on failure. It runs in the cloud with no servers and only consumes CI minutes. The minimum interval is five minutes. For tighter frequency or multiple regions, run the same suite under a Docker container driven by system cron or a Kubernetes CronJob.
How do I avoid false alerts from synthetic monitors?
Add retries so a single transient failure does not page anyone, typically two retries before declaring an outage. Assert on user-visible outcomes rather than brittle implementation details, use a stable dedicated monitor account, and tie alert thresholds to the latency budgets you assert in code. Route severities so only genuine critical-journey failures page on-call, while slow-but-passing runs post to a review channel.
Can synthetic monitors run checkout flows without creating real orders?
Yes. Point the monitor at a sandbox tenant, use test payment tokens, or stop the journey at the payment step before submitting a charge, asserting that the payment screen renders correctly. The goal is to exercise the full path a user takes up to the irreversible action without producing real records or charges. Reserve fully destructive end-to-end checks for non-production environments.
How do I send Playwright monitoring results to Grafana or Datadog?
Emit a metric per run and ship it to your platform. For Datadog, push a journey.up gauge and a duration histogram through StatsD or the API. For Grafana, write to Prometheus via a pushgateway or expose metrics for scraping, then build panels for uptime ratio and p95 latency per journey. The simplest universal path is structured JSON to stdout that a log pipeline parses into time series.
What should a synthetic monitor assert on?
Assert on something that genuinely proves the journey worked from the user's point of view, such as landing on the dashboard URL and seeing a known heading after login, or reaching the payment step after adding to cart. A bare 200 response is not enough because a broken JavaScript bundle can still return 200. Combine a functional assertion with a latency budget so slow degradations also trigger alerts.
Conclusion
Synthetic monitoring is how you find out your login is broken before your customers do. Because a monitor is essentially a focused, production-targeted Playwright test that runs on a schedule, you can stand it up with skills you already have: write one tight check per critical journey, assert on user-visible outcomes plus a latency budget, run it headless with retries, and schedule it with GitHub Actions cron or a managed platform. Layer on alerting that absorbs transient noise and routes severities deliberately, then feed per-journey uptime and duration metrics into Grafana or Datadog to turn green-or-red checks into SLOs you can reason about.
Start small. Pick your single most important journey, usually login or checkout, write one monitor, schedule it every five minutes, and wire a Slack alert on failure. Once that proves its worth, add journeys and regions and graduate to a dashboard. To build the underlying browser automation skills, explore the playwright-e2e skill and the full skills directory, and pair this with the CI testing pipeline guide and the API testing complete guide for backend-level health checks.