Skip to content

About

Dext (Delphi) REST server compared with Rust, Go, .NET, Node and Spring Boot on PostgreSQL: method, results and raw data

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

Dext benchmarks

Versione italiana

How a REST server built on Dext (Delphi, by Cesar Romero) compares with Rust, Go, .NET, Node and Spring Boot: the same machine, the same database, the same four tests, run on 5 November 2026 (without security) and 6 November 2026 (with a JWT check on every request). We measured Dext 1.0.1 as released, Dext with the changes we propose for its HTTP.sys engine, and Dext with its IOCP socket engine and the changes we propose for it.

What the "Dext" rows measure. Our test server is DextBridge (our data-access library on ZeosLib) running on Dext. The numbers are Dext + DextBridge + ZeosLib, not Dext alone. The other servers are written with the usual libraries of their platform, without an ORM. The sources of the test servers are not in this repository; they are available on request.

In short

  • Select 1 record and Select 50 records (one query, JSON out): Dext with the socket engine has the most requests per second, without and with security. The nearest other server is Rust: 4-6% behind without security, 7-13% with the JWT check (where Dext keeps the verified token in a cache, see below). The others follow at a distance.
  • Insert 100 records: everybody is between ~1,900 and ~2,430 batches per second (Node aside). Here the limit is PostgreSQL itself: on this machine it commits about 2,150 batches of 100 rows per second when driven by its own benchmark tool. The differences between servers are within the noise.
  • Ping (no database): without security Node with 6 processes, Rust (at 50 connections) and Go are ahead, and Dext is at 91,000-101,000 requests per second; with the JWT check Dext with sockets (101,000-104,000) is level with .NET, and with Go at 50 connections; Rust at 50 connections (111,000) and Go at 200 (124,000) are ahead. This test measures latency more than CPU, and on this machine it moves by up to ±20% depending on the moment (see How to read the numbers).
  • Memory: Dext with the proposed fixes uses 82-107 MB of RAM (working set), Dext 1.0.1 as released 211-220 MB; Rust 15, Go 44, .NET 122, Node and Spring more. The private bytes of Dext are higher than its working set, and we explain why below.
  • CPU per request (Select 1 record, without security): Rust about 84 µs, Dext with sockets 91, .NET 96, Go 102.

Results

All numbers: median of three rounds, best value of each column in bold. Server CPU and memory are measured on the server process (all processes for Node with 6). The raw data are in data/.

Without security

Ping (no database) — requests/s, median of 3 rounds (p99 latency in ms)

server 50 connections 200 connections
Dext 1.0.1 (HTTP.sys) 100,361 (2.0) 101,231 (4.0)
Dext + proposed fixes (HTTP.sys) 95,449 (2.0) 100,933 (4.0)
Dext + proposed fixes (IOCP sockets) 91,568 (2.0) 96,515 (4.0)
Rust 1.99 · axum 124,236 (1.4) 45,769 (9.3)
Go 1.27 · net/http 118,038 (2.0) 131,275 (3.2)
.NET 10 · Kestrel 108,701 (2.0) 93,962 (4.0)
Node 24 · Fastify, 6 processes 127,580 (2.0) 136,410 (2.8)
Node 24 · Fastify, 1 process 22,465 (5.2) 22,235 (21.1)
Spring Boot 4.1 · Java 21 93,728 (1.8) 96,749 (5.0)

Select 1 record — requests/s, median of 3 rounds (p99 latency in ms)

server 50 connections 200 connections
Dext 1.0.1 (HTTP.sys) 31,550 (2.5) 31,224 (7.8)
Dext + proposed fixes (HTTP.sys) 31,370 (2.8) 32,038 (7.7)
Dext + proposed fixes (IOCP sockets) 35,302 (2.2) 35,097 (6.9)
Rust 1.99 · axum 33,229 (2.4) 33,727 (7.0)
Go 1.27 · net/http 32,501 (3.6) 32,868 (9.1)
.NET 10 · Kestrel 24,120 (5.3) 24,045 (17.5)
Node 24 · Fastify, 6 processes 17,358 (10.3) 16,795 (51.5)
Node 24 · Fastify, 1 process 6,664 (17.6) 6,158 (62.5)
Spring Boot 4.1 · Java 21 24,778 (5.1) 25,032 (14.5)

