engineering · career · 5 min read

Building an ad bidding system for a system design interview

I bombed a system design interview a few weeks ago. Not “could have done better” — bombed. By the last ten minutes I was embarrassed, my thinking had gone flat, and I wanted the call to end more than I wanted to solve the problem.

The prompt was an ad bidder. Two components were already drawn on the board when I got there, which is the part that threw me: an SDK installed on a customer’s site that collects events — page view, product viewed, add to cart, order complete — and a bidder service that talks to AdSense. My job was the middle.

The constraints were on the board too. 500k requests per second at the bidder. An SLO under 500ms.

What I actually did

I saw 500k RPS and started designing for 500k RPS.

I reached for a database almost immediately. Then I reached for a stream processor — I said “Flink” out loud, by name, because I had just learned about Flink and it was sitting near the front of my head. That’s the tell. I wasn’t picking a tool because the problem demanded it. I was picking a tool because I knew it.

What I never did:

I never asked for the non-functional requirements up front. I had two numbers off a whiteboard and treated them as the whole spec. I didn’t ask about read/write ratio, freshness, consistency, or what happens on failure.

I never asked what the bidder actually needs. This is the one that cost me the interview. The bidder talks to AdSense. AdSense wants an ad. What is the minimum payload that satisfies that? I never asked. So I designed a system that could compute anything, because I didn’t know what it had to compute.

The interview cadence didn’t help — quick back-and-forth, not much room to think out loud — but that’s an excuse. The failure was that I designed forward from the components instead of backward from the requirement.

Building it afterward

I was annoyed enough that I went and built it. And I landed a lot closer than I felt in the room.

The thing that unlocked it was the question I never asked: what does the bidder need to return?

An image, a link, and a product ID. That’s it. That’s the whole payload.

Once you know that, the hot path collapses. The bidder does exactly one thing per request: a single lookup by user ID. Cache hit, return the ad. Cache miss, reject the bid. There is no join, no scoring, no database, no computation of any kind on the request path.

That’s what makes 500k RPS under 500ms defensible — not a clever bidder, but a bidder that has almost nothing to do. Every expensive decision has already happened, asynchronously, before the request arrived.

The split is write path and read path. The write path can be as slow as it needs to be. The read path is a single Redis GET.

500k RPS · under 500ms

  1. Bidder receives request
  2. GET user:{userId}~1ms
  3. Hit → return ad · miss → reject bid

One lookup. No join, no scoring, no database, no computation of any kind. That is what makes the number defensible — not a clever bidder, but a bidder that has almost nothing to do.

The design

SDK → ingestion service → Kafka → ranking service → Redis cluster → bidder service → AdSense.

write path · asynchronous

Terminates the SDK’s connection. Validates the event, authenticates the customer, drops anything malformed, and publishes to Kafka. It owns the messy public edge so nothing downstream has to.

{ userId, type, productId, productLink, productImageUrl }

The SDK emits an event with the same shape all the way through: product ID, product link, product image URL. That payload never grows and never gets transformed.

The ingestion service is the only thing the SDK talks to. It terminates the connection, authenticates the customer, validates the event, drops anything malformed, and publishes to Kafka. It exists so that the public edge — untrusted input arriving from every customer site running the SDK — has exactly one owner, and so nothing downstream ever has to reason about a half-formed event. Kafka gets clean messages or it gets nothing.

The ranking service consumes from Kafka and writes into Redis keyed by user ID. It also assigns a priority score, and the scoring is about as simple as scoring gets:

  • page view: 1
  • product viewed: 2
  • add to cart: 3

Highest score wins. A higher-scoring event overwrites whatever is in the cache for that user. An order complete event deletes the key — they bought it, stop showing it to them.

Fire an event for user 8812

Redis

user:8812 → (empty) · bidder would reject

TTL is a couple of days to a few days. Interest goes stale, and a week-old page view isn’t worth bidding on.

That’s the system. One event shape, one score, one key, one lookup.

What I’d take into the next one

Ask for the non-functional requirements before drawing anything. Not just throughput and latency. Read/write ratio, freshness tolerance, failure behavior. The numbers on the board are the start of the conversation, not the spec.

Ask what the consumer at the end of the pipe needs. Then work backwards. Every component upstream exists to serve that payload, and if you know the payload, you know how much machinery is actually justified. In this case: very little.

Don’t name a tool you can’t defend from first principles. “Flink” wasn’t an answer, it was a reflex. If I’d been asked why Flink and not a consumer group with a Redis write, I had nothing.

Simplicity is the answer more often than it feels like it should be. 500k RPS sounds like it demands sophistication. It demanded a cache lookup.

The part I’m still sitting with is the freezing. I don’t think the fix for that is more system design practice, exactly — it’s the same fix as the design itself. When I panicked, it was because I was trying to hold the whole system in my head at once. Asking one clarifying question would have collapsed the problem to something I could actually reason about. I had the tool the entire time and didn’t use it.

← all posts