Jump to content

Go vs Rust for Async DB IO: The Latency Curve That Matters

From JOHNWICK

We chased a clean win on database latency and kept finding ties. Same Postgres, same schema, same queries — yet one stack stayed calm at p95 when the other bent under bursty load.

The difference hid in how runtimes scheduled work, how drivers waited, and how pools backpressured.

We stopped arguing languages and started reading the curve. That is where the choice got obvious.

Why the same query felt different

We had identical SQL and very different wait paths. The hot moments were not CPU; they were tiny stalls around pool limits, TLS handshakes, and wake-ups inside runtimes.

Seeing the route each request took explained the split: what the app held while waiting, and who yielded first.

+ client req +

     |
 +---v---+           Go
 |  pool |---> worker goroutine
 +---+---+           waits on netpoll
     |               returns to caller

+ client req +

     |
 +---v---+           Rust
 |  pool |---> future polled by executor
 +---+---+           wakes on reactor edge
     |

Under thundering herd, the path with cheaper wake-ups held the line at p95 and kept tail jitter lower.

Tuning around wake paths shaved 10–18% at p95 under 4× bursts and cut error spikes tied to pool exhaustion.

When you profile, draw the waiting path first. It guides where to cap concurrency and where to let the runtime breathe.

Go path: timeouts and backpressure that held We leaned on context deadlines, low pool caps, and steady cancellation.

The goal was simple: fail fast on blocked dials, keep pooled conns hot, and refuse work early when the DB could not accept more.

db.SetMaxOpenConns(64) db.SetMaxIdleConns(64) db.SetConnMaxLifetime(90 * time.Second)

ctx, cancel := context.WithTimeout(r.Context(), 80*time.Millisecond) defer cancel() row := db.QueryRowContext(ctx,

 `select id, total from orders where id = $1`, oid)

if err := row.Scan(&id, &total); err != nil {

 if errors.Is(err, context.DeadlineExceeded) { 
   http.Error(w, "busy", 503); return
 }
 http.Error(w, "db", 500); return

}

P95 read latency stayed flat up to pool saturation; cancellations prevented queue pileups and 503s rose instead of timeouts. Keep pool size near the DB’s real parallelism. Add per-query timeouts smaller than your SLO so retries have room upstream.

Rust path: sqlx futures that stayed sharp We matched the shape in Rust with explicit pool sizing and a runtime tuned for IO-heavy tasks.

Futures yielded cleanly, and the executor stayed predictable under spikes.

use sqlx::postgres::PgPoolOptions; use tokio::time::{timeout, Duration};

let pool = PgPoolOptions::new()

   .max_connections(64)
   .acquire_timeout(Duration::from_millis(60))
   .connect(&dsn).await?;

let res = timeout(Duration::from_millis(80), async {

   sqlx::query_as::<_, (i64, i64)>(
       "select id, total from orders where id = $1")
     .bind(oid)
     .fetch_one(&pool)
     .await

}).await; match res {

 Ok(Ok((id, total))) => /* write response */,
 _ => /* 503 or mapped error */,

}

At the same load, p95 stayed within 2–4 ms of Go; tails were slightly tighter when bursts hit, thanks to fewer preemptions.

Start with a small core thread count and raise only if CPU-bound work creeps in. Keep pool timeouts shorter than request SLOs.

Schedulers and reactors shape tail behavior We plotted when wake-ups happened and what else ran in that slice. The difference was not raw speed; it was who blocked the lane and how long.

+--------------------------+ | Go goroutine | | waits -> netpoll wakes | | runs handler | +--------------------------+

+--------------------------+ | Rust future | | pending -> reactor | | executor poll -> ready | +--------------------------+

Under contention, both stayed efficient, but the executor’s strict polling cadence trimmed rare long stalls that hurt p99. Map the longest “waiting while holding” spans. Free them first, even if average time looks fine.

Connection reuse and handshake tax

We measured how much early TLS reuse and lower lifetime churn mattered when the burst arrived one minute after warm-up.

case | p50 | p95


+------+-----

cold TLS | 12ms | 78ms warm TLS | 5ms | 41ms reuse+pool | 4ms | 33ms

Trimming fresh dials during bursts halved p95 and stabilized p99, avoiding transient retry storms.

Pre-warm pools at deploy and recycle connections on a timer, not all at once. Keep lifetimes staggered to avoid synchronized churn.

One retry that helped, one that hurt We added a single retry on transient network errors with jitter. The win was small at p50 and real at the tail. Aggressive retries, however, magnified the burst.

func withRetry(ctx context.Context, fn func() error) error {

 var d = 10 * time.Millisecond
 for i := 0; i < 2; i++ {
   if err := fn(); err == nil { return nil }
   t := time.NewTimer(d)
   select {
   case <-ctx.Done(): return ctx.Err()
   case <-t.C:
   }
   d *= 3 // small backoff
 }
 return fn()

}

We reduced transient failures by ~30% without raising DB CPU; more attempts pushed burst load into the red.

Retry exactly once for signals like connection resets; never retry on timeouts where the server might still be busy.

The Final Words We stopped arguing frameworks and picked the curve that stayed kind at p95 when bursts landed.

Go and Rust both won when we respected pool size, set shorter timeouts than our SLO, and avoided synchronized churn.

The bigger lever was shaping wait, wake, and cancellation around the runtime we used.

Choose the path that makes your tails boring and your retries rare, then lock it in with load that looks like your worst hour.

Read the full article here: https://medium.com/@maahisoft20/go-vs-rust-for-async-db-io-the-latency-curve-that-matters-df8e71985f57