A point-to-point network performance tester that lives in a single HTML file.
Download bitstream.html to two machines, open it on both, set one to Server and the other to
Client, and press the big green button. No install, no runtime, no build step, no server
infrastructure, and no internet access required.
The traffic travels over a WebRTC data channel — SCTP over DTLS over UDP — directly between the two machines. When both ends are on the same subnet there is no relay or proxy in the path, so the bytes genuinely traverse the link under test.
- Copy
bitstream.htmlto both machines and open it in a browser on each. Double-clicking the file is fine;file://is a secure context, which is all WebRTC needs. - Server machine: choose Server, press Generate session code, copy the code.
- Client machine: choose Client, paste that code, press Accept & generate reply, copy the reply code.
- Server machine: paste the reply, press Accept reply & connect. Both ends should show
connected. - Run the link check to get a latency baseline.
- On the client, set the options you want and press Apply configuration to both ends.
- Press START THROUGHPUT TEST.
- When it finishes, download the Markdown report. Either machine can export it — both ends receive the full result set.
The codes are long but you never type them: use the Copy button, or select-all and Ctrl+C. Each code displays a short checksum so you can confirm the paste arrived intact.
| Metric | How |
|---|---|
| Throughput | Application-layer goodput, measured at the receiving end |
| Latency | Round-trip time of small probes over the control channel, reported as min/avg/max/mdev and p50/p95/p99, plus the browser's own ICE connectivity-check RTT |
| Jitter | RFC 3550 smoothed mean deviation of transit time |
| Packet loss | Per-packet sequence numbers against the sender's own message count (unreliable mode only) |
| Reordering / duplication | Exact, over a 65 536-packet window |
| Payload integrity | Received bytes verified against the transmitted bit pattern |
Jitter is computed on the variation in transit time, so the unknown clock offset between the two machines cancels out. No clock synchronisation is needed, and none is assumed. The absolute one-way delay is not measurable this way and is deliberately not reported.
Packet loss uses the sender's own count as the denominator, shipped over the control channel when the stream ends — the receiver cannot otherwise know how many messages were meant to arrive. Per-interval loss is attributed against the sender's second-buckets, so a message delayed across a second boundary is charged to the interval it was sent in rather than the one it landed in.
| Option | Notes |
|---|---|
| Transport mode | Reliable/ordered (TCP-like) or unreliable/unordered (UDP-like). Loss, reordering and duplication only exist in unreliable mode; the reliable channel retransmits them away by design. |
| Direction | Client→Server, Server→Client, or both simultaneously |
| Duration | 3–300 seconds |
| Offered rate | Unlimited, or a fixed Mbit/s enforced by a token-bucket pacer |
| Message size | 1200–65536 bytes. This is the application message size, not the IP MTU. |
| Bit pattern | PRBS-31, PRBS-9, random, all zeros, all ones, alternating 0x55/0xAA, or incrementing bytes |
| Integrity check | Off, sampled (1 in 32), or every message |
Fix the rate if you care about loss or jitter. At unlimited rate you are deliberately overrunning the path, so the loss figure tells you how far past capacity you pushed — not what the path's loss characteristics are. Set a fixed rate and the numbers become interpretable.
Bit patterns matter on links that compress. All-zeros will sail through a compressing link at a flattering rate. Use PRBS-31 or random for a fair measurement; use the compressible patterns deliberately when compression is what you are trying to detect.
A browser data channel costs CPU — SCTP framing, DTLS encryption, and JavaScript overhead all sit between you and the wire. On a fast link that cost, rather than the link, is usually what you hit. Expect a few hundred Mbit/s per direction; that is a browser limit, not a network one.
BitStream watches for this. It compares the achieved rate against the offered rate, tracks event-loop delay, and counts how often the transmit buffer filled. If the sending machine could not keep the channel fed, the result is labelled sender-limited, in the UI and in the exported report. A sender-limited result is a lower bound on the path's capacity, not a measurement of it.
Conversely, if the transmit buffer filled repeatedly, the transport was the constraint and the number means something.
Browsers have no access to raw sockets. Rather than fake these, BitStream leaves them out and says so in the interface and in every exported report:
- Traceroute — needs ICMP and TTL control. Not possible in a browser, and not approximable.
- ICMP ping — the latency figures are application-layer round trips and include a little browser scheduling overhead. Read them as an upper bound.
- Port selection — ICE chooses ephemeral UDP ports.
- QoS / DSCP marking — not exposed for data channels in any portable way.
- Path MTU discovery — the size control sets an application message size. Messages larger than the path MTU are fragmented by SCTP, and in unreliable mode one lost fragment discards the whole message.
If you need any of these, use iperf3, mtr, or traceroute directly. BitStream's niche is that
it needs nothing installed on either end.
- Designed for two machines on the same subnet. No STUN or TURN server is involved, and the tool works with no internet access at all.
- Chromium-first. Chrome and Edge give the richest
getStats()data. Firefox and Safari work; fields they do not report show as "not reported" rather than breaking. - Both machines must run the same version of
bitstream.html. The session code carries a protocol version and a mismatch is rejected with a clear message rather than failing obscurely.
Browsers hide local IP addresses behind randomised <uuid>.local mDNS hostnames. These resolve
between machines on the same network segment but usually not across a router — which is the single
most common reason a connection stalls at checking.
If that happens, open Advanced under Connect, enter a STUN server
(stun:stun.l.google.com:19302 works if the machines have internet access), and exchange fresh
codes. Note that DSCP-style prioritisation and same-segment assumptions no longer hold once traffic
is routed, and if the connection ends up relayed through TURN the report will say so — those figures
describe the path through the relay, not a direct path.
Both checks run in a single browser tab, with no second machine and no network:
bitstream.html?selftest=1— 122 unit assertions covering the jitter accumulator against hand-computed RFC 3550 values, PRBS generators against their known periods, the packet codec, the signalling codec, sequence/duplicate/reorder accounting, interval attribution, and Markdown generation against a fixture.bitstream.html?loopback=1— runs both peers inside the one page over real data channels, exercising the actual signalling codec and measurement path across reliable, unreliable, rate-limited, and bidirectional configurations.
The page title becomes PASS or FAIL, and results are also left on
window.__bitstreamTestResult so the suites can be driven headlessly:
# mDNS candidate hiding must be off for a page to connect to itself headlessly
chromium --headless --disable-features=WebRtcHideLocalIpsWithMdns \
'file:///path/to/bitstream.html?selftest=1'Exported Markdown includes ISO-8601 timestamps with the local UTC offset, the full configuration,
both ends' user agents, the selected ICE candidate pair, the link-check summary, per-second interval
tables, summary statistics, the browser's own getStats() counters as a cross-check, a list of what
was not measured, and the complete timestamped event log.
Warnings — sender-limited results, payload corruption, relayed paths — are placed at the top of the report, ahead of the numbers they qualify.
site/ holds the landing page, published to
bitstream.akhtar.app by .github/workflows/pages.yml. It drops
onto any static host as-is. Its only third-party request is a Google Analytics tag; there are no
CDNs, webfonts, or other external assets, and deleting that one script block from the <head> makes
the page fully self-contained again.
bitstream.html itself carries no analytics and makes no outbound requests at all. That is a
property worth protecting — the tool runs inside the networks people are troubleshooting — so do not
add tracking to it. The Pages workflow copies the root bitstream.html over the site copy on every
build, so anything added to site/bitstream.html would be overwritten anyway.
GitHub Pages can only serve a branch folder from / or /docs, so the site is uploaded as an
Actions artifact instead. That is what lets it live in /site, and it also lets the build copy
bitstream.html in from the repository root.
site/bitstream.html must stay byte-identical to the copy in the repository root: the app
rejects a peer running a different protocol version, so a visitor who launches from the site has to
get the same build as someone who downloaded it. The workflow fails the build if they diverge.
cp bitstream.html site/bitstream.html # after any change to the app
sha256sum bitstream.html site/bitstream.htmlTo preview locally:
cd site && python3 -m http.server 8099 # then open http://localhost:8099