race-for-the-prize
v0.8.0
Published
π Race browsers head-to-head and crown a champion
Maintainers
Readme
π RaceForThePrize
Ladies and gentlemen, welcome to race day!
RaceForThePrize is a command-line showdown that pits browsers against each other in head-to-head performance battles. Pit 2 to 5 racers against each other β write your Playwright scripts, fire the starting gun, and watch them tear down the track side-by-side β complete with live terminal animation, video recordings, and a full race report declaring the champion.
No judges, no bias β just cold, hard milliseconds on the clock.
The Starting Grid
npm install && npx playwright install chromiumNew to Node.js or need help with your platform? See the full Installation Guide for step-by-step instructions on macOS, Linux, and Windows.
π Race Day: Lauda vs Hunt
The classic rivalry. Niki Lauda β "The Computer" β against James Hunt β "The Shunt". Precision vs raw speed. Let's settle it once and for all.
node race.js ./races/lauda-vs-huntTwo browsers launch. Two Wikipedia pages load. Then they scroll β human-like, pixel by pixel β to the bottom. Who reaches the finish line first?

What's in the race folder
races/lauda-vs-hunt/
lauda.spec.js # π΄ Racer 1: Niki Lauda's Wikipedia page
hunt.spec.js # π΅ Racer 2: James Hunt's Wikipedia page
settings.json # Race conditions (parallel, throttle, etc.)π LeBron vs Curry
The GOAT debate, settled by browser performance. LeBron James β "The King" β against Stephen Curry β "The Chef". Both start at a fixed scroll position on their Wikipedia pages and dribble β basketball physics style, with gravity acceleration down and deceleration up β three times before racing back to the top.
node race.js ./races/lebron-vs-curryThe dribbles are perfectly synced. The difference? The scroll back to the top: LeBron uses a smooth ease-in-out, Curry snaps up with a cubic ease-out. Pure browser performance decides the winner.
βοΈ React vs Angular (and friends)
The frontend framework cage match β four racers, one winner. React, Angular, Svelte, and htmx all load the same TodoMVC-style benchmark. RaceForThePrize supports up to five racers in a single heat.
node race.js ./races/react-vs-angularGlobal Install
Install once, race anywhere:
npm install -g race-for-the-prizeChromium is installed automatically via the postinstall script. Then scaffold and run a race from any directory:
race-for-the-prize --init my-race # scaffold starter race into my-race/
race-for-the-prize my-race # run itUse npx if you prefer not to install globally:
npx race-for-the-prize --init my-race
npx race-for-the-prize my-raceBuilding Your Own Grand Prix
Every race needs at least two contenders (up to five). RaceForThePrize supports two modes:
Mode 1: Multi-spec mode
Use this when each racer needs custom logic. Create one .spec.js file per racer:
races/my-race/
contender-a.spec.js # Racer 1 (filename = racer name)
contender-b.spec.js # Racer 2
contender-c.spec.js # Racer 3 (optional β up to 5 racers)
settings.json # Optional: race conditions
setup.sh # Optional: runs before the race (see Setup and Teardown)
teardown.sh # Optional: runs after the raceMode 2: Shared-spec mode
Use this when racers share the same script but differ by variables (URL, commit, feature flags). Keep one race.spec.js and define racers in settings.json:
races/my-race/
race.spec.js # Shared script used by all racers
settings.json # Required: defines racers + vars
race.setup.sh # Optional: shared per-racer setup in shared-spec mode
setup.sh # Optional: global setup
teardown.sh # Optional: global teardown{
"racers": {
"commit-a": { "vars": { "URL": "https://app.example.com?ref=a" } },
"commit-b": { "vars": { "URL": "https://app.example.com?ref=b" } }
}
}In shared-spec mode, racer order follows key declaration order in racers. Use non-numeric racer names (integer-like names such as "0" are rejected to keep ordering deterministic). Access per-racer values via race.vars:
await page.goto(race.vars.URL);Setup scripts are optional in both modes.
If both modes are technically possible, prefer shared-spec mode for faster A/B experiments with less duplicated code.
In shared-spec mode, race.setup.sh/.js can be used as a shared per-racer setup convention (still overridable per racer in settings.racers.<name>.setup).
Each script gets a Playwright page object with race timing built in:
// Navigate and wait for the page to be ready
await page.goto('https://example.com', { waitUntil: 'load' });
await page.waitForSelector('.action-button');
// Start recording early β gives viewers context before the action
await page.raceRecordingStart();
await page.waitForTimeout(1500);
// Drop the flag β start the clock
await page.raceStart('Full Page Load');
// Do whatever you're measuring
await page.click('.action-button');
await page.waitForSelector('.result-loaded');
// Checkered flag β stop the clock
page.raceEnd('Full Page Load');
// Hold the frame so the video doesn't cut abruptly
await page.waitForTimeout(1500);
await page.raceRecordingEnd();The Race API
| Method | What it does |
|---|---|
| await page.raceStart(name) | Starts the stopwatch for a named measurement |
| page.raceEnd(name) | Stops the stopwatch β time is recorded |
| await page.raceRecordingStart() | Manually start the video segment |
| await page.raceRecordingEnd() | Manually end the video segment |
| page.raceMessage(text) | Send a status message to the CLI terminal |
| await page.raceWaitForVisualStability(opts?) | Wait for rendering to settle before measuring |
If you skip raceRecordingStart/End, the video automatically wraps your first raceStart to last raceEnd.
Use Cases: What You Can Race
A/B testing different versions of your app
Ship a performance regression? Find out before your users do. You can race two builds with either separate scripts (multi-spec mode) or one shared script (shared-spec mode).
Multi-spec mode example
races/checkout-v2-vs-v3/
checkout-v2.spec.js # Production: https://app.example.com
checkout-v3.spec.js # Staging: https://staging.example.comShared-spec mode example (single codebase, commit-to-commit)
Use one script and switch commits per racer in a per-racer setup script:
races/checkout-commits/
race.spec.js
checkout-commit.sh
settings.jsonsettings.json:
{
"racers": {
"main": {
"setup": "./checkout-commit.sh",
"vars": { "COMMIT_SHA": "origin/main", "URL": "http://localhost:5601/app/home" }
},
"candidate": {
"setup": "./checkout-commit.sh",
"vars": { "COMMIT_SHA": "feature/checkout-fast", "URL": "http://localhost:5601/app/home" }
}
}
}checkout-commit.sh:
#!/usr/bin/env bash
set -euo pipefail
git -C /path/to/your/repo checkout "$RACE_VAR_COMMIT_SHA"race.spec.js:
await page.goto(race.vars.URL);
await page.waitForSelector('.product-list');
await page.raceRecordingStart();
await page.waitForTimeout(1500);
await page.raceStart('Add to cart flow');
await page.click('.add-to-cart');
await page.waitForSelector('.cart-badge');
page.raceEnd('Add to cart flow');
await page.waitForTimeout(1500);
await page.raceRecordingEnd();Run it under realistic conditions with throttling to see how it feels on real devices:
node race.js ./races/checkout-v2-vs-v3 --network=fast-3g --cpu=4 --runs=5Comparing competing products or frameworks
Which dashboard loads faster β yours or the competition? Which CSS framework renders a complex layout quicker? Set up a head-to-head:
races/react-vs-svelte-todo/
react-todo.spec.js # React TodoMVC
svelte-todo.spec.js # Svelte TodoMVCMeasuring the impact of a single change
Want to know if lazy-loading images actually helped? Create two racers that hit the same page β one with the feature flag on, one off:
races/lazy-loading-impact/
with-lazy.spec.js # ?feature=lazy-images
without-lazy.spec.js # ?feature=eager-imagesMonitoring third-party script cost
Quantify the performance tax of analytics, chat widgets, or ad scripts by racing a page with and without them.
Simulating real-world conditions
Combine network throttling and CPU slowdown to approximate mobile users on spotty connections:
node race.js ./races/my-race --network=slow-3g --cpu=6 --runs=3The --runs flag takes the median, smoothing out noise and giving you a number you can trust. In multi-run mode, each racer independently picks the run closest to their own median β so if Racer A performed best in Run 2 and Racer B in Run 4, each gets their own representative video. The results page shows which runs were selected (e.g., "Runs 2, 4").
Race Flags (CLI Options)
node race.js --init [dir] # Scaffold a starter race (default dir: my-race)
node race.js <dir> # Green light β run the race
node race.js <dir> --results # Check the scoreboard
node race.js <dir> --parallel # Side by side β pure spectacle, wizard-of-many-windows mode
node race.js <dir> --headless # Lights out β no visible browsers
node race.js <dir> --network=slow-3g # Wet track conditions
node race.js <dir> --network=fast-3g # Damp track
node race.js <dir> --network=4g # Dry track
node race.js <dir> --cpu=4 # Ballast penalty (CPU throttle)
node race.js <dir> --format=mov # Broadcast-ready replay format (requires --ffmpeg)
node race.js <dir> --format=gif # Quick highlight reel (requires --ffmpeg)
node race.js <dir> --runs=3 # Best of 3 β median wins
node race.js <dir> --slowmo=2 # Slow-motion replay (2x, 3x, etc.)
node race.js <dir> --overlay=false # Record videos without overlays
node race.js <dir> --recording=false # Skip video recording, just measure
node race.js <dir> --ffmpeg # Enable FFmpeg processing (trim, merge, convert)
node race.js <dir> --har # Record network HAR files alongside videos
node race.js <dir> --wasm=false # Skip copying ffmpeg.wasm files (~25 MB) to results
node race.js <dir> --serve=false # Don't start local results server or auto-open; print results HTML path
node race.js <dir> --pause # Pause between racers β run all laps for each racer, then press Enter for the next
node race.js <dir> --height=900 # Set viewport/recording height in pixels (480β4320, default 720)
node race.js <dir> --ignore-https-errors # Accept invalid/self-signed TLS certificatesCLI flags always override settings.json. For boolean flags, you can pass explicit values like --parallel=false or --ffmpeg=true.
Network Throttling Presets
| Preset | Download | Upload | Latency |
|---|---|---|---|
| slow-3g | 500 Kbps | 500 Kbps | 400 ms |
| fast-3g | 1500 Kbps | 750 Kbps | 150 ms |
| 4g | 4000 Kbps | 3000 Kbps | 50 ms |
Serial vs Parallel: Accuracy vs Spectacle
By default, races run in serial (sequential) mode β one browser at a time. This gives you the most accurate and reliable timing results because each racer gets the full, undivided attention of your machine's CPU and network stack. If you care about the numbers, stick with serial.
Parallel mode (--parallel) launches all browsers simultaneously and is purely for the show. It's demo day mode β the wizard-of-many-windows spectacle where browsers tear down the track side by side in real time. It looks fantastic in presentations and screen recordings, but since all browsers compete for the same system resources, the timings are less reliable. Use it when you want to impress an audience, not when you need to trust the stopwatch.
Race Results
After every race, the results land in a timestamped folder:
races/my-race/results-2026-01-31_14-30-00/
contender-a/
contender-a.race.webm # Onboard camera footage
contender-a.full.webm # Full session recording (--ffmpeg only)
contender-a.trace.json # Performance trace (always generated)
measurements.json # Lap times
contender-b/
...
contender-a-vs-contender-b.webm # Side-by-side broadcast replay (--ffmpeg only)
index.html # Interactive HTML player with video replay
summary.json # Official race classification
README.md # Race report cardBy default, the HTML player handles virtual trimming via clip times and uses CDP screencast metadata or canvas-based calibration for frame-accurate playback β no external dependencies needed. When neither calibration source is available, it falls back to linear time-mapping which is less precise. With --ffmpeg, videos are physically trimmed, a side-by-side merged video is created, and format conversion (mov/gif) is available.
The player includes segment navigation buttons β Race Recording (all measurements combined), individual named segments (one per raceStart/raceEnd pair), and Whole Recording (full unclipped video when available). This lets you scrub directly to any specific measurement.
Disclaimer: Due to the nature of the way the video is transformed, the aim here is not accuracy, it's to showcase, to visualize performance. To compare between different network and browser settings. Do double check and question the metrics and findings. It should be a helpful tool supporting performance related narratives, but don't assume 100% accuracy. However, this generally applies to many browser gained performance metrics. There are many side effects. And screen recording, plus video cutting is another one.
The Podium Ceremony
The terminal delivers the verdict in style:
- ποΈ Live racing animation while browsers compete
- π Bar chart comparison of every timed measurement
- π₯π₯ Medal assignments per measurement
- π Overall winner declared
- πΉ Side-by-side video replay (in-browser export, or physical file via
--ffmpeg) - π Chrome performance traces (open in
chrome://tracing)
settings.json Reference
{
"parallel": false,
"network": "none",
"cpuThrottle": 1,
"headless": false,
"runs": 1,
"slowmo": 0,
"format": "webm",
"ffmpeg": false,
"har": false,
"noOverlay": false,
"noRecording": false,
"noWasm": false,
"noServe": false,
"pauseBetweenRuns": false,
"ignoreHTTPSErrors": false,
"viewportHeight": 720
}| Field | CLI flag | Values (booleans accept true/false, 1/0, yes/no) | Default |
|---|---|---|---|
| parallel | --parallel | true / false | false |
| network | --network=<preset> | none, slow-3g, fast-3g, 4g | none |
| cpuThrottle | --cpu=<n> | 1 (none) to any multiplier | 1 |
| headless | --headless | true / false | false |
| runs | --runs=<n> | integer β₯ 1 (median of N runs) | 1 |
| slowmo | --slowmo=<n> | 0 (off) to 20 (multiplier) | 0 |
| format | --format=<fmt> | webm, mov, gif | webm |
| ffmpeg | --ffmpeg | true / false | false |
| har | --har | true / false | false |
| noOverlay | --overlay | true / false (inverted: overlay=false => noOverlay=true) | false |
| noRecording | --recording | true / false (inverted: recording=false => noRecording=true) | false |
| noWasm | --wasm | true / false (inverted: wasm=false => noWasm=true) | false |
| noServe | --serve | true / false (inverted: serve=false => noServe=true) | false |
| pauseBetweenRuns | --pause | true / false | false |
| ignoreHTTPSErrors | --ignore-https-errors | true / false | false |
| viewportHeight | --height=<px> | integer, 480β4320 | 720 |
| racers | β | optional object keyed by racer name | not present by default |
For boolean fields, prefer JSON literals true / false (not strings). String values like "false" are normalized when possible. On the CLI, boolean flags also accept explicit values (--headless=false, --har=true) and short values (--serve=0, --overlay=1).
Setup and Teardown Scripts
Need to start a dev server before racing, or clean up after? Drop setup and teardown scripts into your race directory β they run automatically.
Convention-based discovery
races/my-race/
setup.sh # Global setup β runs before all races
teardown.sh # Global teardown β runs after all races (even on failure)
contender-a.spec.js
contender-a.setup.sh # Per-racer setup β runs before this racer
contender-a.teardown.sh
contender-b.spec.js
settings.jsonSupported extensions: .sh (shell, requires bash) and .js (Node.js). When both exist, .sh takes priority. Note: .js files run according to Node's module resolution rules (ESM or CommonJS depends on the nearest package.json with "type": "module").
Scripts receive the RACE_DIR environment variable pointing to the race directory. Per-racer setup and teardown scripts also receive each entry from that racer's vars (in settings.json) as RACE_VAR_<KEY> β useful for sharing one script across racers that only differ by a commit hash, branch name, etc.
Settings-based configuration
For more control, configure scripts in settings.json with timeouts and service readiness polling:
{
"setup": {
"command": "./start-server.sh",
"timeout": 120000,
"waitFor": {
"url": "http://localhost:3000/health",
"timeout": 30000,
"interval": 1000
}
},
"teardown": "./stop-server.sh",
"racers": {
"contender-a": {
"setup": "./seed-db.sh",
"teardown": "./cleanup-db.sh"
}
}
}| Field | Type | Description |
|---|---|---|
| setup / teardown | string or object | Script path (relative to race dir) or config object |
| command | string | Script path when using object form |
| timeout | number | Max execution time in ms (default: 300000) |
| waitFor.url | string | Poll this URL after script completes |
| waitFor.timeout | number | Max polling time in ms (default: 30000) |
| waitFor.interval | number | Polling interval in ms (default: 1000) |
Execution order
- Global setup
- For each racer, in order:
- Per-racer setup (e.g.,
contender-a.setup.sh) - All runs of that racer
- Per-racer setup (e.g.,
- Per-racer teardown (for each racer, even on failure)
- Global teardown (even on failure)
When per-racer setup scripts exist, racers run one at a time (split mode) so each setup can prepare the environment before its racer's runs. Without per-racer setups, all racers run together in each run.
Set setup or teardown to false or "" in settings to explicitly disable a discovered script.
Prerequisites
- Node.js 18+ (required)
- FFmpeg (optional β only needed with
--ffmpegfor physical video trimming, side-by-side merging, and format conversion)
FFmpeg is not required for normal use. The HTML player handles virtual trimming with frame-accurate canvas-based calibration and includes a client-side Export button for creating side-by-side videos directly in the browser.
See the Installation Guide for detailed setup instructions on every platform.
Project Structure
RaceForThePrize/
βββ race.js # π Main entry point β the race director
βββ runner.cjs # Playwright automation engine
βββ sync-barrier.cjs # Parallel mode checkpoint synchronization
βββ visual-stability.cjs # Wait-for-visual-stability detection logic
βββ trace-calibration.cjs # Derive timing from Chrome performance traces
βββ cli/
β βββ animation.js # Live terminal racing animation
β βββ colors.js # ANSI color palette
β βββ config.js # Argument parsing & racer discovery
β βββ profile-analysis.js # CDP performance metrics collection & analysis
β βββ player-runtime.js # HTML player client-side runtime (canvas calibration)
β βββ player-sections.js # HTML player template sections
β βββ race-utils.js # Shared race utility helpers
β βββ results.js # File management & video conversion
β βββ summary.js # Results formatting & markdown reports
β βββ sidebyside.js # FFmpeg video composition (--ffmpeg)
β βββ videoplayer.js # Interactive HTML player with clip-based trimming
βββ races/
β βββ lauda-vs-hunt/ # π Example: the greatest rivalry in racing
β βββ lebron-vs-curry/ # π Example: the GOAT debate, dribble-style
β βββ react-vs-angular/ # βοΈ Example: frontend framework showdown (4 racers)
βββ presentation/
β βββ slides.md # Marp slide deck
β βββ script.md # Speaker notes (7 slides, ~7 min)
βββ tests/ # Unit tests (vitest)
βββ integration/ # Integration tests (vitest)
βββ package.jsonPresenting RaceForThePrize
The presentation/ folder contains a ready-to-use slide deck and speaker script for introducing the tool to an audience.
slides.mdβ 7-slide Marp deck covering positioning, the race API, live demo, and resultsscript.mdβ Speaker notes with timing guidance, audience adaptation tips, and key phrases to land
Generate slides with Marp:
npx @marp-team/marp-cli presentation/slides.md --html -o presentation/slides.htmlRunning Tests
npm test # Unit tests (vitest)
npm run test:integration # Integration tests (calibration, trimming)
npx vitest run tests/summary.test.js # Run a single test fileStanding on the Shoulders of Giants
- Built by @kertal and his agents The Flaming Bits. More humans with or without agents are welcome!
- Built on top of the mighty Playwright β the browser automation framework that makes all of this possible.
- Built with support of the great "Race for the Prize" song by The Flaming Lips.
License
MIT
