Real-Time Bidding Exchange Architecture: Engineering Ad Auctions at Scale
Introduction
Real-time bidding (RTB) exchanges are among the most demanding distributed systems in production today. Every time a user loads a webpage, an auction completes in under 100 milliseconds, evaluating bids from dozens of demand partners, enforcing advertiser budgets that may span multiple data centers, and applying frequency caps tied to user history. At scale, these systems process millions of queries per second while maintaining strict latency requirements where every millisecond of delay translates to measurable revenue loss.
This post dissects the architecture of a real-time ad serving and bidding exchange, focusing on the engineering challenges that separate functional prototypes from production systems handling billions of daily requests. We'll examine auction mechanics, bidder integration patterns, budget enforcement, frequency capping, and targeting indexes, with concrete implementation examples and failure mode analysis throughout.


Auction Mechanics: First-Price vs. Second-Price

Second-Price Auctions (Vickrey)
In a traditional implementation, second-price auctions charge the winner the amount of the second-highest bid plus one cent. The theoretical advantage is truthful bidding: bidders submit their true valuation because bidding higher doesn't increase their cost, only their win probability.
class SecondPriceAuction:
def run_auction(self, bids: List[Bid]) -> AuctionResult:
if len(bids) < 2:
return AuctionResult(winner=None, price=0)
# Sort bids descending by effective_bid (bid * quality_score)
sorted_bids = sorted(
bids,
key=lambda b: b.amount * b.quality_score,
reverse=True
)
winner = sorted_bids[0]
second_highest = sorted_bids[1]
# Winner pays second price, adjusted for quality difference
clearing_price = (
second_highest.amount *
second_highest.quality_score /
winner.quality_score
) + 0.01
return AuctionResult(
winner=winner,
price=min(clearing_price, winner.amount)
)
The quality score adjustment is critical. In a typical implementation, an ad with a quality score of 1.2 and a bid of 6.00. If it wins against a 5.50/1.2 = 5.50.
However, second-price auctions create gaming opportunities. Bidders can discover price floors through binary search, bidding progressively lower until they stop winning. Demand-side platforms (DSPs) can also coordinate across multiple bidder seats to manipulate the second-price signal.
First-Price Auctions
Between 2017 and 2019, major exchanges including Google Ad Manager and AppNexus (now Xandr) transitioned to first-price auctions. The winner now pays exactly what they bid. This shift eliminated the price discovery problem but introduced a new challenge: bid shading.
class FirstPriceAuction:
def __init__(self, bid_landscape_model):
self.model = bid_landscape_model
def run_auction(self, bids: List[Bid]) -> AuctionResult:
if not bids:
return AuctionResult(winner=None, price=0)
winner = max(bids, key=lambda b: b.amount * b.quality_score)
# Winner pays their full bid
return AuctionResult(
winner=winner,
price=winner.amount
)
def shade_bid(self, true_value: float, context: BidContext) -> float:
"""
Example bid shading: reduce bid based on estimated win probability
"""
predicted_competition = self.model.predict_second_price(context)
win_prob = self.model.estimate_win_probability(
true_value,
predicted_competition
)
# Example strategy: bid between predicted second price and true value
# based on win probability threshold
if win_prob > 0.8:
# High confidence: shade aggressively
return predicted_competition * 1.05
elif win_prob > 0.5:
# Moderate confidence: partial shading
return (predicted_competition + true_value) / 2
else:
# Low confidence: bid closer to true value
return true_value * 0.95
Bid shading algorithms attempt to bid just above the expected second-highest bid. DSPs now invest heavily in machine learning models that predict competitive landscapes based on historical auction data, time of day, publisher, and user characteristics. The accuracy of these models directly impacts campaign profitability.
Auction Selection Implications
First-price auctions reduce exchange complexity by eliminating the need to defend against price discovery attacks. They also increase transparency since the clearing price is simply the winning bid. However, they shift optimization complexity to bidders, who must now maintain sophisticated win-rate prediction models.
For the exchange, first-price auctions simplify the codebase and reduce the attack surface. The tradeoff is that poorly-optimized bidders may overpay systematically, potentially reducing their lifetime value as customers.
Bidder Fan-Out and Timeout Management