Select 50 records — requests/s, median of 3 rounds (p99 latency in ms)

server 50 connections 200 connections
Dext 1.0.1 (HTTP.sys) 16,823 (4.3) 17,064 (14.6)
Dext + proposed fixes (HTTP.sys) 17,232 (4.1) 16,831 (14.7)
Dext + proposed fixes (IOCP sockets) 17,719 (4.5) 17,655 (14.0)
Rust 1.99 · axum 16,643 (4.7) 16,865 (13.9)
Go 1.27 · net/http 12,778 (7.6) 12,998 (19.8)
.NET 10 · Kestrel 16,060 (7.4) 15,579 (20.4)
Node 24 · Fastify, 6 processes 8,425 (23.1) 7,823 (53.6)
Node 24 · Fastify, 1 process 2,203 (52.7) 2,006 (152.7)
Spring Boot 4.1 · Java 21 13,052 (9.7) 12,814 (28.6)

Insert 100 records — requests/s, median of 3 rounds (p99 latency in ms)

server 50 connections 200 connections
Dext 1.0.1 (HTTP.sys) 2,394 (56.8) 2,425 (211.6)
Dext + proposed fixes (HTTP.sys) 2,299 (65.0) 2,220 (197.7)
Dext + proposed fixes (IOCP sockets) 2,308 (73.3) 2,273 (193.1)
Rust 1.99 · axum 2,156 (80.6) 2,353 (197.7)
Go 1.27 · net/http 2,317 (74.4) 2,364 (252.7)
.NET 10 · Kestrel 2,274 (62.6) 2,000 (248.8)
Node 24 · Fastify, 6 processes 2,048 (84.2) 1,871 (368.1)
Node 24 · Fastify, 1 process 813 (87.4) 822 (321.2)
Spring Boot 4.1 · Java 21 2,098 (124.9) 2,053 (859.3)

CPU per request, µs, 50 connections (server user + kernel; PostgreSQL)

server Ping (no database) Select 1 record Select 50 records Insert 100 records
Dext 1.0.1 (HTTP.sys) 16 + 32 54 + 57; 74 106 + 65; 173 362 + 167; 1,046
Dext + proposed fixes (HTTP.sys) 17 + 34 51 + 57; 76 105 + 65; 175 353 + 149; 1,037
Dext + proposed fixes (IOCP sockets) 18 + 18 49 + 42; 72 105 + 49; 178 380 + 154; 1,056
Rust 1.99 · axum 11 + 17 39 + 44; 68 130 + 55; 168 337 + 117; 1,030
Go 1.27 · net/http 25 + 21 52 + 50; 73 183 + 75; 174 585 + 263; 921
.NET 10 · Kestrel 28 + 24 57 + 39; 150 112 + 42; 212 338 + 132; 1,188
Node 24 · Fastify, 6 processes 27 + 14 138 + 44; 143 426 + 55; 174 670 + 155; 1,231
Node 24 · Fastify, 1 process 28 + 16 113 + 39; 293 408 + 42; 559 1,048 + 155; 2,555
Spring Boot 4.1 · Java 21 22 + 37 86 + 68; 81 175 + 70; 201 812 + 249; 1,449

Memory, MB, highest value over all tests (working set / private bytes)

server working set private bytes
Dext 1.0.1 (HTTP.sys) 220 359
Dext + proposed fixes (HTTP.sys) 85 191
Dext + proposed fixes (IOCP sockets) 104 170
Rust 1.99 · axum 15 11
Go 1.27 · net/http 44 77
.NET 10 · Kestrel 122 79
Node 24 · Fastify, 6 processes 935 1,158
Node 24 · Fastify, 1 process 218 241
Spring Boot 4.1 · Java 21 694 743

Errors (non-2xx or timeouts), all rounds: Spring Boot 4.1 · Java 21 19

With a JWT check on every request

Ping (no database) — requests/s, median of 3 rounds (p99 latency in ms)

