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 intermediate string[] allocations from Split.
  • Mutable locals in hot loops. Tight inner loops use let mutable rather than heap-allocated ref cells.
  • HPACK in HTTP/2. Header compression turns repeated content-type / server / date headers 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 of inline).
  • benchmarks/PongServer — a minimal Suave web app suitable for driving with wrk, h2load or bombardier to 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