The 100ms Budget
A typical RTB request flow allocates the total latency budget as follows:
- Ad server receives request: 0ms
- User lookup and targeting: 10-15ms
- Bidder fan-out (parallel): 50-80ms
- Auction and creative selection: 5-10ms
- Ad markup rendering: 5-10ms
- Response to publisher: 100ms total
The bidder fan-out phase dominates the latency budget. The exchange must send bid requests to 20-100 potential bidders, wait for responses, and proceed with whatever arrives before the deadline.
Parallel Request Architecture
import asyncio
from typing import List, Optional
from dataclasses import dataclass
from enum import Enum
class BidderStatus(Enum):
SUCCESS = "success"
TIMEOUT = "timeout"
ERROR = "error"
CIRCUIT_OPEN = "circuit_open"
@dataclass
class BidResponse:
bidder_id: str
bid: Optional[Bid]
latency_ms: float
status: BidderStatus
class BidderFanOut:
def __init__(self, timeout_ms: int = 100):
self.timeout_ms = timeout_ms
self.circuit_breakers = {}
async def fetch_bids(
self,
bid_request: BidRequest,
eligible_bidders: List[str]
) -> List[BidResponse]:
"""
Fan out to all eligible bidders with timeout protection
"""
tasks = []
for bidder_id in eligible_bidders:
circuit_breaker = self.circuit_breakers.get(bidder_id)
if circuit_breaker and circuit_breaker.is_open():
# Skip bidders with open circuit breakers
tasks.append(
self._circuit_open_response(bidder_id)
)
else:
tasks.append(
self._fetch_single_bid(bidder_id, bid_request)
)
# Wait for all tasks, but enforce global timeout
try:
responses = await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True),
timeout=self.timeout_ms / 1000.0
)
return self._process_responses(responses)
except asyncio.TimeoutError:
# Global timeout exceeded, return partial results
return self._extract_completed_responses(tasks)
async def _fetch_single_bid(
self,
bidder_id: str,
bid_request: BidRequest
) -> BidResponse:
start_time = time.perf_counter()
try:
# Per-bidder timeout slightly less than global timeout
async with asyncio.timeout(self.timeout_ms * 0.8 / 1000.0):
response = await self._http_post(bidder_id, bid_request)
latency_ms = (time.perf_counter() - start_time) * 1000
self._record_success(bidder_id, latency_ms)
return BidResponse(
bidder_id=bidder_id,
bid=response.bid,
latency_ms=latency_ms,
status=BidderStatus.SUCCESS
)
except asyncio.TimeoutError:
latency_ms = (time.perf_counter() - start_time) * 1000
self._record_timeout(bidder_id, latency_ms)
return BidResponse(
bidder_id=bidder_id,
bid=None,
latency_ms=latency_ms,
status=BidderStatus.TIMEOUT
)
except Exception as e:
latency_ms = (time.perf_counter() - start_time) * 1000
self._record_error(bidder_id, e)
return BidResponse(
bidder_id=bidder_id,
bid=None,
latency_ms=latency_ms,
status=BidderStatus.ERROR
)
Circuit Breaker Pattern
When a bidder repeatedly times out or returns errors, continuing to call it wastes latency budget that could be allocated to more responsive bidders. Circuit breakers automatically stop calling failing bidders for a cooldown period.
from collections import deque
import time
class BidderCircuitBreaker:
def __init__(
self,
failure_threshold: int = 10,
success_threshold: int = 3,
timeout_seconds: int = 30
):
"""
Example configuration:
- Open circuit after 10 consecutive failures
- Close circuit after 3 consecutive successes in half-open state
- Wait 30 seconds before attempting half-open state
"""
self.failure_threshold = failure_threshold
self.success_threshold = success_threshold
self.timeout_seconds = timeout_seconds
self.state = "closed" # closed, open, half_open
self.failure_count = 0
self.success_count = 0
self.last_failure_time = None
# Track recent latencies for adaptive timeout
self.recent_latencies = deque(maxlen=100)
def is_open(self) -> bool:
if self.state == "open":
# Check if timeout period has elapsed
if (time.time() - self.last_failure_time) > self.timeout_seconds:
self.state = "half_open"
self.success_count = 0
return False
return True
return False
def record_success(self, latency_ms: float):
self.recent_latencies.append(latency_ms)
if self.state == "half_open":
self.success_count += 1
if self.success_count >= self.success_threshold:
# Recovered: close the circuit
self.state = "closed"
self.failure_count = 0
self.success_count = 0
elif self.state == "closed":
# Reset failure count on success
self.failure_count = 0
def record_failure(self):
self.failure_count += 1
self.last_failure_time = time.time()
if self.state == "half_open":
# Failed during recovery: reopen circuit
self.state = "open"
self.success_count = 0
elif self.state == "closed":
if self.failure_count >= self.failure_threshold:
self.state = "open"
def get_adaptive_timeout(self) -> float:
"""
Calculate timeout based on recent latency percentiles
"""
if len(self.recent_latencies) < 10:
return 100.0 # Default timeout
sorted_latencies = sorted(self.recent_latencies)
p95_latency = sorted_latencies[int(len(sorted_latencies) * 0.95)]
# Set timeout at P95 + 20% buffer
return p95_latency * 1.2
In a typical implementation, circuit breakers track per-bidder error rates over rolling time windows. When the error rate exceeds a threshold (for example, 50% errors over 60 seconds), the circuit opens. After a cooldown period, the circuit enters a half-open state, allowing a small percentage of requests through to test recovery.
Connection Pooling and HTTP/2
Establishing new TCP connections for every bid request adds 20-50ms of latency. Production exchanges maintain persistent connection pools to each bidder:
class BidderConnectionPool:
def __init__(self, bidder_endpoint: str, pool_size: int = 100):
"""
Example: maintain 100-500 persistent connections per bidder
based on expected QPS
"""
self.endpoint = bidder_endpoint
self.pool_size = pool_size
self.connections = asyncio.Queue(maxsize=pool_size)
self._initialize_pool()
async def _initialize_pool(self):
for _ in range(self.pool_size):
conn = await self._create_connection()
await self.connections.put(conn)
async def _create_connection(self):
# HTTP/2 connection with multiplexing
return await aiohttp.ClientSession(
connector=aiohttp.TCPConnector(
limit=self.pool_size,
ttl_dns_cache=300,
keepalive_timeout=30
)
).get(self.endpoint)
async def execute_request(self, bid_request: BidRequest):
conn = await self.connections.get()
try:
response = await conn.post(
self.endpoint,
json=bid_request.to_dict(),
timeout=aiohttp.ClientTimeout(total=0.08) # 80ms
)
return await response.json()
finally:
# Return connection to pool
await self.connections.put(conn)
HTTP/2 multiplexing allows multiple bid requests to share a single TCP connection, reducing connection overhead. Some exchanges report 15-25% latency improvements from HTTP/2 adoption compared to HTTP/1.1 with connection pooling.
Geographic Latency Considerations
Network latency between the exchange and bidders creates a physical lower bound on response times. In a typical deployment, an exchange in Northern Virginia (us-east-1) communicating with a bidder in Northern California (us-west-1) faces approximately 60-80ms of round-trip latency, consuming most of the available budget.
To mitigate this, exchanges deploy regionally:
class GeographicRouter:
def __init__(self):
self.region_endpoints = {
"us-east-1": "https://bidder.example.com/us-east",
"us-west-1": "https://bidder.example.com/us-west",
"eu-west-1": "https://bidder.example.com/eu-west",
}
def route_request(
self,
bid_request: BidRequest,
exchange_region: str
) -> str:
"""
Route to geographically closest bidder endpoint
"""
# Prefer same-region endpoint
if exchange_region in self.region_endpoints:
return self.region_endpoints[exchange_region]
# Fallback to default endpoint
return self.region_endpoints["us-east-1"]
Sophisticated bidders provide regional endpoints and negotiate with exchanges to ensure requests are routed to the nearest data center. This can reduce latency by 30-50ms compared to cross-country routing.
Budget Pacing
Advertiser campaigns have daily or lifetime budgets that must be enforced in real-time. If a campaign has a $10,000 daily budget and exhausts it by 9 AM, the advertiser receives no impressions for the remaining 15 hours, resulting in poor campaign performance and dissatisfied customers.