server 50 connections 200 connections
Dext 1.0.1 (HTTP.sys) 84,786 (2.0) 82,288 (4.0)
Dext + proposed fixes (HTTP.sys) 94,876 (2.0) 86,477 (4.0)
Dext + proposed fixes (IOCP sockets) 101,540 (2.0) 104,373 (4.0)
Rust 1.99 · axum 111,049 (1.3) 45,360 (9.8)
Go 1.27 · net/http 104,615 (2.1) 123,746 (3.6)
.NET 10 · Kestrel 105,666 (2.0) 108,087 (4.0)
Node 24 · Fastify, 6 processes 36,867 (3.0) 37,089 (17.9)
Node 24 · Fastify, 1 process 5,941 (19.3) 5,780 (70.0)
Spring Boot 4.1 · Java 21 83,073 (2.0) 89,388 (5.5)

Select 1 record — requests/s, median of 3 rounds (p99 latency in ms)

server 50 connections 200 connections
Dext 1.0.1 (HTTP.sys) 30,433 (2.6) 29,291 (8.6)
Dext + proposed fixes (HTTP.sys) 31,476 (3.0) 31,720 (8.2)
Dext + proposed fixes (IOCP sockets) 35,055 (2.2) 34,200 (7.0)
Rust 1.99 · axum 30,566 (2.6) 31,275 (7.5)
Go 1.27 · net/http 29,346 (4.1) 29,477 (10.3)
.NET 10 · Kestrel 21,669 (6.1) 21,518 (17.6)
Node 24 · Fastify, 6 processes 10,749 (13.0) 10,742 (41.3)
Node 24 · Fastify, 1 process 3,256 (36.7) 3,057 (112.6)
Spring Boot 4.1 · Java 21 23,836 (5.2) 22,743 (15.9)

Select 50 records — requests/s, median of 3 rounds (p99 latency in ms)

server 50 connections 200 connections
Dext 1.0.1 (HTTP.sys) 16,402 (4.8) 16,122 (15.3)
Dext + proposed fixes (HTTP.sys) 16,157 (4.5) 15,961 (15.4)
Dext + proposed fixes (IOCP sockets) 17,788 (4.1) 17,524 (14.5)
Rust 1.99 · axum 16,379 (4.7) 16,229 (14.4)
Go 1.27 · net/http 12,729 (7.6) 13,002 (19.4)
.NET 10 · Kestrel 15,135 (8.9) 14,759 (26.7)
Node 24 · Fastify, 6 processes 6,729 (25.2) 7,162 (67.6)
Node 24 · Fastify, 1 process 1,579 (65.6) 1,543 (179.8)
Spring Boot 4.1 · Java 21 12,537 (9.2) 12,414 (29.6)

Insert 100 records — requests/s, median of 3 rounds (p99 latency in ms)

server 50 connections 200 connections
Dext 1.0.1 (HTTP.sys) 2,357 (60.7) 2,219 (290.9)
Dext + proposed fixes (HTTP.sys) 2,384 (62.8) 2,264 (204.7)
Dext + proposed fixes (IOCP sockets) 2,234 (64.7) 2,268 (204.8)
Rust 1.99 · axum 2,183 (63.5) 2,298 (222.5)
Go 1.27 · net/http 2,366 (61.3) 2,284 (209.5)
.NET 10 · Kestrel 2,319 (56.2) 2,265 (191.5)
Node 24 · Fastify, 6 processes 1,097 (105.6) 2,042 (253.2)
Node 24 · Fastify, 1 process 654 (104.9) 679 (348.5)
Spring Boot 4.1 · Java 21 2,001 (136.2) 1,895 (1023.2)

CPU per request, µs, 50 connections (server user + kernel; PostgreSQL)

