Designing a Real-Time Ad Serving System: Auction Mechanics, Targeting, and Budget Pacing at Scale
A deep-dive into building an ad serving platform covering RTB auction flow, second-price vs first-price auctions, user targeting, budget pacing algorithms, latency budgets under 100ms, and infrastructure for millions of bid requests per second.
Ad serving is one of the most latency-sensitive, high-throughput distributed systems in existence. A single page load can trigger dozens of simultaneous auctions, each requiring your system to fetch user data, evaluate targeting rules, calculate bids, enforce budget constraints, and respond in under 100 milliseconds. Miss that deadline and you return nothing. Return nothing and you earn nothing.
This article walks through the architecture of a production-grade ad serving system: the RTB auction flow, auction pricing mechanics, how targeting works at scale, budget pacing algorithms, and the infrastructure choices that make sub-100ms responses possible under millions of requests per second.
The RTB Auction Flow
Real-time bidding (RTB) is a protocol where ad impressions are auctioned off individually, in real time, as a user loads a page. The key participants are:
- Publisher: the website or app with ad inventory
- SSP (Supply-Side Platform): aggregates publisher inventory and runs auctions
- DSP (Demand-Side Platform): bids on impressions on behalf of advertisers
- Ad Exchange: the marketplace connecting SSPs and DSPs
The flow looks like this:
User loads page
└─> Publisher ad tag fires
└─> SSP receives impression opportunity
└─> SSP sends bid request to all registered DSPs (in parallel)
└─> DSPs evaluate, bid, respond within ~80ms
└─> SSP runs auction, selects winner
└─> SSP returns winning creative to publisher
└─> Ad renders in browser
└─> SSP sends win/loss notifications
Each DSP must complete the full decision cycle independently and within the timeout window. The SSP typically waits 80-100ms and discards any late responses, regardless of bid amount.
Auction Pricing: First-Price vs Second-Price
Until around 2019, most programmatic auctions used second-price mechanics (Vickrey auctions): the winner pays the second-highest bid plus one cent. The DSP could bid its true valuation without overpaying.
First-price auctions changed this. The winner pays exactly what they bid, which means the equilibrium strategy is bid shading: bidding below your true valuation to avoid overpaying.
interface AuctionResult {
winnerId: string;
clearingPrice: number;
mechanism: 'first-price' | 'second-price';
}
function runAuction(bids: BidResponse[], mechanism: 'first-price' | 'second-price', floorPrice: number): AuctionResult | null {
const eligibleBids = bids
.filter(b => b.price >= floorPrice)
.sort((a, b) => b.price - a.price);
if (eligibleBids.length === 0) return null;
const winner = eligibleBids[0];
const clearingPrice = mechanism === 'second-price'
? (eligibleBids[1]?.price ?? floorPrice) + 0.01
: winner.price;
return {
winnerId: winner.bidderId,
clearingPrice,
mechanism,
};
}
The floor price is critical in both models. Publishers set floors to prevent their inventory from clearing at pennies. In second-price auctions, the floor acts as the second bid when no other eligible bid exists. In first-price auctions, the floor simply excludes low bids.
Bid shading in first-price auctions is typically implemented by the DSP. A simple approach adjusts the bid down by a learned factor based on historical win rates at various price points:
interface BidShadingModel {
winRateByPriceDecile: number[]; // 10 buckets, each representing 10th percentile of auction clearings
targetWinRate: number; // e.g., 0.5 for 50% win rate
}
function shadeBid(rawBid: number, model: BidShadingModel): number {
// Find the price decile where our target win rate is achieved
const targetDecile = model.winRateByPriceDecile.findIndex(
rate => rate >= model.targetWinRate
);
if (targetDecile === -1) return rawBid; // no data, bid full value
// Scale the bid down proportionally
const shadeFactor = targetDecile / model.winRateByPriceDecile.length;
return rawBid * (0.7 + shadeFactor * 0.3); // floor shading at 70% of raw bid
}
The winRateByPriceDecile model is trained offline on historical auction data and updated periodically, not in the hot path.
Targeting: User Segments, Contextual, and Behavioral
An ad impression has value because of what we know about the user and the context. Targeting layers are evaluated during the bid decision and can disqualify a bid before any pricing calculation happens.
User Segment Targeting
Advertisers define audience segments: users who browsed a product page in the last 7 days, users in a specific geography, users with a household income signal above a threshold. Segments are precomputed and stored in a fast lookup store keyed by user ID.
interface UserProfile {
userId: string;
segmentIds: Set<number>;
geoCodes: string[]; // ["US-CA", "US"]
deviceType: 'mobile' | 'desktop' | 'tablet';
frequencyMap: Map<string, number>; // campaignId -> impressions served last 24h
}
async function loadUserProfile(userId: string, redisClient: Redis): Promise<UserProfile | null> {
const raw = await redisClient.get(`user:${userId}`);
if (!raw) return null;
const data = JSON.parse(raw);
return {
userId,
segmentIds: new Set(data.segments),
geoCodes: data.geo,
deviceType: data.device,
frequencyMap: new Map(Object.entries(data.freq)),
};
}
User profiles are pre-populated by a separate pipeline that processes clickstream data and DMP (Data Management Platform) feeds. The bid handler reads from Redis (typically a regional replica), never from the DMP directly.
Contextual Targeting
Contextual targeting matches ads to the content of the page rather than the user. This is increasingly important as third-party cookie support erodes. The bid request from the SSP contains the page URL and, in some protocols, a content category code (IAB taxonomy).
type IABCategory = 'IAB1' | 'IAB1-1' | 'IAB2' | 'IAB3'; // etc.
interface BidRequest {
impressionId: string;
publisherId: string;
pageUrl: string;
contentCategories: IABCategory[];
userId?: string; // absent in cookieless contexts
width: number;
height: number;
floorPrice: number;
}
function matchesContextual(
request: BidRequest,
campaign: Campaign
): boolean {
if (campaign.targetedCategories.length === 0) return true;
return request.contentCategories.some(cat =>
campaign.targetedCategories.includes(cat)
);
}
Frequency Capping
Without frequency caps, a single user will see the same ad dozens of times before converting, or more often, before becoming annoyed. Frequency capping is a hard constraint that should be checked before any bid is emitted.
function isFrequencyCapped(
profile: UserProfile,
campaign: Campaign
): boolean {
const impressionsSoFar = profile.frequencyMap.get(campaign.id) ?? 0;
return impressionsSoFar >= campaign.frequencyCapPerDay;
}
Frequency state is maintained in Redis with a TTL aligned to the cap window. A sliding window counter is more accurate than a daily reset but costs more reads and writes. For most campaigns, a daily bucket is sufficient.
Budget Pacing
An advertiser has a daily budget of $10,000 and wants to run their campaign throughout the day. Without pacing, your bidding logic will exhaust the budget in the first two hours and serve nothing for the remaining twenty-two. Advertisers hate this even when the early impressions were efficient.
Even Pacing
The simplest approach: divide the remaining budget by the remaining hours and bid only up to the allowed spend rate. Check and update spend state once per minute.
interface BudgetState {
campaignId: string;
dailyBudgetCents: number;
spentCents: number;
dayStartTimestamp: number;
}
function computePacingMultiplier(state: BudgetState): number {
const now = Date.now();
const dayElapsedMs = now - state.dayStartTimestamp;
const dayDurationMs = 24 * 60 * 60 * 1000;
const elapsedFraction = Math.min(dayElapsedMs / dayDurationMs, 1.0);
const expectedSpend = state.dailyBudgetCents * elapsedFraction;
const actualSpend = state.spentCents;
if (actualSpend >= state.dailyBudgetCents) return 0; // budget exhausted
// If ahead of pace, slow down. If behind, speed up.
const pacingRatio = expectedSpend > 0 ? actualSpend / expectedSpend : 1.0;
// Clamp to reasonable multiplier range [0.1, 2.0]
return Math.max(0.1, Math.min(2.0, 1.0 / pacingRatio));
}
A pacingMultiplier below 1.0 means you are ahead of pace: you should bid less aggressively or skip some opportunities. Above 1.0 means you are behind: bid more aggressively.
Throttle-Based Pacing
A complementary approach uses throttling rather than bid price adjustment. Instead of changing the bid amount, you probabilistically skip impressions to control spend velocity.
function shouldBid(
state: BudgetState,
requestTimestamp: number
): boolean {
if (state.spentCents >= state.dailyBudgetCents) return false;
const multiplier = computePacingMultiplier(state);
if (multiplier >= 1.0) return true; // behind pace, always bid
// Throttle: bid on a fraction of opportunities proportional to how far ahead we are
return Math.random() < multiplier;
}
Throttle-based pacing is simpler to reason about and does not require adjusting the bid calculation logic. The tradeoff is that random sampling introduces variance: you might skip a high-value impression. In practice, most systems combine both: throttle at very low pacing multipliers, adjust bid price otherwise.
Budget State Distribution
At millions of requests per second, you cannot read and write budget state from a single Redis instance on every bid request. Two patterns address this:
Local counters with periodic sync: Each bid server maintains an in-memory spend counter. Every 5 seconds, it syncs the delta to a central Redis key using INCRBY. The central store tracks the true total spend. Local servers read the central total once per sync interval.
class BudgetTracker {
private localSpendDelta = new Map<string, number>(); // campaignId -> cents
private lastKnownTotals = new Map<string, number>(); // campaignId -> total cents
private syncIntervalMs = 5000;
constructor(private redis: Redis) {
setInterval(() => this.sync(), this.syncIntervalMs);
}
recordSpend(campaignId: string, centsSpent: number): void {
const current = this.localSpendDelta.get(campaignId) ?? 0;
this.localSpendDelta.set(campaignId, current + centsSpent);
}
isOverBudget(campaignId: string, dailyBudgetCents: number): boolean {
const total = this.lastKnownTotals.get(campaignId) ?? 0;
const localDelta = this.localSpendDelta.get(campaignId) ?? 0;
return (total + localDelta) >= dailyBudgetCents;
}
private async sync(): Promise<void> {
for (const [campaignId, delta] of this.localSpendDelta.entries()) {
if (delta === 0) continue;
const newTotal = await this.redis.incrby(`spend:${campaignId}`, delta);
this.lastKnownTotals.set(campaignId, newTotal);
this.localSpendDelta.set(campaignId, 0);
}
}
}
The 5-second sync interval means you can temporarily overspend by at most (requestsPerSecond * averageCPM / 1000) * 5 dollars per server per campaign. For a campaign spending $10/hour across 50 servers, the maximum overspend per sync interval per server is roughly $0.03. Acceptable.
The Latency Budget Problem
Responding in under 100 milliseconds sounds manageable until you enumerate what has to happen in that window:
Receive bid request (network + parse) ~2ms
Load user profile from Redis ~1-3ms
Load campaign index from memory ~0ms (pre-loaded)
Evaluate targeting rules ~2-5ms
Budget pacing check ~0ms (in-memory)
Bid price calculation ~1ms
Serialize and send response ~2ms
That leaves roughly 80-90ms of margin for everything to go right. In practice, you also need to handle the case where user profile lookup takes longer than expected (Redis P99 is not the same as Redis P50).
Deadline-aware execution: if user profile lookup has not returned within 15ms, proceed without it. You lose targeting precision but maintain a valid bid. This is a better outcome than a timeout.
async function buildBidResponse(
request: BidRequest,
campaignIndex: CampaignIndex,
redis: Redis
): Promise<BidResponse | null> {
const PROFILE_TIMEOUT_MS = 15;
const profilePromise = request.userId
? loadUserProfile(request.userId, redis)
: Promise.resolve(null);
const profile = await Promise.race([
profilePromise,
new Promise<null>(resolve => setTimeout(() => resolve(null), PROFILE_TIMEOUT_MS)),
]);
const eligibleCampaigns = campaignIndex.getEligible(request, profile);
if (eligibleCampaigns.length === 0) return null;
const bestCampaign = selectByECPM(eligibleCampaigns, profile);
return {
impressionId: request.impressionId,
bidderId: 'my-dsp',
price: bestCampaign.bidCents / 100,
creativeId: bestCampaign.creativeId,
};
}
Campaign Index: Keep It In-Memory
Campaign data (targeting rules, bids, creative assignments) should be preloaded into memory and updated incrementally, not queried per request. A campaign index is rebuilt every 60 seconds from a database snapshot and pushed to all bid servers. During the rebuild window, the previous index remains active.
interface Campaign {
id: string;
targetedSegments: number[];
targetedCategories: IABCategory[];
targetedGeoCodes: string[];
allowedDeviceTypes: ('mobile' | 'desktop' | 'tablet')[];
frequencyCapPerDay: number;
baseBidCents: number;
dailyBudgetCents: number;
creativeId: string;
}
class CampaignIndex {
private campaigns: Campaign[] = [];
update(newCampaigns: Campaign[]): void {
this.campaigns = newCampaigns; // atomic reference swap
}
getEligible(request: BidRequest, profile: UserProfile | null): Campaign[] {
return this.campaigns.filter(c => {
if (!matchesContextual(request, c)) return false;
if (!matchesGeo(request, c)) return false;
if (!c.allowedDeviceTypes.includes(request.deviceType)) return false;
if (profile && !matchesSegments(profile, c)) return false;
if (profile && isFrequencyCapped(profile, c)) return false;
return true;
});
}
}
The update call is a single reference swap on the campaigns array, which is safe in Node.js (single-threaded event loop). In Go or Java you would use an atomic pointer or copy-on-write structure.
Infrastructure Architecture
┌─────────────────────────────────────┐
│ SSP / Ad Exchange │
└───────────────┬─────────────────────┘
│ bid request (HTTP/2, protobuf)
▼
┌─────────────────────────────────────┐
│ Load Balancer │
│ (L4, anycast, multi-region) │
└───────────────┬─────────────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Bid Node │ │ Bid Node │ │ Bid Node │
│ (Node.js │ │ (Node.js │ │ (Node.js │
│ or Go) │ │ or Go) │ │ or Go) │
│ │ │ │ │ │
│ Campaign │ │ Campaign │ │ Campaign │
│ Index │ │ Index │ │ Index │
│ (memory) │ │ (memory) │ │ (memory) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└──────────┬─────────┘ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Redis Cluster │ │ Redis Cluster │
│ (user profiles, │ │ (budget state, │
│ freq caps) │ │ spend counters) │
└──────────────────┘ └──────────────────┘
│
▼
┌──────────────────┐
│ Campaign Store │
│ (Postgres + │
│ Redis cache) │
│ refresh every │
│ 60s │
└──────────────────┘
Bid nodes are stateless. Their only external calls are the Redis reads for user profiles and budget state. The campaign index lives in process memory. Horizontal scaling is uncomplicated: add more nodes behind the load balancer.
Win notification processing happens asynchronously. When the SSP notifies your DSP that a bid won (or lost), you enqueue the event to a Kafka topic and process it out of the critical path. Spend tracking updates, frequency cap increments, and creative performance logging all happen here.
interface WinNotification {
impressionId: string;
campaignId: string;
clearingPriceCents: number;
userId?: string;
timestamp: number;
}
async function processWin(notification: WinNotification, tracker: BudgetTracker, redis: Redis): Promise<void> {
// Update spend
tracker.recordSpend(notification.campaignId, notification.clearingPriceCents);
// Increment frequency cap
if (notification.userId) {
await redis.incr(`freq:${notification.userId}:${notification.campaignId}`);
await redis.expire(`freq:${notification.userId}:${notification.campaignId}`, 86400);
}
}
Tradeoffs: Pacing and Targeting Strategies
| Approach | Accuracy | Latency impact | Overspend risk | Best for |
|---|---|---|---|---|
| Even pacing, bid multiplier | High | None (in-memory) | Low with 5s sync | Most campaigns |
| Throttle-based pacing | Medium | None | Low | Simple to implement |
| Per-request budget check (Redis) | Highest | +3-5ms per request | Near zero | Small high-value budgets |
| Behavioral targeting (user segments) | High relevance | +1-3ms (Redis) | N/A | Retargeting, known audiences |
| Contextual targeting | Medium relevance | None (in-bid-request) | N/A | Cookieless environments |
| Hybrid (contextual + segment) | Highest | +1-3ms | N/A | Default for most advertisers |
Per-request Redis budget checks are only justified for very small daily budgets (under $500) where a 5-second overspend window would be material. For anything larger, local counters with periodic sync are the correct default.
Production Considerations
Cold start on campaign index refresh: If the database query that refreshes the campaign index takes longer than 60 seconds, bid nodes are left serving with a stale index. Add a freshness timestamp to the index and degrade gracefully: if the index is older than 120 seconds, reduce bid prices by a safety factor rather than halting bids entirely.
Redis sentinel vs Redis Cluster: For user profiles, you want Redis Cluster for horizontal scaling. For budget counters, you want the counters for a given campaign to land on the same shard. Use hash tags in the key ({campaign:123}:spend) to ensure co-location.
Bid request validation: SSPs send malformed requests. Validate the incoming bid request against a schema before any processing. A corrupted impressionId written to your win log will corrupt your billing reconciliation.
Auction timeout discipline: Your response must arrive before the SSP’s deadline. Set your internal processing timeout to 75ms and let the remaining 25ms absorb network latency. A partial response (bid with no creative) is worse than no response; always send a complete bid or nothing.
Currency and CPM normalization: Store all bids and budgets in integer cents to avoid floating-point rounding errors. An advertiser bidding $2.50 CPM is 250 cents per thousand impressions, or 0.025 cents per impression. Integer arithmetic eliminates the class of bugs that come from multiplying floats at high volume.
Win rate feedback loop: Track win rates per campaign per publisher. If a campaign is winning fewer than 10% of the auctions it enters, the bid is too low or the targeting is too narrow. Surface this signal to campaign managers rather than letting them discover it by looking at delivery curves.
Architecture Layer Map
The system has five load-bearing layers, and each one has a different failure mode:
- Bid node fleet (stateless, horizontally scaled): fails by exceeding latency budgets under load
- Campaign index (in-memory, refreshed from DB): fails by going stale during a DB outage
- User profile cache (Redis, regional replicas): fails by returning stale or missing profiles on node restart
- Budget tracker (local counters + Redis sync): fails by over- or under-spending during sync interval or network partition
- Win notification pipeline (Kafka + async workers): fails by lagging on high-volume days, causing frequency cap and spend state to be delayed
Each layer’s failure mode is independent. A Redis profile cache outage does not have to take down the bid fleet; it just means bids are less well targeted. Build the failure modes into your monitoring from the start. Alert on profile cache miss rate, budget sync lag, and campaign index age as first-class signals, not afterthoughts.
Real-time ad serving is not algorithmically complex. The complexity is operational: getting all five layers to operate correctly simultaneously, at speed, under the kind of traffic that spikes unpredictably. The auction mechanics and targeting logic are a few hundred lines of code. The hard part is keeping them all fast, consistent, and observable at scale.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.