Skip to content
pyjeebzPublic

About

The open-source load tester that tells you why.

Topics

Resources

Code of conduct

Stars

7 stars

Watchers

0 watching

Forks

Repository files navigation

PreScale

PreScale

The open-source load tester that tells you why.

Point it at a URL — it finds what breaks first, at what traffic, and how to fix it. No scripts. No account. Runs on your machine.

CI PyPI Python License

PreScale ramps load against a URL, finds the route that breaks first, and prints a plain-English diagnosis

A real run against a fragile demo app — ramp → first break → diagnosis.


A launch, a marketing push, a Hacker News front page, a feature-flag flip — traffic spikes and your app falls over: 500s, exhausted DB connections, a surprise bill, or just dead during the hour that mattered. PreScale tells you what breaks before that happens.

Point it at a URL. It ramps real traffic until something gives, then tells you, in plain English, what failed first, at what load — and why.

curl -fsSL https://prescale.dev/install | sh
prescale investigate https://staging.myapp.com
Scale readiness: ⚠️  Survives ~90 (75–110) concurrent users

                          Load ramp
 Users   Req/s    p50    p95   vs 2s wall           p99   Errors
    10     520   18ms   31ms   █            ┆        44ms      0%
    50     610   46ms  120ms   █            ┆       210ms      0%
    90     590   80ms  240ms   ██           ┆       900ms      0%
   150     410  180ms   2.1s   ░░░░░░░░▒▒▒██┆         3.4s      7%   <- breaks here

Ramp            ░░░░░░░░░▒▒▒██             ~90 of 150 users tested
Traffic         ≈ 590 req/s, roughly 5,900 real visitors (one request per visitor every 10s)

First failure   errors climb at ~150 users
Latency wall    p95 crosses 2s at ~150 users
Likely cause    Server returned 5xx under load — likely an unhandled overload
                (DB connection pool, worker queue, or an uncaught error path).

Illustrative output — your numbers depend on your app. Each bar is PreScale's mark in text: sparse ░ → ▒ → a solid █ end at the measured value, running toward the latency wall (┆).

It isn't a framework you program (like k6 or Locust) — there's nothing to script. Point it, get an answer, fix, repeat.

Why PreScale

  • Zero config. No test scripts, no YAML, no account. One command against a URL.
  • Stack-agnostic. It tests a URL, so it doesn't care what's behind it: Fly, Railway, Kubernetes, a VPS, your laptop. It checks your host's rules before sending load.
  • An answer, not a histogram. "You're good to ~90 users, your DB is the wall" — and investigate tells you how to fix it.
  • Honest about uncertainty. The verdict is a calibrated range with a stable/uncertain flag, not false precision.
  • Safe by default. It won't hammer a non-local host until you confirm you own it.

Install

curl -fsSL https://prescale.dev/install | sh          # macOS and Linux
irm https://prescale.dev/install.ps1 | iex           # Windows (PowerShell)

No Python needed: the installer uses uv to put PreScale in its own isolated environment, bringing its own Python if your machine doesn't have one. Run it again to upgrade. It's short; read it first if you like.

Prefer pip? Python 3.10+:

pip install prescale             # the CLI
pip install 'prescale[mcp]'      # + the MCP server for coding agents

From source: git clone https://github.com/pyjeebz/PreScale.git && pip install ./PreScale/cli

Commands

Command What it does
run <url> Ramp traffic; report what breaks first and at what load
investigate <url> …and why it breaks, plus how to fix it (--app to start your app first: replica mode)
audit <url> Load-free HTTP hygiene check (compression, caching, CDN, HTTP/2)
journeys List your journey files; journeys verify <url> checks they still replay. run replays them at load
compare Capacity diff of two runs; regression gating for CI
profiles Launch scenarios for --profile
history / show List and re-open saved runs
schema Print the JSON Schema for a saved run
mcp Run as an MCP server for coding agents

run — what breaks first

# Local app, quick check
prescale run http://localhost:8000

# Specific routes, gently, on staging
prescale run https://staging.myapp.com --path /api/search --path /pricing --max-rps 200 --i-own-this

# Ramp harder, machine-readable
prescale run https://staging.myapp.com -u 500 --i-own-this --json