server Ping (no database) Select 1 record Select 50 records Insert 100 records
Dext 1.0.1 (HTTP.sys) 21 + 34 56 + 58; 76 114 + 67; 181 364 + 146; 1,081
Dext + proposed fixes (HTTP.sys) 19 + 33 55 + 57; 76 113 + 69; 180 361 + 168; 1,056
Dext + proposed fixes (IOCP sockets) 20 + 17 53 + 42; 71 113 + 48; 174 370 + 141; 1,118
Rust 1.99 · axum 18 + 17 51 + 44; 71 136 + 52; 170 350 + 116; 1,043
Go 1.27 · net/http 33 + 20 64 + 54; 74 198 + 73; 179 581 + 277; 908
.NET 10 · Kestrel 32 + 26 69 + 39; 154 122 + 45; 216 360 + 124; 1,096
Node 24 · Fastify, 6 processes 137 + 22 299 + 63; 166 575 + 70; 220 1,061 + 176; n/a
Node 24 · Fastify, 1 process 148 + 20 257 + 49; 406 547 + 70; 588 1,418 + 190; 2,778
Spring Boot 4.1 · Java 21 31 + 35 99 + 65; 78 194 + 72; 199 830 + 237; 1,358

Memory, MB, highest value over all tests (working set / private bytes)

server working set private bytes
Dext 1.0.1 (HTTP.sys) 211 359
Dext + proposed fixes (HTTP.sys) 82 201
Dext + proposed fixes (IOCP sockets) 107 173
Rust 1.99 · axum 15 10
Go 1.27 · net/http 44 78
.NET 10 · Kestrel 123 79
Node 24 · Fastify, 6 processes 1,021 1,217
Node 24 · Fastify, 1 process 212 238
Spring Boot 4.1 · Java 21 716 787

Errors (non-2xx or timeouts), all rounds: Spring Boot 4.1 · Java 21 26

How to read the numbers

  • One machine, in the cloud. Every value is a median of three rounds run at different times. On this virtual machine some minutes are slower than others for every server: in our checks the same Dext binary gave 68,000 or 101,000 requests per second on Ping ten minutes apart, and an HTTP.sys server went from about 95,000 to 74,000 within two minutes. Differences of a few percent are not meaningful; the spread of each value over the three rounds is in the raw data.
  • Ping sends 50 or 200 connections against a reply of a few bytes: at 100,000 requests per second each round trip lasts half a millisecond, and no server uses all its CPU. The test measures latency, which is why it is the most sensitive to the moment. The load generator has two vCPUs and probably does not go much beyond 130,000 requests per second. Rust at 200 connections stays at 38,000-46,000 in every round; we did not investigate and report it as measured.
  • Insert 100 records is limited by PostgreSQL (one synchronous commit per batch, about 1.5 ms on this disk). Saving CPU in the server does not raise this number.
  • CPU per request is the CPU of the server process during the test, divided by the successful requests, split into user and kernel time, plus the CPU of PostgreSQL. ⚠️ For the two Dext HTTP.sys rows part of the work of HTTP.sys runs in the Windows kernel outside the server process (in the "System" process) and is not counted: they look cheaper than they are. The socket engine, Kestrel, Rust, Go, Node and Java do their network work inside the process.
  • Memory is read at the end of each test; the table shows the highest value. The working set is the RAM the process occupies; the private bytes are the memory Windows has committed to the process, used or not. For Dext the gap is the memory manager (mimalloc): each worker thread that serves requests gets its own memory segment, and mimalloc commits more than it touches. In our measurement after the Insert test, of 187 MB of private bytes 176 MB were committed by mimalloc, 23 MB of them touched, and 16 MB were live objects. Dext runs 32 worker threads because a worker waits while the database answers; with 8 workers the memory halves but Insert loses 40%.
  • The JWT check: every server verifies an HS256 token in a middleware before every endpoint (constant-time signature check, alg must be HS256, exp must be in the future; otherwise 401). Dext uses RequireJwt of DextBridge. One difference: DextBridge keeps a verified token in a cache, as it does by default in production, so after the first request it does not compute the signature again; the others compute it on every request. On this machine the token check adds about 2 µs of CPU per request to Dext. (In production DextBridge also checks that every endpoint is declared, its "closed by default" access control: about 3 µs more per request. It is not in these numbers, because the others have nothing like it.)
  • Errors: only Spring Boot had errors (non-2xx answers or timeouts): 3 to 13 per round out of about 30,000 requests, all in Insert 100 at 200 connections.
  • Node with 6 processes, Insert 100 with the JWT check, 50 connections: in two rounds out of three it made half the usual batches (1,097 and 873 instead of ~2,000), and the PostgreSQL CPU of those tests comes out negative, i.e. database connections closed while the test ran. The likely cause: node-postgres closes a pool connection idle for more than 10 s (its default idleTimeoutMillis), and the table clean-up before every Insert test leaves the pool idle long enough; Node then reopens connections under load. We kept the defaults of every platform, so we report it as measured.

