DEV Community

Benny
Benny

Posted on

The Transport Raises the Floor, Not the Ceiling: GenHTTP, ASP.NET, and an io_uring Power-Up

In round one, Benny Franciscus left us with a tidy moral: performance is contextual. ASP.NET took raw throughput, GenHTTP took the workloads, everybody nodded, nobody got fired. That conclusion has aged in an interesting way, because two things changed underneath it. GenHTTP swapped engines, and a new io_uring runtime called ioxide showed up offering to re-plumb the I/O layer of whatever you point it at.

So this is a controlled experiment, not a rematch. Two stages. Stage 1 establishes the gap between stock genhttp-11 and stock aspnet-minimal. Stage 2 changes exactly one variable — the transport — on both sides, holding the application code constant, and lets the deltas confess which framework was carrying more I/O overhead. Every number below is paired with the mechanism that produced it, because a benchmark without a mechanism is just a rumor with a chart.

Stage 1 — Stock vs Stock: GenHTTP 12, ASP.NET 0

First, the correction round one earned. Benny's genhttp was the old default engine, and ASP.NET beat it on baseline by roughly 10x. genhttp-11 is GenHTTP's tuned native HTTP/1.1 engine — its own socket loop plus its routing and serialization pipeline, server and framework fused into one library. That is not "round one lied." That is GenHTTP changing the motor and the result flipping. Here is the stock board.

Workload genhttp-11 aspnet-minimal Ratio
baseline 2,075,459 1,269,768 1.63x
pipelined 17,885,664 6,887,346 2.60x
limited-conn 1,093,905 458,236 2.39x
json 834,527 577,052 1.45x
json-comp 462,495 337,876 1.37x
json-tls 682,330 511,002 1.34x
static 914,726 356,294 2.57x
upload 2,932 1,539 1.91x
crud 518,433 356,311 1.46x
async-db 196,599 169,173 1.16x
api-4 53,376 40,202 1.33x
api-16 149,741 118,886 1.26x

GenHTTP 12, ASP.NET 0. A clean sweep, and a structurally honest one.

The why is architecture, not luck. aspnet-minimal is Kestrel on the default .NET sockets transport: epoll readiness, then a syscall to actually move the bytes, then continuations dispatched onto the thread pool. Every dependency it touches has its own private I/O stack stapled on the side — Npgsql brings its own socket layer and a locked connection pool, TLS goes through user-space SslStream crypto and copies, static files go through the filesystem. General-purpose, composable, and paying a coordination tax at every layer.

genhttp-11 is a purpose-built HTTP/1.1 engine that never agreed to be general-purpose. The widest stock gaps are exactly where Kestrel's generality costs the most: pipelined 2.60x, where the bottleneck is per-request HTTP/1.1 parsing and ASP.NET's pipeline does more bookkeeping per request; static 2.57x, GenHTTP's native file path versus a filesystem round-trip; limited-conn 2.39x, where the workload is dominated by accept/close churn and Kestrel's accept path is the slower one. The narrow gaps — async-db 1.16x, json-tls 1.34x — are the tell, and worth bookmarking: those are workloads dominated by something other than HTTP, where Npgsql and SslStream drag both frameworks down toward a common floor.

Stage 2 — Power Up Both: GenHTTP 11, ASP.NET 1

Now the one variable. Both frameworks keep their entire application layer — same routing, same serialization, same handlers — and swap their I/O substrate for the ioxide io_uring runtime.

ioxide is a shared-nothing io_uring runtime: one ring per reactor, one reactor per core (capped at 64). Each reactor owns its sockets, buffers, and DB connections, so there are no cross-core locks on the hot path. SO_REUSEPORT plus multishot accept lets the kernel load-balance connections and turns one submitted accept into many. Multishot recv harvests many completions into a pre-registered buffer ring. Completions resume the request state machine inline on the reactor thread via IValueTaskSource — no thread-pool handoff, no per-await allocation — and it talks to the kernel through raw io_uring_enter, no liburing in between. Where epoll says "this socket is ready, now go syscall," io_uring says "here is the completed read," and ioxide does the continuation on the same core that owns the data.

For aspnet-minimal-ioxide the change is — almost insultingly — one line: builder.WebHost.UseIoxide(), plus ioxide.pg for the database and kTLS instead of UseHttps. HTTP/2 and HTTP/3 are intentionally omitted, because ioxide is HTTP/1.1-only with no ALPN. genhttp-11-ioxide runs GenHTTP's same pipeline on GenHTTP.Engine.Ioxide, with the same per-reactor seams for ioxide.pg and kTLS. App layer constant; substrate swapped. Anything that moves is attributable to I/O.

Workload genhttp-11-ioxide aspnet-minimal-ioxide Ratio vs stock
baseline 3,207,811 1,806,612 1.78x
pipelined 21,625,750 7,024,560 3.08x widened
limited-conn 2,572,916 1,145,776 2.25x
json 1,115,920 676,306 1.65x
json-comp 565,487 382,233 1.48x
json-tls 870,802 738,079 1.18x narrowed
static 858,611 332,365 2.58x ~
upload 2,222 1,958 1.13x collapsed
crud 651,333 555,136 1.17x narrowed
async-db 274,238 291,828 0.94x THE FLIP
api-4 55,032 48,477 1.14x
api-16 184,888 154,249 1.20x

