The 9:16 auction: why vertical needed its own ad stack

A year of rebuilding the bid path for a frame nobody in display had designed for.

The Danumai ad-tech team reviewing a campaign dashboard in the Dhaka office

The first version of our ad stack was a fork of a desktop header-bidding wrapper. It worked, in the sense that it returned ads. It also cost us roughly a fifth of every session, because it had been designed for a page that sits still while a person decides what to read.

Vertical video does not sit still. The viewer's thumb is already moving when the ad request fires. Every millisecond you spend deciding is a millisecond of a video somebody is no longer watching, and unlike a banner, there is no below the fold to hide the cost in.

What display gets wrong about a 9:16 frame

Display auction logic makes three assumptions that all break in vertical:

  • The viewport is wide. Creative sizing, viewability thresholds and the entire IAB size table assume a rectangle wider than it is tall. In a 9:16 frame there is exactly one size that does not letterbox.
  • Latency is amortised. A page loads once and shows many ads. A feed shows one ad per decision, and the decision happens while the viewer is mid-swipe.
  • Context is a keyword list. Brand safety built on English keyword blocklists does not read Bangla, so it defaults to refusing anything it cannot parse.

The first two are engineering problems. The third turned out to be the expensive one, and we wrote about it separately in why we don't buy brand-safety lists.

Moving the auction to the edge

The old path made a round trip to a regional data centre in Singapore, ran the wrapper in the client, then waited on the slowest bidder. Median decision time was 148ms and the ninety-fifth percentile was past 400ms.

We moved the auction itself to an edge worker sitting in front of the CDN, gave every bidder a hard deadline rather than a soft timeout, and stopped waiting on stragglers entirely:

// The deadline is the contract. A bidder that misses it is not late — it is out.
const bids = await Promise.allSettled(
  bidders.map((bidder) => withDeadline(bidder.request(slot), DEADLINE_MS)),
)
return settle(bids.filter(isFulfilled))

Cutting a bidder off mid-flight sounds like it should cost revenue. It did, for about three weeks, until the bidders adjusted their own timeouts to match ours. After that, the fill rate came back and the latency stayed down.

What the numbers did

MetricBeforeAfter
Median ad decision148ms38ms
95th percentile412ms96ms
Fill rate71%96%
CPM vs. repurposed display1.0×2.1×

If an ad breaks the viewing experience it replaced, it does not ship — no matter what it would have bid.

The yield curve we chose not to climb

There is a version of this system that earns more. It waits another eighty milliseconds, collects two more bids, and lifts CPM by something like four percent. We measured it, and then we did not ship it, because the same eighty milliseconds cost us more completed views than the four percent was worth.

That is not altruism. Short-form retention compounds: a session that ends early does not just lose the current ad, it loses every ad after it. The yield curve that looks best on a single impression is rarely the one that looks best on a cohort.

What we would do differently

We spent four months making the client wrapper faster before admitting the wrapper was the problem. If you are starting from a display stack and your inventory is vertical, skip that. Move the decision to the edge on day one and let the client do nothing but render.

The rest — the context model, the measurement layer, the advertiser dashboard — is downstream of that one decision.