# Real-Time Bidding Exchange Architecture: Engineering Ad Auctions at Scale

## Blog Details

- **Author**: Naveen R.
- **Date**: September 20, 2026
- **Tags**: real-time bidding, distributed systems, ad tech, auction mechanics, system architecture
- **Read Time**: 20 mins

## 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.

![High level architecture of a real-time bidding exchange where a publisher page ad request reaches the ad server, which opens an auction on the bid exchange, sends bid requests to demand-side platforms, checks budget and pacing state, and logs the impression.](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ad-serving-rtb/01-high-level-architecture.png)

![Scalable ad serving architecture where regional ad servers front an auction fleet that fans out parallel bids to demand-side platforms, consults distributed budget counters and a targeting index, and streams impressions and clicks into an event pipeline feeding billing and aggregation.](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ad-serving-rtb/02-scalable-architecture.png)

## Auction Mechanics: First-Price vs. Second-Price

![Auction mechanics where a bid request goes to a bid collector that gathers competing bids, and a second-price rule determines the winner as the highest bidder who pays the second highest clearing price.](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ad-serving-rtb/03-auction-mechanics.png)

### 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.

```python
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 $5.00 competes as an effective bid of $6.00. If it wins against a $5.50 bid with quality 1.0, the winner pays $5.50/1.2 = $4.58, not $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.

```python
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

![Bidder fan-out where the exchange sends parallel bid requests to several demand-side platforms under a strict 100ms deadline, only timely bids reach the response collector while a slow bidder is dropped, and the collector forwards timely bids to the auction.](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ad-serving-rtb/04-bidder-fanout.png)

![Targeting and candidate indexing where a bid request with user context is matched against an inverted targeting index and a frequency cap store, filtering to eligible candidate ads that then enter the auction.](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ad-serving-rtb/06-targeting-indexing.png)

### 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

```python
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.

```python
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:

```python
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:

```python
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 where a campaign budget drives a pacing controller that sets a bid probability throttle to gate the auction, wins produce spend events that increment distributed spend counters, and the counters feed back into the controller to smooth spend across the day.](https://d5osvdbc8um23.cloudfront.net/static-asset/blog_images/system-design-ad-serving-rtb/05-budget-pacing.png)

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:

```python
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:

```python
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:

```python
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 = [