The rules (the same for everyone)

  • The machine: a cloud virtual machine (QEMU/KVM) with 8 vCPUs (Intel Xeon, Cascade Lake), 16 GB of RAM, Windows Server 2025 (build 26100), "High performance" power plan. The server under test and PostgreSQL run on vCPUs 0-5, the load generator on vCPUs 6-7 (processor affinity, set right after the server starts, the same way for everyone).

  • The load generator: bombardier 1.2.6, on the same machine.

  • The database: PostgreSQL 17.11 on the same machine, default configuration (128 MB of shared buffers, synchronous commit), connections over localhost. One table, clienti (customers), 100,000 rows:

    CREATE TABLE clienti (
      id              BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
      ragione_sociale VARCHAR(80)  NOT NULL,   -- company name
      citta           VARCHAR(40)  NOT NULL,   -- city
      email           VARCHAR(80),
      saldo           NUMERIC(12,2) NOT NULL DEFAULT 0,   -- balance
      creato          TIMESTAMP    NOT NULL    -- created
    );

    Before every Insert test the inserted rows are deleted, then VACUUM ANALYZE and CHECKPOINT: every server starts from the same table.

  • Connections to the database: a pool of 16 for every server. Node with 6 processes splits it: 2 per process, 12 in all.

  • The four tests, the same work for everyone:

    test endpoint what it does
    Ping (no database) GET /ping answers {"ok":true}: the cost of HTTP alone
    Select 1 record GET /clienti/42 reads one row by primary key (6 columns) and returns it as JSON
    Select 50 records GET /clienti?pagina=3 reads a page of 50 rows (ORDER BY id LIMIT 50 OFFSET 100) and returns it as JSON
    Insert 100 records POST /clienti/batch receives 100 rows in JSON and inserts them in one transaction

    For Insert, Dext, Rust, Go, .NET and Node send one statement with arrays (INSERT ... SELECT * FROM unnest($1, ..., $5)); Spring Boot uses JDBC batching (batchUpdate with reWriteBatchedInserts=true), the documented way on its platform.

  • Each test: 5 s of warm-up at 50 connections, then 15 s at 50 and 15 s at 200 connections.

  • Three rounds, in alternated order (forward, reverse, forward): no server always runs first or last. A CPU calibration of the machine (a fixed job with 1, 4 and 8 processes) before and after each run shows the same speed all the time (in data/).

  • Everyone at their best, no tricks: release builds, the production settings each platform documents, nothing tuned for this benchmark.

Who runs

row in the tables versions how it runs
Dext 1.0.1 (HTTP.sys) Dext 1.0.1 (tag v1.0.1), DextBridge, ZeosLib 8.0.1-beta, mimalloc 2.5.2, Delphi 12 (compiler 36.0), Win64 release HTTP.sys, 32 worker threads, 8 receives posted
Dext + proposed fixes (HTTP.sys) the same, Dext 1.0.1 plus the changes listed below HTTP.sys, 32 worker threads, 8 receives posted
Dext + proposed fixes (IOCP sockets) the same binary as the previous row the IOCP socket engine of Dext, 32 worker threads
Rust 1.99 · axum rustc 1.99.1, axum 0.8.9, tokio 1.54.1, tokio-postgres 0.7.18, deadpool-postgres 0.14.2, serde_json 1.0.154 one process, multi-thread runtime, LTO, prepare_cached
Go 1.27 · net/http Go 1.27.0, pgx 5.11.0 (pgxpool) one process, standard net/http and encoding/json
.NET 10 · Kestrel .NET SDK 10.0.403, ASP.NET Core 10 (minimal API, slim builder), Npgsql 10.0.3 one process, Kestrel, server GC, ReadyToRun, tiered PGO
Node 24 · Fastify, 1 / 6 processes Node 24.20.0, Fastify 5.12.5, pg 8.23.1 one process, or a cluster of 6
Spring Boot 4.1 · Java 21 Spring Boot 4.1.1 (Spring MVC 7.0.9, Tomcat 11.0.24, Jackson 3.1.5), HikariCP 7.0.2, PostgreSQL JDBC 42.7.13, OpenJDK 21.0.12 one process, JdbcTemplate

