Skip to content

Performance

This page shows how fast Dox is, and how to keep your app fast in production.

The short version:

  1. Build with dox build and run the AOT binary. Never dart run in production.
  2. Put Caddy or nginx in front for HTTPS and gzip.
  3. Keep the default isolates (one per CPU core).
  4. Keep the body limits on. Uploads stream to disk.

Benchmark

The rules of the-benchmarker/web-frameworks: each server answers GET / (empty body), GET /user/:id (the id) and POST /user (empty body). Dox is a full Dox app with these three routes. "Dox server only" is dox_http_native with no framework, to show what the framework costs.

This only measures the HTTP stack. A real app spends most of its time on the database, JSON and your own code, so the gaps are smaller there. See App benchmark.

2 cores

2 cores: ahead of Rust hyper and Bun

GET /, 256 connections. Every server has one worker per core: Dox one isolate, Bun and Node one process, Go and Rust one thread.

req/s p99 RAM
Doxfull app 638,067fastest 0.7 ms 19 MB
Rusthyper 585,6581.09× slower 0.7 ms 13 MB
BunBun.serve 501,5291.27× slower 1.0 ms 40 MB
Nodenode:http 286,8472.2× slower 1.7 ms 200 MB
Gonet/http 242,0432.6× slower 3.3 ms 36 MB
dart:ioHttpServer 129,7964.9× slower 3.8 ms 75 MB

Dox · Rust 1.99 · Bun 1.4 · Node 26 · Go 1.26 · Dart 3.13 · 2 cores · 256 connections · wrk

Requests per second on GET / at 64, 256 and 512 connections:

Server 64 256 512 p99 at 256 peak MB
Dox 494,223 638,067 612,032 0.74 ms 19
Dox server only 589,866 692,705 699,938 0.68 ms 16
Rust (hyper) 499,734 585,658 560,181 0.71 ms 13
Bun (Bun.serve) 508,243 501,529 531,310 1.00 ms 40
Node (node:http) 252,866 286,847 296,257 1.70 ms 200
Go (net/http) 248,951 242,043 232,687 3.27 ms 36
dart:io 125,428 129,796 132,112 3.83 ms 75

The other two routes at 256 connections:

Server GET /user/:id POST /user
Dox 572,658 545,986
Dox server only 672,098 683,477
Rust (hyper) 564,411 569,313
Bun 482,328 498,518
Node 274,067 301,084
Go 230,845 231,050
dart:io 93,561 126,027

Why it is fast

The server runs on the isolate's own thread. One FFI call writes the answers, reads the sockets and parses the new requests. Dart runs the handlers and calls again. A request never waits for another thread to wake up. A small helper thread only watches the sockets while the isolate has nothing to do. See Native server.

A profile with perf (4 server cores, 64 connections) shows the effect:

Dox Bun
CPU time per request 4.85 µs 5.5 µs
Context switches per request 0.08 0.035

How it was run

  • Docker on an Apple M2 (8 CPUs in the Docker VM), linux/arm64.
  • One server at a time. The server and wrk run in two containers on different CPUs: the server on 2 CPUs, wrk on the other 6. Host network, keep-alive.
  • wrk 4.1, 64, 256 and 512 connections, 5 seconds warm-up, then 15 seconds per route.
  • Every server has as many workers as server CPUs.
  • Peak memory is the peak of the container (all workers).
  • Dart 3.13.6, AOT build with dart build cli (this is what dox build does). dox_core and dox_http_native as released when these numbers were measured. Node 26.12, Bun 1.4.4, Go 1.26, Rust 1.99 with hyper 1.

App benchmark

A small real app, the same in each language: GET /json returns a map, GET /db reads one row by id from Postgres, GET /queries?n=20 reads 20 rows one after another. The table has 10,000 rows. Each app uses its database driver directly, with no ORM. Dox uses dox_postgres_native.

2 cores: one row from Postgres

GET /db, 256 connections. Requests per second, higher is better.

req/s p99 RAM
Doxdox_postgres_native 266,432fastest 1.8 ms 106 MB
BunBun.sql 198,2961.34× slower 2.1 ms 64 MB
Rusttokio-postgres 128,0652.1× slower 2.6 ms 11 MB
Nodepg 75,4473.5× slower 4.1 ms 232 MB
Gopgx 55,9034.8× slower 6.3 ms 31 MB
dart:iopackage:postgres 28,5989.3× slower 14.2 ms 47 MB

