I originally wrote this script after discovering that curl can independently measure each phase of an HTTP connection. I've since vibe-coded it into something considerably more complete and robust.
A live, per-phase HTTP timing probe, like curl -w on steroids using pycurl. Each timing
field prints the moment it becomes available, so a hung request visibly stalls
at exactly the phase where it's stuck rather than silently timing out.
- Live streaming output - each phase prints as it completes, not all at once at the end
- Per-phase deltas - every column is the duration of that phase only, not a cumulative total
- Redirect accounting - a
REDIRECTcolumn shows count and total time when redirects are followed, explaining whyTOTAL TIMEcan exceed the sum of the other columns - Failure markers -
<DNS-FAIL>,<CONN-FAIL>,<TLS-FAIL>,<TO>, and more - printed at exactly the phase that failed - Clear empty-cell conventions - a dim
n/amarks a phase that structurally doesn't apply (e.g.TLS HANDSHAKEon plainhttp://, orREDIRECTwhen none occurred); a dim-marks a field that's empty for any other reason - IP pinning - pin repeated requests to one IP to avoid measuring different backends across a DNS round-robin
- Streaming / chunked-transfer testing -
-S/--streamtimes every chunk as it arrives (not just first/last byte) and reportsCHUNKS,AVG GAP, andMAX GAPcolumns - the gaps measured are strictly between chunks, not including the first chunk's arrival (that span is already covered by the DNS/TCP/TLS/PRE-TRANSFER/1ST BYTE columns) - so you can see whether an SSE or chunked response streams smoothly or stalls mid-transfer - Catppuccin Mocha color theme - timing magnitude encoded in color (cool blues for fast, warm peach/red for slow); auto-disabled when output is piped
- curl-compatible flags -
-H,-d,-X,-4/-6,-F,-a,-p/-P,-S - HTTP/2 support -
--http2requests HTTP/2 via ALPN negotiation; aPROTOcolumn (printed last, afterTOTAL BYTES) shows the protocol actually used (h1orh2); falls back gracefully to HTTP/1.1 - Body and header support - POST payloads, auth headers, custom content types; works against authenticated and stateful endpoints
Run with -c 10 or -c 20 to surface patterns invisible in a single request.
- Slow or flaky resolvers - high or variable DNS times across runs
- Missing local DNS cache - DNS stays high every request instead of dropping to ~0ms after the first lookup
- Short TTLs - DNS spikes when the record expires mid-test
<DNS-FAIL>- hostname cannot be resolved at all
- Geographic latency - high TCP CONNECT reveals round-trip time to the server
- Connection backlog - TCP time grows as the server runs out of accept queue capacity under load
- Firewall / filtering -
<CONN-FAIL>on specific ports or from specific network paths
- Missing session resumption - TLS time stays high on every repeat request instead of dropping after the first; compare run 1 vs run 2+
- Slow OCSP validation or long cert chains - consistently elevated TLS time even without load
<TLS-FAIL>- expired cert, hostname mismatch, or untrusted CA
- Slow backend - high 1ST BYTE reveals heavy server work: DB queries, auth checks, computation, rendering
- Queue depth behind a reverse proxy - fast TCP but slow 1ST BYTE means the proxy accepted the connection but the backend was busy
- Backend inconsistency - variable 1ST BYTE across runs reveals hot/cold cache states, uneven DB load, or connection pool exhaustion
- Classic pattern: high
1ST BYTE+ fastBODY DL- server is slow to produce the response but fast to deliver it; the bottleneck is computation or IO server-side, not the network - Slow DB providing response data - consistently high 1ST BYTE while BODY DL is fast points directly at backend data retrieval time
- Slow server IO - high BODY DL relative to content size (slow disk reads, DB result streaming)
- Bandwidth throttling - BODY DL scales disproportionately with response size
- Inconsistent content size -
TOTAL BYTESvaries across-c Nruns: reveals A/B tests, CDN inconsistencies, partial or truncated responses, or outright payload bugs
Without -S, a streaming response is still measured meaningfully: 1ST BYTE is
the time until the first chunk/token arrives, and BODY DL is the total
duration of the whole stream. What's missing without -S is the rhythm of the
stream - whether it arrives steadily or in bursts with stalls.
AVG GAP and MAX GAP measure the time strictly between chunks - the first
chunk's arrival is deliberately excluded, since that span is already the DNS +
TCP + TLS + PRE-TRANSFER + 1ST BYTE columns; counting it again here would
misreport ordinary connection setup as if it were an in-stream stall. With fewer
than 2 chunks there's no inter-chunk gap to measure, so both columns correctly
show n/a rather than a misleading number.
This makes AVG GAP functionally the same metric LLM serving benchmarks call
Inter-Token Latency (ITL) - the average time between successive tokens.
Measuring it over the wire, rather than trusting server-side logs, captures what
the client actually experiences: network jitter, reverse-proxy buffering, and
load-balancer hops are all included, not just model-side generation time.
- Token stutter / uneven generation - a large gap between
AVG GAPandMAX GAPmeans the stream paused somewhere in the middle, even thoughBODY DLandTOTAL TIMElook fine in aggregate. This is exactly the kind of thing that makes a chat UI feel like it "hangs then dumps text." - Buffering misconfigurations - if a reverse proxy is accidentally buffering
the whole response before forwarding it (a common
nginx proxy_bufferingmisconfiguration),CHUNKScollapses to 1 or 2,AVG GAP/MAX GAPshown/a, and1ST BYTEballoons to roughly equalTOTAL TIME- the "stream" isn't actually streaming. - Inconsistency across backend replicas - combine with
IP ADDRESSto see whether one particular backend produces the stutter (uneven load, resource pressure) while others stream smoothly. - ITL benchmarking without server-side instrumentation - if you don't have
access to your model server's internal metrics (or you're testing someone
else's API),
-c 20 -Sgives you a client-side ITL measurement for free:AVG GAPis your typical inter-token latency,MAX GAPis your worst-case, and running multiple requests shows whether ITL is consistent or degrades under concurrent load. - Works with auth and POST bodies -
-Scomposes with-H/-d/-X, so you can test real chat-completion or SSE endpoints directly:-X POST -d '{"stream": true, ...}' -H "Authorization: Bearer ..." -S
- Mixed response codes - running
-c 20surfaces occasional 502/503 mixed with 200s, revealing backend instability, pods cycling in Kubernetes, or upstream timeouts - Intermittent timeouts - one or two
<TO>markers among otherwise successful requests indicate connection pool exhaustion, GC pauses, or health check races - Outlier requests - a single request dramatically slower than the rest reveals cold cache misses, JVM garbage collection pauses, or lock contention
- Verify HTTP/2 is actually active -
--http2with thePROTOcolumn (rightmost column) confirms whether the server is servingh2or falling back toh1. Useful to verify CDN or load balancer HTTP/2 configuration. - Connection reuse visible in timing - on repeated
-c Nruns with--http2, TCP CONNECT and TLS HANDSHAKE drop to<1msfrom run 2 onwards, confirming the persistent connection is being reused - one of HTTP/2's main performance benefits. - Detect HTTP/2 connection issues - if
PROTOshowsh1despite--http2, the server or an intermediate proxy is downgrading the connection.
- Uneven backends - without
-P, different IPs per request show which backends are in rotation; timing differences per IP identify the slow ones - Isolate one backend - use
-Pto pin all requests to a single IP; then switch IPs to compare them individually - Backend-specific errors - correlate the
IP ADDRESScolumn withHTTP CODEto see which backend is misbehaving
- Authenticated APIs - use
-H "Authorization: Bearer token"to test protected endpoints;<AUTH-FAIL>or 401/403 reveals auth configuration problems - POST/PUT/PATCH endpoints - use
-d @payload.json -H "Content-Type: application/json" -X PUTto test write endpoints with real payloads - Token expiry under load - combine auth headers with
-c 20to observe if validation degrades or fails on repeated calls - Header-conditional behavior - send routing or feature-flag headers
(
-H "X-Feature: beta") to test conditional server logic
- Non-zero
PRE-TRANSFER- this phase is internal libcurl bookkeeping and is normally ~0ms; consistently high values indicate CPU pressure on the machine running the script
# Recommended: install pycurl in a pyenv virtualenv
pyenv virtualenv 3.12.0 check-endpoint-env
pyenv activate check-endpoint-env
pip install pycurl
# Or install into the system Python directly
pip install pycurl --break-system-packages
# macOS may need: brew install curl
# Linux may need: apt install libcurl4-openssl-dev
chmod +x check-endpoint.py./check-endpoint.py <url>
./check-endpoint.py [options] <url>
# Single request
./check-endpoint.py https://example.com
# 10 requests with a 5-second timeout
./check-endpoint.py -c 10 -t 5 https://example.com
# Force IPv6, use Chrome's User-Agent
./check-endpoint.py -6 -a chrome https://example.com
# Custom auth header
./check-endpoint.py -H "Authorization: Bearer xyz123" https://api.example.com/v1/data
# Multiple headers
./check-endpoint.py -H "X-Trace-Id: 42" -H "Accept: application/json" https://example.com
# POST a JSON body (implies POST automatically)
./check-endpoint.py -d '{"foo":"bar"}' -H "Content-Type: application/json" https://example.com/api
# POST from a file (curl-style @file)
./check-endpoint.py -d @payload.json -H "Content-Type: application/json" https://example.com/api
# Force a specific method
./check-endpoint.py -X PUT https://example.com/api/resource/1
# Force a fresh DNS lookup + new connection on every repeat
./check-endpoint.py -c 10 -F https://example.com
# Pin all repeats to the first resolved IP (avoids round-robin drift)
./check-endpoint.py -c 10 -P https://example.com
# Pin to a specific known IP
./check-endpoint.py -c 10 -p 93.184.216.34 https://example.com
# Test an SSE / chunked-streaming endpoint and see per-chunk cadence
./check-endpoint.py -c 10 -S -H "Accept: text/event-stream" https://example.com/stream
# Stream mode against a real chat-completion endpoint (auth + POST body)
./check-endpoint.py -X POST -d '{"stream": true, "prompt": "hi"}' \
-H "Content-Type: application/json" -H "Authorization: Bearer xyz123" \
-S https://api.example.com/v1/chat| Flag | Description |
|---|---|
-c N / --count N |
Number of requests to perform (default: 1) |
-t N / --timeout N |
Per-request timeout in seconds (default: 10) |
-4 / --ipv4 |
Force IPv4 resolution (default) |
-6 / --ipv6 |
Force IPv6 resolution |
-a ALIAS / --user-agent ALIAS |
Use a baked-in UA string: chrome, firefox, edge, safari, googlebot |
-H 'K: V' / --header |
Custom request header, repeatable |
-d DATA / --data |
Request body (POST); prefix with @ to read from a file |
-X METHOD / --request |
Force an HTTP method (e.g. PUT, DELETE) |
-F / --force-dns |
Disable libcurl's DNS cache and connection reuse |
-P / --auto-pin |
Resolve once, then pin all repeats to that IP |
-p IP / --pin-ip IP |
Pin all repeats to a specific IP address |
-S / --stream |
Time the gaps between chunks as they arrive and report CHUNKS/AVG GAP/MAX GAP - for testing SSE or chunked-transfer streaming responses |
| Column | Description |
|---|---|
# |
Request number |
IP ADDRESS |
IP address libcurl connected to |
DNS |
Duration of DNS lookup (phase only) |
TCP CONNECT |
Duration of TCP handshake (phase only) |
TLS HANDSHAKE |
Duration of TLS negotiation; n/a for plain http:// |
PRE-TRANSFER |
Time from connect-ready to request-send-ready; typically ~0ms on direct HTTPS |
1ST BYTE |
Time from request sent to first byte of response - the clearest indicator of server-side processing time |
REDIRECT |
Count and total time of any redirects followed; n/a when none. This is why TOTAL TIME can exceed the sum of other columns. |
BODY DL |
Time to receive the complete response body after the first byte |
TOTAL TIME |
End-to-end wall-clock time including all redirects (the only cumulative column) |
HTTP CODE |
HTTP response status code |
TOTAL BYTES |
Response body size received |
PROTO |
HTTP version actually used - h1 (HTTP/1.1) or h2 (HTTP/2). Teal for h2, dim for h1. |
CHUNKS |
(only with -S) Number of chunks the response body arrived in |
AVG GAP |
(only with -S) Average time between consecutive chunks, excluding the first chunk's arrival (already covered by 1ST BYTE and the columns before it); n/a with fewer than 2 chunks |
MAX GAP |
(only with -S) Longest of those inter-chunk gaps - a high MAX GAP relative to AVG GAP reveals a mid-stream stall; n/a with fewer than 2 chunks |
CHUNKS, AVG GAP, and MAX GAP only appear when -S/--stream is passed;
without it, the columns end at PROTO and the rest of the table is unaffected.
Note on
PRE-TRANSFER = 0ms: This is correct behavior for direct HTTPS connections. Once TLS completes, libcurl is immediately ready to transfer - the gap between those two timers is genuinely near zero.Note on
TLS HANDSHAKEappearing onhttp://URLs: This is correct when the URL redirected tohttps://. The TLS column shows the handshake for the final connection; the redirect itself appears in theREDIRECTcolumn.Note on empty cells: a dim
n/ameans the phase structurally doesn't apply to this request (e.g.TLS HANDSHAKEon plainhttp://, orREDIRECTwhen none were followed). A dim-means the field is empty for any other reason (e.g. truncated by a failure mid-transfer).Note on
-Sand request bodies:-Sonly changes how the response is measured - it has no effect on what you send. Combine it with-X/-das usual to test POST/PUT streaming endpoints with a body.Note on
AVG GAP/MAX GAPexcluding the first chunk: these two columns intentionally start counting from the second chunk onward. Including the first chunk's arrival would double-count the same span already shown by DNS/TCP/TLS/PRE-TRANSFER/1ST BYTE, which would misreport ordinary connection setup time as an in-stream stall. With fewer than 2 chunks there's nothing to measure a gap between, so both columns shown/a.
| Marker | Meaning |
|---|---|
<TO> |
Request timed out (-t/--timeout exceeded) |
<DNS-FAIL> |
DNS resolution failed |
<CONN-FAIL> |
TCP connection refused or failed |
<TLS-FAIL> |
TLS handshake or certificate verification failed |
<NO-DATA> |
Connection succeeded but server sent nothing back |
<SEND-FAIL> |
Failed to send the request mid-transfer |
<RECV-FAIL> |
Failed to receive the response mid-transfer |
<RDR-FAIL> |
Too many redirects |
<BAD-URL> |
Malformed URL |
<AUTH-FAIL> |
Authentication denied |
<DENIED> |
Remote access denied |
<ERR> |
Any other libcurl error |
Markers are printed at the phase where failure occurred. All subsequent columns
for that row are left blank (-), and the next request (if -c N > 1) still
runs.
Colors are auto-disabled when output is piped to a file or another command.
| Element | Color |
|---|---|
| Header row | Bold blue |
| Odd rows | Primary text |
| Even rows | Slightly dimmed |
<1ms |
Dim (sub-millisecond) |
1-9ms |
Sky blue - fast |
10-99ms |
Teal - moderate |
≥100ms |
Yellow/peach - getting slow |
| Seconds | Bold peach - slow |
| Minutes | Bold red - very slow |
REDIRECT |
Peach |
| Error markers | Bold red |
n/a and - |
Dim overlay (same shade as the row number) |
2xx codes |
Green |
3xx codes |
Mauve |
4xx codes |
Maroon |
5xx codes |
Bold red |
| Bytes | Green → yellow → peach → red (B → KB → MB → GB) |
| IP address | Lavender |
| Row number | Dim |
CHUNKS |
Primary text (row color) |
AVG GAP / MAX GAP |
Same magnitude-based timing colors as other duration columns |
If you find this useful, please consider starring the repo ⭐, it helps others find it.
MIT

