Performance#
Bialet is a single worker serving one request per connection. That design makes performance easy to reason about and easy to measure: there is no thread pool, no connection reuse, and no cache between requests. This page publishes what the benchmark harness measures, on named hardware, so you can decide whether one worker is enough for your app.
These are capacity-planning numbers, not a leaderboard. Bialet is not trying to beat Go or nginx on throughput; it is trying to be fast enough for an internal tool on a small server, and to say so with measurements instead of adjectives.
What was measured#
scripts/perf_bench.py
boots a throwaway app and measures both latency (one request at a time) and
throughput (four clients in parallel, which is more than one worker can serve —
the extra connections queue in the kernel).
Endpoint |
What the request does |
|---|---|
|
A 3 KB static HTML file, no interpreter |
|
A |
|
Render a 60-section Markdown document |
|
Parse and stringify a ~20 KB JSON document |
|
|
|
An HTML template interpolating a 500-element list |
|
HTML-escape a 200-line document |
|
A form POST with an 8 KB body |
|
A 64 KB multipart file upload |
Each request opens a new TCP connection and the server closes it after the response, exactly as a client sees it in production.
Test setup#
macOS run |
Linux run |
|
|---|---|---|
Machine |
MacBook Air, Apple M3, 16 GB |
Same machine, Ubuntu 24.04 (arm64) |
OS |
macOS 15.8 |
Docker Desktop 29.9.2 VM |
Build |
|
|
Requests |
100 per endpoint, 4 concurrent |
100 per endpoint, 4 concurrent |
Rounds |
5, best median |
3, best median |
Both runs used the same source tree. The Linux run is a container on the same laptop, not a VPS: expect a small machine to be slower and a modern VPS to be in the same range.
Results: macOS arm64#
Endpoint |
Median |
p95 |
Requests/s (4 clients) |
|---|---|---|---|
|
0.27 ms |
0.36 ms |
7,109 |
|
3.99 ms |
4.58 ms |
266 |
|
4.15 ms |
4.62 ms |
254 |
|
5.19 ms |
5.97 ms |
199 |
|
7.56 ms |
8.88 ms |
137 |
|
6.83 ms |
8.39 ms |
150 |
|
6.22 ms |
7.17 ms |
164 |
|
4.33 ms |
5.03 ms |
237 |
|
4.06 ms |
4.64 ms |
246 |
Results: Linux arm64 (Ubuntu 24.04 container)#
Endpoint |
Median |
p95 |
Requests/s (4 clients) |
|---|---|---|---|
|
0.14 ms |
0.16 ms |
4,588 |
|
2.83 ms |
3.13 ms |
350 |
|
2.91 ms |
3.24 ms |
340 |
|
3.84 ms |
5.44 ms |
286 |
|
5.40 ms |
6.76 ms |
187 |
|
4.85 ms |
5.00 ms |
209 |
|
4.71 ms |
5.08 ms |
205 |
|
3.37 ms |
8.30 ms |
294 |
|
3.03 ms |
3.47 ms |
346 |
Startup and memory#
Measured on the same builds, five runs each, with an empty app that returns
"ok":
Metric |
macOS |
Linux |
|---|---|---|
Start to first response |
~27 ms |
~26 ms |
Peak RSS after first request |
~6.4 MB |
~13 MB |
The Linux build links OpenSSL and libcurl dynamically and its C library keeps more per-process state, which is most of the difference. Both are small enough that memory is not the constraint; the single worker is.
How to read these numbers#
A dynamic page costs 3–7 ms. Most of that is not your SQL: every request creates a fresh Wren VM and compiles the handler script. That is the price of request isolation, and it is why an opcode cache is on the roadmap.
Throughput is roughly
1000 / medianonce the queue is saturated, and it scales with workers. Four workers on a four-core box serve about four times the requests per second, sharing one SQLite file.Latency is queueing. At 4 clients, the fourth request waits for the three in front of it. That is why p95 grows when clients are added: the server is honest about being sequential.
Static files are the fast path. If a page can be prerendered, let the reverse proxy serve or cache it.
Two limits are documented in Deployment and show up in practice: the listen backlog is 10, so a burst larger than that can be refused before the worker accepts it, and there is no keep-alive, so every request pays a TCP handshake. A reverse proxy absorbs both.
Reproduce it#
make
python3 scripts/perf_bench.py --rounds 5 --requests 100 --concurrency 4
Add --json out.json to keep the raw timings, and --baseline out.json to
compare two builds (for example, before and after a change to the request
path). The machine will not match this one; the ratios will.