Dox · Bun 1.4 · Rust 1.99 · Node 26 · Go 1.26 · Dart 3.13 · Postgres 18 · 2 cores · 256 connections · wrk

All three routes at 256 connections, requests per second:

Server /json /db /queries?n=20 peak MB
Dox (dox_postgres_native) 555,216 266,432 17,772 106
Bun (Bun.sql) 564,362 198,296 18,994 64
Rust (hyper + tokio-postgres) 667,718 128,065 9,312 11
Node (node:http + pg) 205,774 75,447 7,458 232
Go (net/http + pgx) 206,398 55,903 6,635 31
dart:io (+ package:postgres) 89,480 28,598 2,269 47
  • On one query per request, Dox is the fastest: 1.3 times Bun.
  • On 20 queries per request, Dox and Bun are about the same. Postgres is the limit here. Both are about twice as fast as Rust.

The same app on MySQL 8.4 (dox_mysql_native). The dart:io app has no MySQL version.

Server /json /db /queries?n=20 peak MB
Dox (dox_mysql_native) 558,886 247,242 12,347 70
Bun (Bun.sql) 561,340 201,923 12,676 65
Rust (hyper + mysql_async) 653,244 46,795 3,158 10
Node (node:http + mysql2) 205,630 74,809 6,001 239
Go (net/http + go-sql-driver) 211,713 55,322 5,520 27

How it was run: the same rig as the benchmark above. The server gets 2 CPUs, the database gets 2 CPUs and wrk gets the other 4. Each server has 4 database connections per core, 8 in total. One run of 15 seconds per route. dox_postgres_native and dox_mysql_native as released when these numbers were measured.

Dart VM flags

We tried a bigger young generation (DART_VM_OPTIONS=--new_gen_semi_max_size=64 and 128) for a better p99. Over 5 runs the change was inside the noise, and memory did not move. So there is no flag to set. Dox never sets VM flags for you. DART_VM_OPTIONS is read by the AOT binary from dox build, so you can try your own.

Database drivers

One find-by-id query, AOT build (dart build cli), database on the same machine (Apple M-series), one isolate:

Database One query at a time Many queries in flight App CPU time per query
Postgres 14 (4 connections) 60 µs 12.5 µs each (64 in flight)
MySQL 8.4 (10 connections) 67 µs 11 µs each (32 in flight) 13.5 µs
MariaDB 11 (10 connections) 63 µs 9.7 µs each (32 in flight) 12.4 µs
  • The CPU time counts the Dart thread and the Rust thread. For MySQL, it is about 75,000 queries per second on one core.
  • Dart sends many queries to Rust in one call. Rust sends many results back in one message.
  • One query alone spends most of its time on the trip to the database. Many requests at the same time, like in a web server, get the most out of the driver.

Bodies, uploads and big responses

These are on by default. You only need to keep them:

  • A body over maxBodySize or maxMultipartSize gets 413 from the native server, before Dart reads it. Set the same limit in the proxy. See Size limits.
  • An upload over 1 MB streams to a temp file, so it does not fill the memory. See Request.
  • A File or Stream response is streamed, not loaded into memory. See Files and big responses.

gzip

gzip in Dart is off by default.

AppConfig(compress: true);
  • It saves bandwidth, but costs CPU on every response. The native server gzips on a Rust thread. Streamed responses are not compressed.
  • A proxy (Caddy, nginx) does it better. Turn it on in Dart only when there is no proxy.
  • Only a body over 256 bytes is gzipped, and only when the client accepts gzip. The gzipped body still gets a Content-Length.

Logging

LogMiddleware prints one JSON line per request. Writing to stdout blocks the isolate. So Dox collects log lines and writes them every 20 ms (or every 64 KB) in one write. If the process is killed hard, the last 20 ms of request logs can be lost. Errors from Logger are still written right away.

Turn LogMiddleware off when a proxy already writes access logs. The new project does this for you: enabled is false when APP_ENV is production or not set.

Leave responseHandler unset when you do not need it. With no handler, a plain value (text, map, list) is sent without building a DoxResponse.

What Dox does for you

  • Routes are grouped by method. The regex of each route is built once, not on every request.
  • The argument types of each controller are read once at start, not on every request.
  • JSON and text responses are encoded once, straight to bytes, with Content-Length set.