Budget pacing algorithms spread spend evenly across the target time period while maximizing the value of impressions won.
Centralized Budget Tracking
In a typical implementation, a centralized budget service maintains authoritative spend state:
from dataclasses import dataclass
from datetime import datetime, timedelta
import redis
@dataclass
class CampaignBudget:
campaign_id: str
daily_budget: float
spent_today: float
budget_reset_time: datetime
target_cpm: float
class BudgetService:
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
def check_and_reserve_budget(
self,
campaign_id: str,
bid_amount: float
) -> bool:
"""
Atomically check budget availability and reserve funds
"""
key = f"budget:{campaign_id}:today"
# Lua script for atomic check-and-decrement
script = """
local budget_key = KEYS[1]
local bid_amount = tonumber(ARGV[1])
local daily_budget = tonumber(ARGV[2])
local spent = tonumber(redis.call('GET', budget_key) or 0)
if spent + bid_amount <= daily_budget then
redis.call('INCRBYFLOAT', budget_key, bid_amount)
return 1
else
return 0
end
"""
campaign = self._get_campaign(campaign_id)
result = self.redis.eval(
script,
1,
key,
bid_amount,
campaign.daily_budget
)
return bool(result)
def release_budget(self, campaign_id: str, bid_amount: float):
"""
Release reserved budget if auction is lost
"""
key = f"budget:{campaign_id}:today"
self.redis.incrbyfloat(key, -bid_amount)
This approach provides strong consistency: every bid request checks the centralized service before participating in the auction. The downside is latency. If the budget service is 10ms away, we've consumed 10% of the total latency budget on a single check.
Throttling and Pacing Algorithms
Rather than bidding on every eligible impression until the budget exhausts, pacing algorithms throttle bid participation to spread spend across the day:
import math
from datetime import datetime, time
class BudgetPacer:
def __init__(self, budget_service: BudgetService):
self.budget_service = budget_service
def should_bid(
self,
campaign: CampaignBudget,
current_time: datetime
) -> bool:
"""
Determine if campaign should participate in this auction
based on pacing algorithm
"""
# Calculate time progress through the day
seconds_since_midnight = (
current_time - current_time.replace(
hour=0, minute=0, second=0, microsecond=0
)
).total_seconds()
time_progress = seconds_since_midnight / 86400.0 # 86400 seconds per day
# Calculate budget progress
budget_progress = campaign.spent_today / campaign.daily_budget
# Throttle if spending ahead of schedule
if budget_progress > time_progress * 1.2: # 20% tolerance
# Aggressive throttling: participate in fewer auctions
target_participation_rate = 0.3
elif budget_progress > time_progress:
# Moderate throttling
target_participation_rate = 0.6
else:
# Behind schedule: participate in more auctions
target_participation_rate = 1.0
# Probabilistic throttling
import random
return random.random() < target_participation_rate
def calculate_throttle_rate(
self,
campaign: CampaignBudget,
current_time: datetime,
estimated_hourly_volume: int
) -> float:
"""
Calculate precise throttle rate based on remaining budget
and expected impression volume
"""
seconds_remaining = 86400 - (
current_time - current_time.replace(
hour=0, minute=0, second=0, microsecond=0
)
).total_seconds()
hours_remaining = seconds_remaining / 3600.0
remaining_budget = campaign.daily_budget - campaign.spent_today
# Estimate impressions needed to spend remaining budget
estimated_cpm = campaign.target_cpm
impressions_needed = (remaining_budget / estimated_cpm) * 1000
# Estimate total impressions available in remaining time
estimated_available = estimated_hourly_volume * hours_remaining
if estimated_available == 0:
return 0.0
# Throttle rate: impressions needed / impressions available
throttle_rate = min(1.0, impressions_needed / estimated_available)
return throttle_rate
The throttle rate determines what fraction of eligible auctions the campaign participates in. If the rate is 0.5, the campaign bids on 50% of opportunities, selected randomly or based on value signals.
Distributed Budget Pacing
Centralized budget tracking creates a single point of failure and a latency bottleneck. Production systems distribute budget allocation across multiple servers:
from dataclasses import dataclass
from typing import Dict
import hashlib
@dataclass
class BudgetShard:
shard_id: int
allocated_budget: float
spent: float
class DistributedBudgetPacer:
def __init__(self, num_shards: int = 100):
self.num_shards = num_shards
self.shards: Dict[str, BudgetShard] = {}
def allocate_budget(self, campaign_id: str, daily_budget: float):
"""
Distribute daily budget across shards at start of day
"""
budget_per_shard = daily_budget / self.num_shards
for shard_id in range(self.num_shards):
key = f"{campaign_id}:shard:{shard_id}"
self.shards[key] = BudgetShard(
shard_id=shard_id,
allocated_budget=budget_per_shard,
spent=0.0
)
def get_shard_for_request(self, campaign_id: str, request_id: str) -> int:
"""
Deterministically assign request to a shard
"""
hash_input = f"{campaign_id}:{request_id}"
hash_value = int(hashlib.md5(hash_input.encode()).hexdigest(), 16)
return hash_value % self.num_shards
def check_shard_budget(
self,
campaign_id: str,
shard_id: int,
bid_amount: float
) -> bool:
"""
Check budget availability in assigned shard
"""
key = f"{campaign_id}:shard:{shard_id}"
shard = self.shards.get(key)
if not shard:
return False
if shard.spent + bid_amount <= shard.allocated_budget:
shard.spent += bid_amount
return True
return False
def rebalance_shards(self, campaign_id: str):
"""
Periodically rebalance budget across shards to prevent
starvation when some shards exhaust early
"""
campaign_shards = [
