What Is This?
Perf Platform is a synthetic web performance monitoring tool. It lets you measure how fast your website loads — not from a single page visit, but across an entire multi-step user journey you define (e.g. "go to the homepage → click the login button → type in a username → wait for the dashboard to load").
It captures real browser performance metrics (the same ones Google uses for Core Web Vitals scoring), and visualizes them in a dashboard so you can track performance over time and catch regressions before your users do.
Why Does This Exist?
Most performance tools give you a one-shot score for a single URL. That's useful, but real user sessions don't work that way. A user logging in, navigating through your app, or going through a checkout flow hits multiple pages in sequence, and each step adds latency.
This platform lets you:
- Define a multi-step journey (like a mini Playwright test, but for performance)
- Run it in a headless Chromium browser with controlled device/network conditions (e.g. "simulate a budget Android phone on Slow 4G")
- Capture Core Web Vitals at the end of that journey
- See everything in a dashboard with visual timelines and pass/fail ratings
It's the kind of tool a frontend team would use for:
- Pre-deploy performance checks ("did this PR make LCP worse?")
- Monitoring production from a synthetic agent
- Establishing performance budgets per device/network tier
What It Does, Step by Step
- You open the Journey Builder in the dashboard and define a sequence of browser actions (navigate to a URL, click a button, fill an input field, scroll the page).
- You pick a device profile (desktop, mid-range mobile, low-end mobile) and a network condition (Fast 3G, Slow 4G, No Throttling, Offline).
- You hit Run Audit. The dashboard sends the job to the API orchestrator.
- The orchestrator validates the job and puts it in a Redis queue (BullMQ).
- A worker node picks it up, launches a headless Chromium browser, executes every step in your journey, and captures performance metrics.
- The results — TTFB, FCP, LCP, and eventually TBT, CLS, INP — come back and get stored.
- The Performance Overview page in the dashboard shows you the latest result with color-coded ratings (good / needs improvement / poor) and a visual SVG timeline of when FCP and LCP fired.
How to Use It
Prerequisites
- Node.js v22+
- pnpm v9+
- A running Redis instance (locally:
redis-serveror via Docker) - (Later phases) A Supabase project and ClickHouse instance
Setup
# Install all dependencies across the monorepo
pnpm install
# Copy the env template and fill in your values
cp .env.example .env.localMinimum required env vars to get running locally:
REDIS_URL=redis://localhost:6379
NEXT_PUBLIC_API_URL=http://localhost:3001
API_PORT=3001
DASHBOARD_URL=http://localhost:3007Running in Development
# Start everything (API + Worker + Dashboard) concurrently
pnpm dev
# Or start each individually:
pnpm --filter @perf-platform/api-orchestrator dev # API on :3001
pnpm --filter @perf-platform/worker-node dev # Worker daemon
pnpm --filter @perf-platform/dashboard dev # Dashboard on :3007Running a Quick Test Audit (no UI needed)
# Runs a single audit against https://example.com and prints the result JSON
pnpm --filter @perf-platform/worker-node audit:testBuilding for Production
pnpm build
pnpm --filter @perf-platform/api-orchestrator start
pnpm --filter @perf-platform/worker-node start
pnpm --filter @perf-platform/dashboard startTechnical Deep Dive
Architecture Overview
┌──────────────────────────────────┐
│ Browser / User │
└───────────────────────────┬──────┘
│ HTTP POST /api/audits/dispatch
▼
┌──────────────────────────────────┐
│ API Orchestrator (:3001) │
│ Fastify · BullMQ Producer │
└────────────────┬────────────────┘
│ Enqueue job
▼
┌──────────────────────────────────┐
│ Redis (BullMQ) │
│ Queue: audit-jobs │
└────────────────┬─────────────────┘
│ Dequeue job
▼
┌──────────────────────────────────┐
│ Worker Node (daemon) │
│ BullMQ Consumer · Playwright/Chromium │
└────────────────┬─────────────────┘
│ AuditResult (stored in Redis job)
▼
┌──────────────────────────────────┐
│ Dashboard (:3007) │
│ Next.js 15 · React 19 │
└──────────────────────────────────┘
Planned (Phase 6+): Worker ──► Supabase (PostgreSQL) ← structured results Worker ──► ClickHouse ← time-series analytics Worker ──► GCS/S3 ← trace files, HAR files
Monorepo Structure
The project is a pnpm workspace monorepo with two categories of packages:
web-test-suite/
├── apps/
│ ├── api-orchestrator/ ← HTTP API + queue dispatcher
│ ├── worker-node/ ← headless browser + metrics engine
│ └── dashboard/ ← Next.js web UI
├── packages/
│ ├── core-types/ ← shared TypeScript types (the contract)
│ └── database/ ← data access layer (Supabase + ClickHouse stubs)
├── _docs/
├── turbo.json ← build orchestration
└── pnpm-workspace.yamlAll apps depend on @perf-platform/core-types. The types package is the single source of truth — if the API dispatches an AuditJobData, the worker receives an AuditJobData, and the types enforce that at compile time across every boundary.
Tech Stack & Why Each Tool Was Chosen
TypeScript (everywhere)
The entire platform is TypeScript — apps, packages, configs. Strict mode is on (`exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`). This catches null/undefined errors and type mismatches that normally blow up at runtime, especially useful across the API→Queue→Worker boundary where data crosses process lines.pnpm Workspaces
pnpm's workspace:* protocol lets packages reference each other locally without publishing to npm. pnpm itself is faster and more disk-efficient than npm/yarn because it hard-links packages from a global store instead of copying them into each node_modules.
Turbo
Turbo sits on top of pnpm workspaces and handles build orchestration. It understands the dependency graph (e.g., worker-node depends on core-types, so build core-types first), runs tasks in parallel where safe, and caches outputs — if core-types hasn't changed, its build result is replayed from cache instead of recompiled.
Fastify (API Orchestrator)
Fastify is the HTTP framework for the API orchestrator. It was chosen over Express because:
- It has built-in JSON schema validation via AJV. The dispatch endpoint validates the incoming job payload before touching the queue — invalid jobs are rejected immediately with a proper 400, not passed through to the worker.
- It is significantly faster than Express for JSON-heavy APIs.
- It has a first-class TypeScript plugin system.
BullMQ + Redis (Queue)
BullMQ is a job queue built on Redis. The API orchestrator is the producer (it enqueues jobs), and the worker node is the consumer (it dequeues and executes them).
Why a queue instead of direct API-to-worker HTTP calls?
- Durability: If the worker crashes mid-job, BullMQ marks the job as failed and can retry it. Nothing is lost.
- Backpressure: If 100 audits are dispatched at once, they queue up and get processed at the worker's pace, rather than spawning 100 concurrent browsers.
- Decoupling: The API responds immediately with a job ID (
202 Accepted) instead of waiting for the browser to finish. The dashboard can poll for results without blocking. - Horizontal scaling: Add more worker node instances and they all pull from the same Redis queue — zero configuration scaling.
Playwright (Worker Node)
Playwright is the headless browser automation library. It drives a real Chromium browser (not a JavaScript sandbox), which means the performance metrics it captures are real — the same V8 engine, the same network stack, the same rendering pipeline your users get.
Key design choice: one browser process per worker node, one isolated context per job. Launching Chromium takes ~300–500ms. By keeping one browser warm and creating a fresh incognito context per audit, jobs start in ~50ms while getting complete isolation (no shared cookies, localStorage, or cache between runs).
The worker uses Playwright's CDP (Chrome DevTools Protocol) integration to access raw performance internals beyond what the Web Performance APIs expose.
Metrics Captured and How
| Metric | Full Name | Source | What It Measures |
|---|---|---|---|
| TTFB | Time to First Byte | Navigation Timing API | Time from navigation start until first byte of the response arrives. Network quality + server response speed. |
| FCP | First Contentful Paint | Paint Timing API | Time until the browser renders the first text or image. When the user sees something. |
| LCP | Largest Contentful Paint | LCP API | Time until the largest visible element is rendered. The Core Web Vitals "loading" metric. |
| TBT | Total Blocking Time | Long Tasks API (Phase 5+) | Sum of time the main thread was blocked (>50ms tasks). Measures JavaScript jank. |
| CLS | Cumulative Layout Shift | Layout Instability API (Phase 5+) | How much content unexpectedly moves around. Measures visual stability. |
| INP | Interaction to Next Paint | Event Timing API |
All three "Phase 5+" metrics require a PerformanceObserver to be injected before navigation starts — they observe events rather than reading a value after the fact. That injection is planned for Phase 9.
Rating thresholds follow Google's official Core Web Vitals definitions:
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| TTFB | < 800ms | < 1800ms | ≥ 1800ms |
| FCP | < 1800ms | < 3000ms | ≥ 3000ms |
| LCP | < 2500ms | < 4000ms | ≥ 4000ms |
Next.js 15 + React 19 (Dashboard)
Next.js provides the file-system router, server components, and production build pipeline for the dashboard. React 19 ships the new concurrent features used by Next.js's App Router. Tailwind CSS 4 handles styling (the new Vite-based engine, configured via PostCSS).
Supabase (Planned — Phase 6)
Supabase will handle two things:
- Auth — workspace login, user sessions, API key management
- PostgreSQL — structured storage for audit results, workspace settings, journey configs
The @perf-platform/database package already has the Supabase client as a dependency, ready to be wired up.
ClickHouse (Planned — Phase 7)
ClickHouse is a columnar database optimized for time-series analytics. Audit results (with timestamps and metric values) are exactly the kind of data ClickHouse is designed for — you can query "average LCP over the last 30 days per device type" in milliseconds across millions of rows.
Data Flow: A Single Audit, End to End
1. User defines a journey in the builder UI
steps = [
{ action: 'navigate', url: 'https://myapp.com', waitFor: 'networkidle' },
{ action: 'click', selector: '#login-btn', waitFor: 'domcontentloaded' }
]
config = { deviceId: 'mobile-standard', networkProfile: 'Slow4G', cpuThrottlingRate: 4 }
2. Dashboard POSTs to API orchestrator
POST http://localhost:3001/api/audits/dispatch
Body: { steps, config }
3. API orchestrator validates schema, generates UUID, enqueues
auditId = crypto.randomUUID()
queue.add('run-audit', { auditId, steps, config }, { jobId: auditId })
Returns: { jobId: auditId, status: 'queued' }
4. Worker node dequeues the job
job.data = { auditId, steps, config }
- AuditEngine finds the navigate step → extracts target URL targetUrl = 'https://myapp.com'
6. AuditEngine creates an isolated browser context with mobile emulation
context = browser.newContext({ userAgent: '...iPhone...', viewport: { width: 390, height: 844 } })
7. AuditEngine navigates and waits for load
page.goto(targetUrl)
page.waitForLoadState('networkidle')
8. AuditEngine reads metrics from the page
ttfb = performance.timing.responseStart - performance.timing.navigationStart
fcp = performance.getEntriesByType('paint').find(e => e.name === 'first-contentful-paint').startTime
lcp = performance.getEntriesByType('largest-contentful-paint').at(-1).startTime
9. AuditEngine closes the context, returns AuditResult
{ auditId, targetUrl, timestamp, metrics: { ttfb, fcp, lcp, tbt: null, cls: null, inp: null } }
10. BullMQ stores the result in Redis as job.returnvalue
11. Dashboard reads and displays the result
(currently from a mock function; Phase 6 will read from Supabase)Key Type Definitions (the cross-service contract)
These live in packages/core-types/src/index.ts and are shared across all three apps:
// A single step in a user journey
interface JourneyStep {
stepNumber: number;
action: 'navigate' | 'click' | 'input' | 'scroll';
url?: string; // used when action = 'navigate'
selector?: string; // used when action = 'click' | 'input'
value?: string; // used when action = 'input'
waitFor: 'networkidle' | 'domcontentloaded' | 'selector' | 'timeout';
timeoutMs?: number;
}
// Device + network configuration for a run
interface AuditConfig {
deviceId: 'desktop' | 'mobile-standard' | 'mobile-low-end';
cpuThrottlingRate
Current Status & Roadmap
| Phase | What | Status |
|---|---|---|
| 1 | Monorepo scaffolding, Turbo, pnpm workspaces | Done |
| 2 | Core types, database package stubs | Done |
| 3 | Worker node — Playwright + TTFB/FCP/LCP capture | Done |
| 4 | API orchestrator — Fastify + BullMQ dispatch | Done |
| 5 | Dashboard UI — Overview, Journey Builder, Settings | Done |
| 6 | Supabase auth + result persistence (PostgreSQL) | Planned |
| 7 | ClickHouse analytics writes | Planned |
| 8 | Trace/HAR file storage (GCS or S3) | Planned |
| 9 | TBT, CLS, INP via PerformanceObserver injection | Planned |
The platform is end-to-end functional today for TTFB, FCP, and LCP on single-URL journeys. The dashboard currently shows mocked data — the plumbing to read real results from storage will land in Phase 6.