The two Dext rows "with proposed fixes" use the same DextBridge library as the Dext 1.0.1 row; only Dext changes. Each server prepares its statements the way its driver does by default: DextBridge/ZeosLib, Rust (prepare_cached) and Go (pgx statement cache) keep them prepared; the PostgreSQL JDBC driver prepares them on the server after 5 executions; Npgsql (without Max Auto Prepare) and node-postgres do not prepare them. This is visible in the PostgreSQL CPU per request.

What we changed in Dext, and why

We sent every change to Dext as an issue or a pull request on dotpas/dext, opened on 6 November 2026 and not yet reviewed when this was written: #278, #279, #280, #281, #282.

On top of Dext 1.0.1, in the rows "with proposed fixes":

  • a use-after-free in the HTTP.sys engine: a context read its engine from a request or response that had already been recycled or freed, and the worker thread ended silently. Fixed (the context keeps its own reference). Issue #278, pull request #279;
  • the memory of the HTTP.sys engine: each pooled context allocated a 64 KB body buffer up front, and each thread kept up to 64 contexts. Now the buffer is allocated only when a body must be received apart, and each thread keeps 32 contexts (about 280 MB of private memory became about 104 MB in our measurements). Pull request #280;
  • HTTP.sys sends that complete at once finish inline (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS): one worker wake-up less per request. Pull request #281;
  • the IOCP socket engine, which Dext 1.0.1 already has but does not use for HTTP by default (pull request #282):
    • robustness for a server facing the network: limits on header and body size (431, 413), malformed requests (400), Transfer-Encoding it does not support (501), HTTP versions (505), Connection: close and HTTP/1.0, Expect: 100-continue, SO_UPDATE_ACCEPT_CONTEXT on accepted sockets; 26 tests;
    • speed: sends that complete at once finish inline, the body is sent by reference without a copy, the receive buffer and the request/response objects are reused per connection;
    • the Date header, which HTTP/1.1 requires.

To run the socket engine on Windows our test server chooses it with an environment variable: Dext 1.0.1 has no option for it. The build we measured also has three small changes that are not in the pull requests and made no measurable difference: requests and responses freed outside their pool lock, the two references of a context released together after a send, and a try/except that records an exception escaping the HTTP.sys worker loop (there were none).

Not in these numbers, measured separately: a hook that lets a worker thread idle for a second ask the memory manager to trim its own heap. After a load it halves the private memory at rest, with no measurable cost.

Raw data

The data/ folder holds every measurement: one line per server, round, test and number of connections.

file content
2026-11-05_no-auth.csv the run without security (5 November)
2026-11-06_jwt.csv the run with the JWT check on every request (6 November)
2026-11-0{5,6}_calibration_{before,after}.csv the CPU calibration of the machine before and after each run (ms for a fixed job with 1, 4 and 8 processes)

Columns of the two runs: utc (end of the test), server, round, test, connections, requests_per_s, latency_p50_ms, latency_p99_ms, responses_2xx, errors, cpu_server_s, cpu_postgres_s (CPU seconds during the 15 s), working_set_mb, private_mb (at the end of the test), startup_s (until the port accepts connections), cpu_server_user_s, cpu_server_kernel_s.

What this benchmark is not

  • one machine, one operating system (Windows), one database (PostgreSQL), small payloads;
  • the load generator runs on the same machine;
  • not a substitute for measuring your own application.

We also measured Dext with Firebird (embedded and server); those numbers are not here because the other servers were not run on Firebird.

About

Dext (Delphi) REST server compared with Rust, Go, .NET, Node and Spring Boot on PostgreSQL: method, results and raw data

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors