A Go bidder that loses auctions on timeout while its average looks healthy has two problems under one symptom: a deadline that belongs to the exchange and covers the round trip, and a collector charging mark assist to the goroutine scoring the bid. This is how the deadline is split, which Go knobs pay off, and what a Rust hot path does not fix.
A bidder that loses auctions on timeout while its average looks healthy is being described by the wrong number. The deadline belongs to the exchange and covers the network in both directions. A collector is a property of the process, so its pause is charged to every connection at once, and a spike on one connection alone needs another explanation.
Rewrite in Rust or tune the runtime is a choice between two answers to a question nobody has asked: which part of the deadline is being spent, and on what. What follows separates the budget from the collector, the collector from the scheduler, and the rewrite from the component that deserves it.
The short answer is structural. The exchange sets the deadline and it includes the network, so the first repair splits the p99 of one connection into handler time, time waiting for a processor, and time on the wire. What amBrain can substantiate publicly: we built RTBBidder, a demand-side platform delivered from scratch, where each bid decision evaluates dozens of targeting conditions per impression. The latency figures we publish as measured come from trading paths, not from an ad bidder, and no number below is measured on a bidder of ours.
The OpenRTB specification defines tmax as the maximum time in milliseconds the exchange allows for bids to be received, including Internet latency, and says the value supersedes any prior guidance. The budget is a round trip that arrives inside each request.
The threshold you are judged against is not the one on your dashboard. Google's Authorized Buyers documentation requires that 85 percent of responses arrive inside the deadline as measured at the trading location, and throttles bidders that miss it. Between its clock and yours sits everything that is not computation:
So the internal deadline sits below tmax by the amount your own histogram says an answer costs on that connection, re-derived per exchange rather than set once for the fleet. A deadline is not a capacity plan: work cancelled at the deadline has already spent its CPU.
Under overload that means paying full price for responses nobody counts, so the missing repair is admission control: read tmax, compare it against the queue delay you measure, and answer no-bid when the arithmetic does not close. A fast no-bid counts toward the 85 percent; a late bid does not.
A garbage collector is a property of the process, so a cycle triggered on any connection charges every connection. The one that misses first is the one with the tightest tmax and the heaviest request. Rule out what produces the same picture without a collector:
None of the four is repaired by a collector setting, and the split has instruments. A CPU profile separates the collector bills by symbol, runtime.gcAssistAlloc for handler charges and runtime.gcBgMarkWorker for background marking. Stop-the-world time is /sched/pauses/total/gc:seconds, runnable-wait is /sched/latencies:seconds, and the accept queue is read outside the process through ListenOverflows.
One distinction decides which Go repair you need. A stop-the-world pause is charged to every goroutine at once, so it appears as a flat spike on all connections. A mark assist is charged to the goroutine that allocated, so it lands on the requests that allocated most. Two things lower an assist: fewer bytes per bid request, or a longer cycle in which background workers cover more of the marking. Only the first survives a change in traffic mix.
The Go collector is concurrent, and the official guide is explicit that pause length does not scale with heap size, so stop-the-world transitions are brief. Assists are the source that matters: goroutines assist the collector when allocation is fast, because background marking gets a fixed quarter of the processors and the shortfall is charged to the allocator.
Rate makes this a threshold rather than a slope. Allocation rate is QPS multiplied by bytes per bid request against a fixed background share, so code that never assists at a fifth of your traffic can assist on nearly every request at 100K QPS.
Marking cost is proportional to the live pointer graph, not to the garbage, and a bidder holds the wrong shape for it: campaign indexes, audience segments, frequency caches. Discord published the same finding in 2020, with a collector scanning an entire LRU cache to decide whether the memory was free. Five mechanisms hide under one phrase:
Read that as five separate bills. Exactly one is settled by a collector setting, and none by changing the language before the split is measured.
Gil Tene named the failure coordinated omission: the measuring system coordinates with the system under test in a way that avoids measuring outliers, because a closed loop waits for a reply and stops sending during a stall. ScyllaDB published a comparison in 2021 where one workload reported a p99 of 249 microseconds closed-loop and 665 ms under open load with correction, off by about 2,700 times.
A load generator that waits for a reply stops sending during exactly the stall it was built to find, and then averages the silence into the result. The percentile it prints afterwards describes the generator, not your bidder.
Name the stopping number before you tune, because a rewrite decided by exhaustion is not a decision. Two figures, not one: heap allocations per request, which drives assist, and the live heap, which drives marking.
The order is source before ceiling, not largest win first. Start where the assist is generated: escape analysis on the bid path, buffers reused instead of allocated, and a codec that reads the fields you need instead of materialising a fresh object graph. Keep its slices short-lived: a slice into the request buffer pins that whole buffer for the life of the bid.
The approach has a ceiling: Uber reported in 2021 that tuning GOGC against the container memory limit recovered around 70,000 cores across its mission-critical services. That is a cost result, not a percentile.
RTB House described a JVM bidding service in June 2025 where a split into microservices produced a high volume of small requests: the added latency had to stay inside 7 ms against an average request of about 2.5 ms, and the 98th and 99th percentiles broke under frequent G1 pauses. They moved to generational ZGC and paid in memory.
Note what that repair required: a second collector to switch to. Go ships one and it is not pluggable, so the Go levers are allocation rate, the shape of the live set, and GOGC against GOMEMLIMIT. What a Rust hot path removes instead is specific: no assist, no background marking, no forced cycle. What does not go away is longer than most teams expect:
If the tail lives in parsing the request, in the connection to the exchange or in scheduler queueing, Rust returns none of those milliseconds, and rewriting the wrong component spends a quarter to keep the same timeout rate.
So move the smallest piece that owns the allocations, not the service: the impression evaluation loop and its indexes, candidate selection, targeting, frequency and budget lookups, scoring. Price the boundary per crossing: once per bid request with a flat buffer, never once per targeting rule. Validate on separate instances fed a mirrored stream, never inside the process under test, where a shadow path doubles the two quantities you are measuring.
An acceptance test that works on either path: run a short window with the collector off, under a memory ceiling you control, and record p99.9 inside the handler. Run it on one instance behind a fraction of traffic with an automatic revert, because with GOGC off a heap spike against the ceiling puts the runtime into back-to-back cycles, and the guide says that stall can be indefinite. Read the result only if the GC CPU limiter never engaged: once it does, the percentile describes the limiter.
The second half of the question, who does this kind of work, has a test that does not need a vendor list. A firm that repairs GC-driven latency behaves differently from one that sells a rewrite:
None of them requires trust: each is a document you can ask for in the first conversation, and one that comes back vague says the diagnosis is being skipped.
So the first question is not which language. It is which of the three sums - handler, scheduler or wire - owns the missing milliseconds on the connection that times out, and whether bytes allocated per bid request moves when you attack it.
What amBrain can substantiate publicly: amBrain is a software development company specializing in trading platforms, matching engines, real-time bidding systems, and casino platform engineering, we have been building software since 2019, and in AdTech what we build is DSP development, real-time bidding platforms and ad exchange engineering. We work in three formats: full delivery, a dedicated team, or engineers embedded in yours. If you are weighing a rewrite against a tuning pass, the conversation worth having runs the split before it picks a language.
Bring your current architecture and the failure mode that worries you, and we will go through it together in half an hour.