WebStress is a controlled, Go-based HTTP performance and bottleneck analysis tool for systems you own or are explicitly authorized to test. It goes beyond raw request generation: it tells you at which load level degradation begins, whether that degradation appears client-side (generator saturation) or server-side, and which endpoint is responsible.
Use only on systems you own or have explicit written authorization to test. WebStress will not run without the
--authorizedflag. Public targets can additionally require a served ownership token before load starts.
See README_TR.md for the full Turkish guide and docs/ for architecture, security, and testing notes.
- Features
- Requirements
- Installation
- Quick Start
- Load Models
- Session Flows
- Assertions
- Dynamic Variables
- Safety Limits
- Automatic Abort
- HTTP Timing Breakdown
- Live Dashboard
- Reports
- Public Target Ownership Verification
- Prometheus Correlation
- Redirect Safety
- Proxy Behaviour
- TLS
- Secret Redaction
- Error Classes
- Generator Saturation vs Server Bottleneck
- Design Limits
- CI / CD Integration
- Example Configs
- Build & Test
- Developer
- License
| Category | Capability |
|---|---|
| Load models | Open arrival-rate (RPS) and concurrent-user (virtual users) |
| Stages | Constant, linear ramp, multi-stage pipelines |
| Traffic | Weighted endpoint distribution |
| Sessions | Per-worker isolated cookie jars for authenticated flows |
| Assertions | Status code range + body content checks |
| Variables | {{timestamp}}, {{random_int}}, {{random_uuid}} |
| Timings | DNS · TCP connect · TLS · connection wait · TTFB · server wait · total |
| Histograms | p50 / p90 / p95 / p99 / p99.9 — fixed-memory buckets |
| Error classes | timeout · dns · tls · connection_refused · connection_reset · redirect_blocked · unexpected_status · assertion · body_read · body_too_large · canceled |
| Dropped requests | Measures scheduler saturation; separates generator limit from server limit |
| Safety abort | Auto-stop on error rate, timeout rate, p99, or dropped-rate thresholds |
| Dashboard | Live localhost HTML dashboard + JSON API |
| Reports | Self-contained HTML report + raw JSON report |
| Analysis | Breakpoint detection, tail-latency spread, hotspot identification, phase attribution |
| Prometheus | Optional exposition endpoint scrape for CPU/memory/goroutine correlation |
| Security | Authorization flag · host allowlist · loopback-only dashboard · opt-in proxy · secret redaction |
- Go 1.23+ (for building from source)
- Linux, macOS, or Windows (pre-built binaries available in
dist/)
Pre-built binaries are available in the dist/ directory for all major platforms:
| File | Platform |
|---|---|
dist/webstress-linux-amd64 |
Linux x86-64 |
dist/webstress-linux-arm64 |
Linux ARM64 (e.g. Raspberry Pi, AWS Graviton) |
dist/webstress-darwin-amd64 |
macOS Intel |
dist/webstress-darwin-arm64 |
macOS Apple Silicon (M1/M2/M3) |
dist/webstress-windows-amd64.exe |
Windows x86-64 |
# x86-64
cp dist/webstress-linux-amd64 /usr/local/bin/webstress
chmod +x /usr/local/bin/webstress
# ARM64
cp dist/webstress-linux-arm64 /usr/local/bin/webstress
chmod +x /usr/local/bin/webstressVerify:
webstress version
webstress about# Apple Silicon (M1/M2/M3)
cp dist/webstress-darwin-arm64 /usr/local/bin/webstress
chmod +x /usr/local/bin/webstress
# Intel
cp dist/webstress-darwin-amd64 /usr/local/bin/webstress
chmod +x /usr/local/bin/webstressmacOS may show a Gatekeeper warning the first time you run the binary. To allow it: System Settings → Privacy & Security → Allow Anyway, or run:
xattr -dr com.apple.quarantine /usr/local/bin/webstress
Verify:
webstress version
webstress aboutCopy dist/webstress-windows-amd64.exe to a directory that is on your PATH
(for example C:\Tools\) and rename it to webstress.exe for convenience:
# PowerShell
Copy-Item dist\webstress-windows-amd64.exe C:\Tools\webstress.exeOr run it directly without installing:
.\dist\webstress-windows-amd64.exe version
.\dist\webstress-windows-amd64.exe aboutRun a test on Windows:
.\dist\webstress-windows-amd64.exe validate -config examples\smoke-local.json
.\dist\webstress-windows-amd64.exe run -config examples\smoke-local.json --authorizedThe live dashboard is available at
http://127.0.0.1:8787on Windows as well. Reports are written towebstress-reports\in the current directory.
Before using a binary, verify its integrity with the provided SHA256 checksums:
# Linux / macOS
sha256sum -c dist/SHA256SUMS
# macOS (if sha256sum is not available, use shasum)
shasum -a 256 -c dist/SHA256SUMS# Windows PowerShell — verify a single binary
Get-FileHash dist\webstress-windows-amd64.exe -Algorithm SHA256
# Compare the output hash against the value in dist/SHA256SUMSgit clone https://github.com/cumakurt/webstress.git
cd webstress
make build
# binary → dist/webstressCross-compile all platform targets at once:
make release
# produces all binaries in dist/Verify the build:
./dist/webstress version
./dist/webstress about1. Validate your config first:
./dist/webstress validate -config examples/smoke-local.json2. Run against a local target:
./dist/webstress run -config examples/smoke-local.json --authorized3. Open the live dashboard (while the test is running):
http://127.0.0.1:8787
4. Check the reports after the test finishes:
webstress-reports/smoke-YYYYMMDD-HHMMSS.html
webstress-reports/smoke-YYYYMMDD-HHMMSS.json
1. Validate your config:
.\dist\webstress-windows-amd64.exe validate -config examples\smoke-local.json2. Run a test:
.\dist\webstress-windows-amd64.exe run -config examples\smoke-local.json --authorized3. Open the live dashboard (while the test is running):
http://127.0.0.1:8787
4. Check the reports:
webstress-reports\smoke-YYYYMMDD-HHMMSS.html
webstress-reports\smoke-YYYYMMDD-HHMMSS.json
If you copied the binary to
C:\Tools\webstress.exeand addedC:\Toolsto yourPATH, you can simply usewebstressinstead of the full binary path in all commands above.
The scheduler drives a target RPS regardless of how long responses take. This prevents the closed-loop effect where a slow server naturally receives fewer requests and the bottleneck is hidden.
Constant stage:
{"name": "baseline", "duration": "30s", "rps": 100}Linear ramp:
{"name": "ramp", "duration": "60s", "rps_start": 100, "rps_end": 1000}Full example:
"load": {
"model": "arrival-rate",
"stages": [
{"name": "warmup", "duration": "20s", "rps": 25},
{"name": "ramp", "duration": "90s", "rps_start": 50, "rps_end": 1000},
{"name": "hold", "duration": "60s", "rps": 1000},
{"name": "cooldown", "duration": "15s", "rps": 25}
]
}When the scheduler cannot dispatch a request because max_concurrency is exhausted, it records the request as dropped instead of silently queuing it. A rising dropped-rate means the load generator — not the server — is saturated at that concurrency ceiling.
Each worker acts as an independent virtual user. Workers run continuously (with optional think time) for the stage duration.
"load": {
"model": "concurrency",
"concurrency": 20,
"think_time_ms": 250,
"stages": [
{"name": "warmup", "duration": "20s", "concurrency": 5},
{"name": "normal", "duration": "60s", "concurrency": 20},
{"name": "stress", "duration": "60s", "concurrency": 50}
]
}Flows model real authenticated user journeys. Each virtual user gets its own cookie jar — cookies set in step N are available in step N+1.
"flows": [
{
"name": "login-and-browse",
"weight": 1,
"think_time_ms": 150,
"continue_on_error": false,
"steps": [
{
"name": "login",
"method": "POST",
"path": "/login",
"body": "{\"username\":\"loaduser\",\"password\":\"REPLACE_ME\"}",
"expect": {"status_min": 200, "status_max": 299}
},
{
"name": "profile",
"method": "GET",
"path": "/profile",
"expect": {"status_min": 200, "status_max": 299}
},
{
"name": "item",
"method": "GET",
"path": "/items/{{random_int}}",
"pause_ms": 300,
"expect": {"status_min": 200, "status_max": 404}
}
]
}
]
trafficandflowscannot be used together in the same config. Flows require theconcurrencyload model to preserve session semantics.
Each endpoint or flow step can declare expected response behaviour:
"expect": {
"status_min": 200,
"status_max": 299,
"contains": "expected text"
}A request that receives a transport-level response but fails an assertion is counted as an error (unexpected_status or assertion class). This prevents passing load tests that silently return 500s.
Use these placeholders in path, body, and request headers:
| Variable | Generates |
|---|---|
{{timestamp}} |
Current Unix timestamp (seconds) |
{{random_int}} |
Random non-negative integer |
{{random_uuid}} |
RFC 4122 UUID v4 |
Example:
{"name": "search", "method": "GET", "path": "/api/items/{{random_int}}?ts={{timestamp}}"}Hard ceilings that stage values cannot exceed:
"limits": {
"max_rps": 2500,
"max_concurrency": 2000,
"max_body_bytes": 2097152
}max_rps— maximum requests per second across all stagesmax_concurrency— maximum simultaneous in-flight requests; excess arrivals are counted asdroppedmax_body_bytes— response bodies larger than this are truncated and counted asbody_too_large
WebStress can abort the test automatically when sustained degradation is detected:
"abort": {
"error_rate": 0.05,
"timeout_rate": 0.02,
"p99_ms": 5000,
"dropped_rate": 0.05,
"consecutive_windows": 3,
"min_samples": 50
}| Field | Meaning |
|---|---|
error_rate |
Fraction of requests ending in any error class |
timeout_rate |
Fraction of requests ending in timeout specifically |
p99_ms |
p99 latency ceiling in milliseconds |
dropped_rate |
Fraction of scheduled requests that were dropped |
consecutive_windows |
Number of consecutive 1-second windows that must all breach a threshold before aborting — prevents single-spike false positives |
min_samples |
Minimum requests in a window before abort logic activates |
WebStress captures the full Go httptrace lifecycle for every request:
| Phase | What it measures |
|---|---|
| DNS | Name resolution duration |
| TCP connect | Socket establishment |
| TLS | TLS handshake |
| Connection wait | Time the client waited to acquire a connection from the pool |
| TTFB | Total time from request start to first response byte received |
| Server wait | Time from last request byte written to first response byte — the closest client-visible proxy for backend processing time |
| Total | Time to read the full response body (up to max_body_bytes) |
The analyzer uses these phases to produce directional hints:
- High server wait / total ratio → likely backend processing bottleneck
- High connection wait → connection pool pressure or connection exhaustion
- High DNS or TLS → infrastructure-level latency
- Low connection reuse ratio → keep-alive issues or frequent reconnects
These are probability-weighted hints, not root-cause proof. Validate with Prometheus metrics or distributed tracing.
While a test is running, a dashboard is available at http://127.0.0.1:8787 (or the address set in live.listen).
The dashboard shows in real time:
- Active stage name and target RPS
- Achieved RPS
- p50 / p95 / p99 latency
- Error rate and timeout rate
- Dropped request count
- Per-endpoint request count, error count, p95, p99
- Load timeline chart
The JSON API endpoint (/api/status) returns the same data in machine-readable form.
Security: the dashboard can only bind to localhost / loopback addresses. Binding to a public interface is rejected at startup.
Configure:
"live": {
"enabled": true,
"listen": "127.0.0.1:8787"
}After the test completes, two files are written to the report directory:
webstress-reports/<prefix>-YYYYMMDD-HHMMSS.html
webstress-reports/<prefix>-YYYYMMDD-HHMMSS.json
Self-contained — no external CDN or JavaScript dependencies. Suitable for archiving and sharing. Contains:
- Total request count and average throughput
- Error rate, timeout rate
- p50 / p95 / p99 / max latency
- Load curve (RPS over time)
- Observed sustainable and breakpoint RPS estimates
- Analyzer findings with directional hints
- HTTP phase averages (DNS, TCP, TLS, connection wait, server wait, total)
- Per-endpoint summary table
- Safety preflight and abort details
Raw structured output suitable for CI comparison, diff tooling, or post-hoc analysis. Includes the full timeline, per-second buckets, Prometheus metric snapshots (if enabled), and all analyzer findings.
Configure:
"report": {
"directory": "./webstress-reports",
"prefix": "staging-ramp"
}To prevent accidental or unauthorized use against public systems, WebStress can require a served token at a well-known path before load begins.
Config:
"verification": {
"required_for_public": true,
"path": "/.well-known/webstress-verify.txt",
"token": "REPLACE_WITH_A_LONG_RANDOM_TOKEN"
}The target web server must return the exact same token as plain text at:
https://your-target.example.com/.well-known/webstress-verify.txt
If the token does not match, load does not start. The token is redacted from all reports.
WebStress can scrape a Prometheus exposition endpoint and embed the time series in the JSON report alongside load data:
"telemetry": {
"prometheus_url": "http://127.0.0.1:9100/metrics",
"interval": "2s",
"metrics": {
"process_cpu": "process_cpu_seconds_total",
"process_memory": "process_resident_memory_bytes",
"go_goroutines": "go_goroutines"
}
}If a metric has multiple label combinations (e.g. per-CPU), their values are summed. Know the semantics of the metrics you select.
This enables correlating load curves with CPU saturation, memory growth, or goroutine leaks without needing a separate Grafana setup for the test run.
HTTP redirects are followed only to hosts listed in target.allowed_hosts. A redirect to any other host results in a redirect_blocked error and the request is not followed. This prevents a test from accidentally driving load to a third-party domain.
"target": {
"base_url": "https://staging.example.com",
"allowed_hosts": ["staging.example.com"]
}HTTP_PROXY and HTTPS_PROXY environment variables are ignored by default. Test traffic is sent directly to the target.
To intentionally route through a proxy (only when you are also authorized to load the proxy):
"target": {
"use_proxy_env": true
}TLS certificate verification is enabled by default. Only disable it for internal test environments with self-signed certificates:
"target": {
"insecure_tls": true
}Never use insecure_tls: true against production-like targets.
Before writing reports, WebStress redacts:
Authorizationheader valuesCookieheader values- Any header whose name contains
token,secret,api-key, orapikey - The ownership verification token
- Request body content
Store reports in a trusted location. Avoid embedding sensitive data in URL paths or query strings, as those are not redacted.
| Class | Cause |
|---|---|
timeout |
Request exceeded timeout_ms |
dns |
DNS resolution failure |
tls |
TLS handshake failure |
connection_refused |
Target actively refused the connection |
connection_reset |
Connection reset by peer mid-request |
redirect_blocked |
Redirect target not in allowed_hosts |
unexpected_status |
Response status outside expect.status_min/status_max |
assertion |
Response body did not match expect.contains |
body_read |
Error reading response body |
body_too_large |
Response body exceeded max_body_bytes |
canceled |
Request canceled (e.g. due to safety abort) |
One of WebStress's key design goals is distinguishing between the load generator being the bottleneck and the server being the bottleneck.
- In arrival-rate mode, if in-flight requests pile up and hit
max_concurrency, new scheduled requests are counted asdroppedrather than queued silently. - A rising
dropped_ratemeans the load generator itself cannot sustain the target RPS at the current concurrency ceiling — the server result at that point is inconclusive. - Remedy: increase
max_concurrency, distribute load across multiple generator instances, or reduce target RPS.
The analyzer reports this explicitly so you don't misinterpret generator saturation as a server capacity finding.
WebStress produces strong signals from the client's perspective and optionally from Prometheus metrics. It cannot see:
- SQL query plans or lock contention inside the application
- Individual downstream service call latencies
- Specific garbage collection pauses
- Internal queue depths
Analyzer findings are "evidence-weighted probabilities" — not definitive root causes. Confirm with distributed tracing or application-level observability.
The JSON report exit code is non-zero when the test aborts due to safety thresholds. Use this in CI to gate deployments:
# GitHub Actions example
- name: Run load test
run: ./dist/webstress run -config examples/smoke-local.json --authorized
- name: Check report
run: |
REPORT=$(ls webstress-reports/*.json | tail -1)
python3 -c "
import json, sys
r = json.load(open('$REPORT'))
assert r['summary']['error_rate'] < 0.01, 'error rate too high'
assert r['summary']['p99_ms'] < 500, 'p99 too high'
print('Load test passed')
"For structured diffing between runs, compare the JSON report summary object across commits.
| File | Description |
|---|---|
examples/smoke-local.json |
Minimal 5-second smoke test against localhost |
examples/ramp-public.json |
Multi-stage arrival-rate ramp with ownership verification and Prometheus scraping |
examples/session-flow.json |
Concurrency model with authenticated session flow (login → profile → browse) |
make test # unit tests
make race # race detector
make vet # static analysis
make build # compile → dist/webstressCross-platform binaries:
make release # builds all platform targets into dist/Validate a config without running a test:
./dist/webstress validate -config examples/smoke-local.json- Developer: Cuma KURT
- Email: cumakurt@gmail.com
- Project repository: https://github.com/cumakurt/webstress
- LinkedIn: https://www.linkedin.com/in/cuma-kurt-34414917/
Display embedded binary metadata:
./dist/webstress aboutWebStress is licensed under the GNU General Public License v3.0 only (GPL-3.0-only).
See LICENSE for the complete license text.