Stock 12-0 becomes powered 11-1. ASP.NET takes async-db. To understand why that is the only flip — and why it was always going to be that one — read the per-framework power-up deltas, because the aggregate ratios hide the real story. Each cell here is measured against that framework's own stock baseline.

Workload genhttp-11 → ioxide aspnet-minimal → ioxide
baseline +55% +42%
pipelined +21% +2%
limited-conn +135% +150%
json-tls +28% +44%
crud +26% +56%
async-db +39% +73%
upload -24% +27%
static -6% -7%

The Payoff — Who Had More to Shed

Same substrate, two very different windfalls.

The database path is where ASP.NET was bleeding. async-db jumps +73% for ASP.NET versus +39% for GenHTTP; crud jumps +56% versus +26%. Mechanism: stock ASP.NET ran Npgsql, which means its own socket stack, a locked pool, and a thread-pool hop between the DB completion and the request continuation — bytes arrive on one thread, the request resumes on another. ioxide.pg deletes all of it. DB sockets ride the same ring as HTTP, the pool is per-reactor and shared-nothing, and rows stream from the driver's receive buffer straight into the HTTP response — query and response on one core, one ring, one buffer. GenHTTP gained from the same change, but it had less coordination overhead to begin with, so there was less to delete. Result: aspnet-minimal-ioxide at 291,828 overtakes genhttp-11-ioxide at 274,238. The single flip on the entire board, and it is a DB-transport effect, full stop. ASP.NET got rescued, because Npgsql's cross-thread choreography was a bigger fire to put out. The workload where the server mattered least in Stage 1 is exactly the workload that ioxide could swing — because the thing it fixed was never the server.

TLS tells the same story in miniature. json-tls gains +44% for ASP.NET versus +28% for GenHTTP, narrowing 1.34x → 1.18x. kTLS pushes record encryption into the kernel and retires Kestrel's user-space SslStream crypto and copies — and SslStream was the heavier starting point, so the heavier starting point loses more weight. Same with limited-conn: ASP.NET's accept/close churn was the worse path, so multishot accept helps it a hair more (+150% vs +135%).

Notice the pattern: ioxide removes the most where ASP.NET had the most — the plumbing. DB, TLS, connection churn. That is the floor rising.

Now the ceiling, which does not move. On raw HTTP, GenHTTP gains more: baseline +55% vs +42%, and pipelined +21% vs a rounding-error +2%. The pipelined gap widens, 2.60x → 3.08x. The mechanism is the whole thesis: pipelined is bottlenecked by Kestrel's per-request HTTP/1.1 parsing and pipeline work, which sits above the transport. Swapping sockets for a ring cannot accelerate parsing you still have to do. The ceiling is the parser, and the parser didn't change. ioxide raises the floor under both frameworks; it cannot lift application-layer request handling. GenHTTP's structural advantage there survives the power-up intact, and then some.

And, honestly, the regressions. Upload collapses from 1.91x to 1.13x — but not because ASP.NET got clever. ASP.NET's buffering path improved +27%, while GenHTTP regressed -24%: ioxide's recv buffer ring caps each slice at 16KB, so a 20MB body shatters into hundreds of completions and buffer returns. That ring is tuned for small requests, and it makes GenHTTP's otherwise-excellent bulk path pay for it; stock genhttp-11 remains GenHTTP's best uploader. Static regresses ~6-7% on both sides. ioxide.file serves static as a baked snapshot — full response, headers and body plus precompressed .br/.gz siblings, precomputed in native memory at startup and sent from a slab. But GenHTTP's FileResource overflows that 128KB write slab, so genhttp-11-ioxide routes static through IoxideFiles and still edges just under its own native path. The substrate is not a free lunch; it is a differently-priced lunch.

One belt ioxide never reaches for: HTTP/2 and HTTP/3. ioxide is HTTP/1.1-only — no framing, no HPACK, no ALPN. ASP.NET's live h2 numbers (baseline-h2 around 2.2M; static-h2 a 2.2-2.4x edge for the AOT build) are uncontested here because the powered-up variants simply do not speak those protocols. If your edge is multiplexed h2/h3, this entire experiment is beneath your actual workload, and ASP.NET keeps that crown unchallenged.

Conclusion

The transport raises the floor, not the ceiling. ioxide deletes the most overhead where ASP.NET was carrying the most — the database driver, the TLS stack, the connection churn — which closes nearly every gap and steals async-db outright. But it cannot touch the layer above itself, so GenHTTP's purpose-built HTTP/1.1 parsing and pipeline keep their lead, and on the most parsing-bound workload the gap actually grows. Stock 12-0 becomes powered 11-1, and that single point changed hands in the plumbing, exactly where the theory predicted it would.

Round one's "performance is contextual" was right; this is the same truth at higher resolution. The substrate sets your floor. The architecture above it sets your ceiling. Pick the workload, then pick the layer that owns it.

See the live filtered board and sort it yourself: https://www.http-arena.com/#sort=rps:-1&type=emerging,experimental,flagship

Top comments (0)