# A shareable, self-contained HTML report
prescale run https://staging.myapp.com --i-own-this --html report.html
All run options
Option Default Description
-u, --max-users 200 Peak virtual users to ramp to
-s, --stage-seconds 5 Seconds to hold each load level
--path — Extra route to test, relative to URL (repeatable)
--from-sitemap off Also pull GET routes from the site's sitemap.xml
--profile — Frame the run as a launch scenario (see prescale profiles)
--latency-wall 2.0 p95 latency (s) treated as failure
--error-threshold 0.02 Error rate (0–1) treated as failure
-m, --method GET HTTP method to fire
--timeout 10 Per-request timeout (s)
--max-rps — Cap aggregate requests/sec (a safety ceiling)
--no-warmup (warmup on) Skip the brief warmup before measuring
--repeat N 1 Run the whole ramp N times and pool results (tightens the band)
--think-time S 0 Seconds each virtual user pauses between requests
--fail-under N — Exit non-zero if it survives fewer than N users (a CI gate)
--i-own-this off Skip the confirmation prompt for non-local targets
--ignore-robots off Skip the robots.txt courtesy check
--json off Emit the full versioned Result as JSON
--html PATH — Write a shareable HTML report (single self-contained file)
--store DIR ./.prescale Directory for saved runs
--no-save off Don't save this run to .prescale/runs/

investigate — and why

run tells you what breaks; investigate tells you why and how to fix it. It finds the culprit route, then probes it — baseline vs loaded latency, a static-vs-dynamic comparison, error/header forensics — to classify the bottleneck and prescribe a fix. Fully local, deterministic, no LLM.

prescale investigate http://localhost:8000
prescale investigate          # re-investigate the latest saved run
🔬 Diagnosis
Likely cause: 5xx under load while static assets held — the app/backend is the wall (often a DB or upstream pool).
Bottleneck  connection_pool (high confidence)  ·  culprit /api/search
Evidence
  • culprit p95 28ms at 1 user vs 2100ms at 150 users
  • static /assets/app.js held at 150 users
  • errors under load: 5xx (180)
Try this
  → Increase the DB/upstream connection-pool size.
  → Add a pooler (e.g. pgbouncer) and check the pool checkout timeout.

With journeys in prescale/journeys/, investigate diagnoses the journey step that breaks first instead, reaching it the way a user does (logged in, cart filled): culprit checkout, step 3 place order. The journey's other steps play the part of the static assets. --urls investigates URLs.

Every diagnosis ends with a fix prompt, plain text to paste into your coding agent (--json | jq -r .fix_prompt); the PR comment folds it under the diagnosis.

journeys — your users' real paths

A journey is a path a user takes through your app (log in, add to cart, place an order), saved as the requests it makes. Journeys live in your repo as prescale/journeys/<name>.yaml:

version: 1
name: checkout
type: checkout
steps:
  - name: log in
    request:
      method: POST
      path: /api/login
      json: {email: "{{unique.email}}", password: "{{env.SHOP_PASSWORD}}"}
    expect: {status: 200}
    extract: {token: body.token}
  - name: place order
    request:
      method: POST
      path: /api/orders
      headers: {Authorization: "Bearer {{token}}"}
    expect: {status: 201}
prescale journeys                                  # list them
prescale journeys verify http://localhost:3000     # replay each once, as one user

verify stops at the first step that doesn't answer as expected and calls the journey stale: the app changed, or it's broken. It exits non-zero, so it works as a CI check.

When prescale/journeys/ has journeys, prescale run replays them at load: each virtual user walks a whole journey (logs in, adds to cart, orders), pauses for each step's think time, then starts again as a new user. Each journey is ramped on its own, so its verdict doesn't depend on any traffic mix, and the report names the journey and the step that break first:

                    Journeys (each ramped on its own)
 Journey     Survives   Breaks at step                         Why
 browse   400+ (held)   —
 checkout ~20 (20–30)   3. place order  POST /api/carts/{{cart_id}}/order
                                                    Server returned 5xx under load…

A stale journey isn't load-tested (a failure with no load says nothing about capacity); it's listed with the reason. --journey NAME picks journeys, and --urls (or --path, --from-sitemap) tests URLs instead.

