Performance
Suave is a non-blocking async web server. The hot paths have been incrementally tuned with modern .NET techniques — pooled buffers, cached encoders, span-based parsing — so that a steady request load translates into steady throughput and predictable latency rather than GC pressure.
This page is a tour of the techniques in use and a pointer to the in-tree benchmark harness you can run yourself. It is intentionally free of headline percentages: hardware, workload and .NET version dominate any such number, and you should always measure on the configuration you care about.
Design choices that matter
- Async I/O end to end. Connection accept, read, write and handler dispatch all run on the .NET thread pool with no blocking calls in the request path.
- Pooled buffers. Per-connection read/write buffers
are rented from
ArrayPool<byte>and returned on close, so steady-state traffic creates almost no LOH pressure. - Cached encoders & pre-computed constants. Common status lines, header names and the once-per-second HTTP date are formatted once and reused.
- Span-based parsing. Request lines, header
fields and query strings are inspected as
ReadOnlySpan<char>/ReadOnlySpan<byte>, avoiding intermediatestring[]allocations fromSplit. - Mutable locals in hot loops. Tight inner loops
use
let mutablerather than heap-allocatedrefcells. - HPACK in HTTP/2. Header compression turns
repeated
content-type/server/dateheaders into one or two bytes per stream after the first request — see the HTTP/2 docs.
Techniques in your own code
If you want to keep your handlers in the same low-allocation territory:
Rent large buffers from the pool
open System.Buffers
let buffer = ArrayPool<byte>.Shared.Rent size
try
// use buffer ...
()
finally
ArrayPool<byte>.Shared.Return buffer
Inspect spans instead of splitting strings
// Allocates a string[]:
let parts = line.Split ' '
// Allocates nothing:
let span = line.AsSpan()
let idx = span.IndexOf ' '
let head = span.Slice(0, idx)
let tail = span.Slice(idx + 1)
Stream bodies instead of concatenating
open System.Text
open Suave
open Suave.Successful
// Bad: builds N intermediate strings.
let mutable s = ""
for i in 1 .. 1000 do s <- s + sprintf "line %d" i
// Good: write each chunk directly to the connection.
let streamLines : WebPart =
fun ctx -> async {
for i in 1 .. 1000 do
let chunk = Encoding.UTF8.GetBytes(sprintf "line %d\n" i)
do! Async.AwaitTask (ctx.connection.asyncWriteBufferedBytes chunk)
return! NO_CONTENT ctx
}
Reuse the cached UTF-8 encoder
// Suave's own hot paths read through a cached UTF-8 encoder:
let str = Suave.Globals.UTF8.GetString(bytes, 0, bytes.Length)
Benchmarking
The repository ships two benchmark projects under benchmarks/:
-
benchmarks/InlineBenchmark— BenchmarkDotNet micro-benchmarks isolating specific hot-path decisions (e.g. property access vs. direct fields, the cost ofinline). -
benchmarks/PongServer— a minimal Suave web app suitable for driving withwrk,h2loadorbombardierto measure throughput and latency end to end.
Run the micro-benchmarks (Release configuration is required for meaningful JIT output):
cd benchmarks/InlineBenchmark
dotnet run -c Release
Drive the pong server with wrk for an end-to-end measurement:
cd benchmarks/PongServer
dotnet run -c Release &
wrk -t4 -c128 -d30s http://localhost:8080/
See BENCHMARKS_GUIDE.md
for the methodology used in-tree, and the
profile-output/
directory for example traces (cpu.nettrace,
alloc.nettrace) that you can open in
speedscope
or PerfView.
Microbenchmark your own handler
open BenchmarkDotNet.Attributes
open BenchmarkDotNet.Running
open Suave
open Suave.Successful
[<MemoryDiagnoser>]
type HandlerBench() =
let ctx = HttpContext.empty
[<Benchmark>]
member _.OkHello() =
OK "hello" ctx |> Async.RunSynchronously
[<EntryPoint>]
let main _ =
BenchmarkRunner.Run<HandlerBench>() |> ignore
0
BenchmarkDotNet will report allocations per operation alongside the timing — that is the number worth optimising in a per-request handler.
Profiling a running server
For CPU and allocation profiles of a live process, use the .NET
diagnostic CLI tools. The profile-output/ folder in the
repository was produced with this script:
# Replace <pid> with the Suave process id.
dotnet-trace collect --process-id <pid> \
--profile cpu-sampling --duration 00:00:30 \
--output cpu.nettrace
dotnet-trace collect --process-id <pid> \
--providers Microsoft-DotNETCore-SampleProfiler,Microsoft-Windows-DotNETRuntime:0x1:5 \
--duration 00:00:30 --output alloc.nettrace
Convert to speedscope and inspect interactively:
dotnet-trace convert cpu.nettrace --format Speedscope
Further reading
- In-tree benchmark guide
- BenchmarkDotNet — the micro-benchmark harness used in
benchmarks/ dotnet-trace— production-grade profilingwrk/h2load— HTTP load generators- Suave HTTP/2 — multiplexing and HPACK reduce per-request overhead on the wire