Gleam for Ad-Serving Pipelines
Erlang's concurrency model with a type system that does not make you want to quit programming. We run our bid orchestration on it.
Our bid orchestration service fans out to 23 demand-side platforms simultaneously, collects their responses, runs an auction, and returns a winner. The entire operation must complete in under 100 milliseconds. If it does not, the supply-side platform times us out and we lose the impression. Lost impressions are lost revenue.
We run this service on Gleam, targeting the BEAM virtual machine. It handles 140,000 concurrent bid orchestrations at peak traffic. It has not crashed in production in 7 months. The longest GC pause we have observed on any individual BEAM process is 0.3 milliseconds.
Why the BEAM
The BEAM virtual machine, originally built for Erlang at Ericsson in the 1980s, was designed for telephone switches. Telephone switches have the same requirements as ad-serving: massive concurrency, strict latency bounds, and zero tolerance for total system failure.
BEAM processes are not OS threads. They are lightweight, isolated units of computation that cost roughly 2KB of memory each. We spawn one process per bid request. At 140,000 concurrent requests, that is 280MB of memory for process overhead. On a machine with 64GB of RAM, this is a rounding error.
BEAM's scheduler is preemptive. Every process gets a reduction budget (roughly 4,000 function calls), and when it exhausts that budget, the scheduler suspends it and runs another process. This means a single slow process cannot starve the system. In ad-serving, this is critical: one DSP might respond in 2ms while another takes 90ms. The slow DSP's process does not block the 139,999 other bid orchestrations running concurrently.
The garbage collector runs per-process. Each BEAM process has its own heap. When a process's heap fills up, only that process pauses for GC. The other 139,999 processes keep running. This is the property that makes BEAM suitable for soft real-time systems: GC pauses are bounded, isolated, and short. Our bid orchestration processes live for less than 100ms and allocate less than 50KB. Most of them are collected in under 0.1ms.
Why Gleam Over Elixir
Elixir is a good language. We used it for two years before switching to Gleam. The switch was driven by one requirement: static types for systems that handle money.
Every bid in programmatic advertising has a price attached. Prices are in different currencies. Floor prices, bid prices, clearing prices, and commission rates flow through the system. A type error in the pricing pipeline does not cause a test to fail. It causes us to overbid or underbid on inventory, and the financial impact compounds with every impression.
Elixir is dynamically typed. Dialyzer provides optional type checking, but it is unsound by design (it finds some type errors, not all) and its error messages require a PhD in type theory to interpret. We had a bug in production where a floor price in USD micros (integer) was compared against a bid price in CPM (float). Dialyzer did not catch it. The bug ran for 6 hours and cost us $14,000 in overbids before a human noticed the margin anomaly.
Gleam's type system is simple, sound, and mandatory. Every function has a type signature. Every value has a known type at compile time. The compiler will not produce a binary if there is a type mismatch. Our pricing pipeline now uses a Money type:
pub type Currency {
USD
SGD
EUR
JPY
}
pub type Money {
Money(amount_micros: Int, currency: Currency)
}
pub fn add(a: Money, b: Money) -> Result(Money, PricingError) {
case a.currency == b.currency {
True -> Ok(Money(a.amount_micros + b.amount_micros, a.currency))
False -> Error(CurrencyMismatch(a.currency, b.currency))
}
}
You cannot add USD to SGD without an explicit conversion. You cannot compare a Money value to a raw integer. The compiler enforces this. We have not had a pricing type error since we migrated to Gleam.
The Bid Orchestration Pipeline
Here is how a bid request flows through our Gleam service:
1. Request Ingestion. An HTTP request arrives from the SSP. We parse the OpenRTB bid request using Gleam's pattern matching to validate the structure:
pub fn parse_bid_request(json: Dynamic) -> Result(BidRequest, ParseError) {
use id <- result.try(dynamic.field("id", dynamic.string)(json))
use imp <- result.try(dynamic.field("imp", dynamic.list(parse_impression))(json))
use device <- result.try(dynamic.field("device", parse_device)(json))
use user <- result.try(dynamic.field("user", parse_user)(json))
Ok(BidRequest(id:, imp:, device:, user:))
}
If any required field is missing or has the wrong type, the function returns an error. No exceptions. No null pointer dereferences. The error is a value that the caller must handle.
2. DSP Fan-Out. We spawn one BEAM process per DSP. Each process sends the bid request to its DSP, waits for a response with a timeout, and returns the result:
pub fn request_bid(dsp: DspConfig, request: BidRequest) -> Result(BidResponse, DspError) {
let deadline = erlang.system_time(Millisecond) + dsp.timeout_ms
case httpc.post(dsp.endpoint, encode_request(request), deadline) {
Ok(response) -> parse_bid_response(response.body)
Error(Timeout) -> Error(DspTimeout(dsp.name))
Error(other) -> Error(DspNetworkError(dsp.name, other))
}
}
All 23 DSP requests execute concurrently. The BEAM scheduler multiplexes them across 16 CPU cores. If a DSP times out at 90ms, only that process is affected. The other 22 responses are already collected.
3. Auction. Once all responses arrive (or timeout), we run a second-price auction:
pub fn run_auction(responses: List(BidResponse), floor: Money) -> Result(AuctionResult, AuctionError) {
let valid_bids =
responses
|> list.filter_map(fn(r) {
case r.bid.price.amount_micros > floor.amount_micros {
True -> Ok(r)
False -> Error(Nil)
}
})
|> list.sort(fn(a, b) {
int.compare(b.bid.price.amount_micros, a.bid.price.amount_micros)
})
case valid_bids {
[winner, second, ..] ->
Ok(AuctionResult(
winner: winner.dsp,
creative: winner.bid.creative,
clearing_price: Money(second.bid.price.amount_micros + 1, floor.currency),
))
[winner] ->
Ok(AuctionResult(
winner: winner.dsp,
creative: winner.bid.creative,
clearing_price: Money(floor.amount_micros + 1, floor.currency),
))
[] -> Error(NoBidsAboveFloor)
}
}
The pattern match on the sorted bid list handles all three cases explicitly: two or more bids (second-price), one bid (floor + 1 micro), and no valid bids. The compiler verifies that we handle every case. There is no "else" clause hiding a logic error.
4. Response. We serialize the auction result and return it to the SSP. Total time: median 34ms, P99 72ms. Well within the 100ms timeout.
OTP Supervision
Every bid orchestration process runs under an OTP supervisor. If a process crashes due to an unexpected error (a malformed DSP response that our parser did not anticipate, a network error during serialization, anything), the supervisor logs the crash and the process dies. It does not bring down other processes. It does not corrupt shared state, because BEAM processes do not share state.
Our supervision tree has three layers:
- Top-level supervisor: restarts the entire application if something catastrophic happens (never triggered in production).
- Endpoint supervisor: manages the HTTP listener and connection pool. One-for-one restart strategy.
- Request supervisor: dynamic supervisor that spawns bid orchestration processes. If a request process crashes, it is simply gone. The SSP's timeout handles the missing response.
We process roughly 4 billion bid requests per month. In the last 7 months, we have had 2,847 individual process crashes. That is a crash rate of 0.00007%. Each crash affected exactly one bid request. The other 3,999,999,999 requests were unaffected.
The Tradeoff
Gleam is young. The package ecosystem is small compared to Elixir's. We have written our own OpenRTB parser, our own connection pool tuned for DSP traffic patterns, and our own metrics library that exports to our internal observability stack.
This is the cost. The benefit is a statically-typed language running on the most battle-tested concurrent runtime in existence. The BEAM has been running telephone switches since 1986. It has been running WhatsApp (2 billion users, 50 engineers) since 2009. It will handle our ad-serving traffic.
Gleam gives us the BEAM's concurrency model with a type system that catches the errors that matter most: the ones that involve money. For ad-serving pipelines where every millisecond is a business constraint and every price calculation is a financial transaction, this combination is not just good. It is the only combination that makes sense.