Then all journeys run together, the users split between journeys by weight (each user stays on its journey, so a weight is that journey's share of the users in the app at once) (defaults by type: browse 60, search 25, account 8, checkout 5, write 5, auth 2; edit them in the files). That catches interference: journeys competing for something they share. If a journey breaks with fewer of its own users than it held alone, the report says so:

Interference  checkout broke with 15 of its own users, but held 20 alone.
              It competes with quickbuy for something they share (a database pool, CPU, a cache).

prescale show, --html reports and the MCP tools show journeys too, and prescale compare diffs each journey: a drop past the threshold in any journey is a regression (so --fail-on-regression catches it), even when it isn't the weakest one; it's marked confident when the confidence bands don't overlap. A journey that held everything it was tested to, below the old level, is inconclusive, not a drop. A URL run and a journey run measure different things, so comparing them is reported as not comparable.

In CI, prescale ci (and the GitHub Action) replays the checkout's journeys instead of URLs whenever prescale/journeys/ exists, and the PR comment shows each journey's verdict, any interference, and journeys that no longer replay. Those are always listed; --stale fail (Action input stale: fail) also fails the check. With app: the replica is local, so journeys that change data can run; against a preview host they need allow-writes. Journey secrets ({{env.NAME}}) come from the step's env:.

--no-combined skips this pass. --expect 500 says whether you'd hold 500 users at peak: in a journey run, each journey must hold its share of them (by weight) on its own, and all journeys together must hold 500; a journey that wasn't load-tested makes the answer unknown. It reports, it doesn't change the exit code (use --fail-under to gate CI).

  • {{name}} is a value an earlier step pulled out with extract (body.<field> or header.<name>); {{unique.email}} and {{unique.id}} are fresh each run; {{env.NAME}} reads an environment variable.
  • Secrets never go in the file. A literal password, token, API key or Authorization value is refused; use {{env.NAME}}.
  • Paths start with / and are relative to the URL you give (http://host/app + /api/cart is http://host/app/api/cart). A journey only calls that app: a request or redirect to another host fails the step.
  • Journeys that change data (POST, PUT, PATCH, DELETE) run against local hosts, or a host you name with --allow-writes staging.myapp.com (on verify and run). Types side_effect (emails, SMS, invites) and destructive are never replayed.
  • Each run of a journey has its own cookies and extracted values, so one user's login never leaks to another.

Try it on the example shop: python examples/fragile-shop.py 8400, then SHOP_PASSWORD=demo prescale journeys verify http://localhost:8400 --dir examples/journeys. Its flash-sale journey has everyone buy the same product, which locks one stock row: prescale investigate http://localhost:8400 --dir examples/journeys --journey flash-sale names it serialized, one request at a time.

audit — hygiene, no load

A fast, load-free check of the HTTP-level footguns that decide how you scale — compression, static-asset caching, CDN, HTTP version, cookies on assets. Cheap enough to run on every commit.

prescale audit https://myapp.com
✓ Compression           Responses are compressed (br).
⚠ HTTP version          Served over HTTP/1.1.
⚠ Static asset caching  2 of 6 sampled assets have no caching headers.
✓ CDN / edge cache      Detected (Cloudflare).

Frame it as a launch

Abstract user counts are hard to act on, so name the scenario with --profile:

prescale run https://staging.myapp.com --i-own-this --profile product-hunt
prescale profiles      # list scenarios
Launch  🛑 a Product Hunt #1 launch: unlikely (peaks ~100, you break at ~90).

Profiles (steady-10k-dau, product-hunt, reddit, hn-frontpage, black-friday) set a realistic peak concurrency + think-time and frame the verdict against it.

Saved runs, and the JSON contract

Every run is saved to ./.prescale/runs/<id>.json — a single versioned record (config, verdict, and per-level/per-route metrics). Re-open or share past runs without re-testing:

prescale history                  # list saved runs, newest first
prescale show                     # re-render the most recent run
prescale show <id> --html r.html  # re-render a specific run to HTML
prescale schema                   # the JSON Schema for a saved run

prescale run --json and the saved .json are the same shape, so a result is easy to script against or hand to another tool. Use --store DIR to change where runs are kept, --no-save to skip saving, and gitignore .prescale/.

Replica mode — test your app, not your host

Many hosts restrict load testing (Vercel only permits it on Enterprise plans), and what breaks under load is usually your app's code and database, not the CDN. So PreScale can start your app's own production build, test it on localhost, and stop it:

prescale investigate http://localhost:3000 --app "npm run build && npm start"

PreScale runs the command in your project, waits until the URL answers (--app-timeout, default 600s, since builds take a while), runs the test, then stops the app and everything it started, even on errors or Ctrl-C. The app uses your environment, so point DATABASE_URL and API keys at a dev or staging database. Results say they were measured on a local replica (no serverless cold starts), and the command itself is never saved or uploaded.

In CI it needs no deploy at all: build the pull request in the job and test it there.

# .github/workflows/capacity.yml
on:
  pull_request:
  push:
    branches: [main]          # the baseline PRs compare against

jobs:
  capacity:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - uses: pyjeebz/prescale/action@v1
        with:
          token: ${{ secrets.PRESCALE_TOKEN }}
          target: myapp.com
          app: npm run build && npm start
        env:
          DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}

CI — gate on capacity

Fail a build if capacity drops below a floor:

- run: pip install prescale
- run: prescale run https://staging.myapp.com --i-own-this --fail-under 100

Or catch regressions against a committed baseline (just a saved Result JSON):

# on main — refresh and commit the baseline
- run: prescale run https://staging.myapp.com --i-own-this --json > prescale-baseline.json

# on PRs — run, compare, and comment
- run: prescale run https://staging.myapp.com --i-own-this
- run: prescale compare --baseline prescale-baseline.json --fail-on-regression --markdown > cmp.md
- run: gh pr comment "${{ github.event.number }}" --body-file cmp.md
  env:
    GH_TOKEN: ${{ github.token }}

compare diffs the latest saved run against the baseline; the regression check uses the confidence band, so it won't fail the build on noise.

Coding agents (MCP)

PreScale ships an MCP server so an AI coding agent can load-test mid-build — point it at your local preview URL, get a verdict, fix, repeat.

curl -fsSL https://prescale.dev/install | PRESCALE_EXTRAS=mcp sh   # or: pip install 'prescale[mcp]'
claude mcp add prescale -- prescale mcp     # Claude Code

It exposes load_test, investigate, audit, list_runs, and get_run tools that return the same compact verdict you get on the CLI. Safe by default: the agent can only load-test local hosts unless you allowlist others with PRESCALE_MCP_ALLOW=staging.myapp.com (or prescale mcp --allow staging.myapp.com).

What gets uploaded

The CLI works fully offline. Nothing leaves your machine unless you pass --upload, run prescale upload, or use the GitHub Action. An upload is the saved run file (.prescale/runs/<id>.json), unchanged:

  • the verdict: users survived, confidence band, where and why it broke
  • per-stage and per-route timings, error rates and request counts
  • the diagnosis and suggested fixes, if you ran investigate, and HTTP hygiene findings, if you ran --audit
  • the target URL, the route paths you tested, the git commit and branch, and the CLI version

Never sent: request and response bodies, cookies, header values you pass with -H (auth tokens, Vercel bypass secrets), environment variables, or anything from your codebase. See the exact payload first with prescale upload --dry-run.

Runs are private to your account. A run is public only if you share it (an unguessable link you can take down) or sign it off. API tokens are stored as hashes; the token itself is shown once.

How it works

  1. Preflight — one request to confirm the URL is reachable, then a brief warmup.
  2. Ramp — increase virtual users step by step (1 → max), holding each level briefly.
  3. Measure — throughput, latency percentiles, and error kinds at every level.
  4. Report — find the first level that crosses the error or latency threshold, put a confidence band on it, and explain the likely cause in plain English. investigate then probes the culprit for a root cause.

It's self-contained (httpx + asyncio) — no external load tool or server required.

⚠️ Use it on what you own

Load testing sends real traffic and can cause real outages or bills. PreScale defaults to safe — it prompts before hitting any non-local host (and the MCP server refuses non-local hosts unless you allowlist them). Point it at a staging / preview URL, not production, unless you know what you're doing.

Check your host's rules first. Some hosts restrict load testing: Vercel only permits it on Enterprise plans with prior notice, Netlify needs its express permission, and Cloudflare blocks it unless arranged. PreScale recognises these hosts from their first response and won't send load until you confirm you have permission (--host-permission; in the GitHub Action, host-permission: true). Otherwise, test your app locally or on staging you're allowed to load. Behind a CDN, PreScale also spots firewall blocks and cached responses, so the CDN's limits aren't blamed on your app.

Changelog

See CHANGELOG.md. Latest: 0.2.0.

Contributing

Issues and PRs welcome — see CONTRIBUTING.md.

License

Apache 2.0 — see LICENSE.

About

The open-source load tester that tells you why.

Topics

Resources

Code of conduct

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages