Learning Track — Multi-level

Header Bidding

From “what is a waterfall?” to writing your own Prebid bid adapter and hardening it for production — five progressive levels in one guide.

Multi-level track

Five levels — student to integration engineer

This page is deliberately layered. Start at Level 0 if you have never heard of header bidding. Jump to Level 3 or 4 if you are already running Prebid in production and need adapter internals or operational depth. Every level builds on the one before it.

Level 0 — 1 (Student / Beginner)

  • What header bidding is and the waterfall problem it replaced.
  • Core vocabulary: wrapper, adapter, bid, floor, key-value.
  • How the auction flow connects SSP, DSP, and the ad server.

Level 2 — 3 (Practitioner / TAM)

  • Full Prebid.js setup with addAdUnits, setConfig, and GAM integration.
  • Prebid Server (PBS) for CTV and mobile — stored requests, auction API.
  • Price granularity strategy, user sync, timeout tuning.

Level 4 — 5 (Integration Engineer)

  • Writing a Prebid.js bid adapter from scratch — full working code.
  • Production hardening: monitoring, supply chain, privacy compliance.
  • Adapter testing, PBS Go adapter concepts, performance optimisation.

The waterfall problem header bidding solved

Before header bidding existed, publishers sold their remaining inventory through a “waterfall” — a sequential chain of ad networks each given a chance to fill the slot, one at a time. The problem: the first network in the chain always got the best crack at the inventory, even if a network further down would have paid more. Revenue was left on the table every single day.

✗ Old model: the waterfall

  • Ad server calls Network A first. Network A bids $1.00 — accepted.
  • Network B never gets a chance, even though it would have paid $3.50.
  • Publisher earns $1.00 when it could have earned $3.50.
  • Each network is evaluated sequentially, adding latency per hop.
  • Networks at the top of the waterfall always win regardless of price.

✓ Header bidding

  • All networks (A, B, C, D) receive the ad opportunity simultaneously.
  • Each returns a bid independently within a time window (e.g. 1500 ms).
  • The highest bid wins and is passed to the ad server.
  • Publisher earns the best market price, not just the first acceptable one.
  • Increased competition drives up CPMs across all inventory.
Analogy Waterfall vs header bidding in plain English
WATERFALL (old way):
  "I have one ticket to sell. I'll ask my friend first.
   He offers $5. Sold. My other friend would have paid $20 — too bad."

HEADER BIDDING (new way):
  "I have one ticket to sell. I'll text all 10 friends at once
   and give them 90 seconds to reply with their best offer.
   The highest reply wins. I get $20 instead of $5."
The name “header bidding” comes from the original implementation: JavaScript was placed in the HTML <head> tag so it ran before the page body loaded, giving buyers time to bid before the ad server was called. The name stuck even as implementations evolved.

How header bidding actually works

Before writing any code, you need to understand the 8 moving parts and how they connect. Most explanations skip this and jump straight to configuration — which means practitioners configure things they cannot explain when something breaks.

TermWhat it isAnalogy
Header bidding wrapper The JavaScript library (e.g. Prebid.js) that orchestrates the auction on the page The auctioneer who calls all bidders simultaneously and collects offers
Bid adapter A connector module inside the wrapper that speaks each SSP’s specific API language A translator who converts the auctioneer’s request into each buyer’s native language
SSP (Supply-Side Platform) The seller’s platform — AppNexus, Rubicon, Index Exchange, Magnite The marketplace where buyers compete for the publisher’s inventory
Bid A CPM price (in USD) returned by an SSP for a specific impression The offer price a buyer submits at auction
Bid floor The minimum CPM the publisher will accept — bids below this are rejected Reserve price in an auction — any offer below this is declined
Key-value targeting Data (e.g. hb_pb=3.50) the wrapper passes to the ad server so the right line item wins A secret code the auctioneer passes to the venue so they know which seat the winner gets
Ad server (GAM) Google Ad Manager — the final decision-maker that selects which ad serves The venue manager who decides whose reservation to honour based on who paid what
Bidder timeout The maximum ms the wrapper waits for SSP bids before closing the auction The auction closing time — bidders who reply late are excluded
Price granularity How precisely bid prices are bucketed before being sent to GAM as key-values Rounding: a $3.47 bid becomes “$3.40” or “$3.50” depending on bucket size
Win notice (nurl) A URL the ad server pings back to the DSP to confirm the bid won and the clearing price The auctioneer calling the winner to confirm their offer was accepted

End-to-end auction flow — 10 steps

1
User opens a page

The browser loads HTML. The <script> tag for Prebid.js is in the page head or loaded async. GPT (Google Publisher Tag) also loads.

2
Wrapper initialises

pbjs.que.push() defers setup until Prebid.js is ready. addAdUnits() registers which slots run header bidding and which adapters compete.

3
requestBids fires — timeout clock starts

Prebid fans out to every adapter simultaneously. Each adapter converts the ad unit config into its SSP’s API format and fires an HTTP request.

4
SSPs run their own auctions

Each SSP receives the request, runs its internal DSP auction (OpenRTB), and returns the highest bid it found — or no bid.

5
Bids return to the wrapper

Adapters parse each SSP response and hand the CPM, creative, and metadata back to the Prebid auction manager.

6
Timeout fires or all bids collected

whichever comes first. Late bids are dropped. Prebid selects the highest CPM bid per ad unit.

7
Key-values set on GPT

pbjs.setTargetingForGPTAsync() writes hb_pb, hb_bidder, hb_adid onto each GPT slot. These are the signals GAM uses to route to the right line item.

8
GAM ad request fires

googletag.pubads().refresh() sends the ad request to GAM with the Prebid key-values attached. GAM now runs its own priority auction.

9
GAM selects the winner

The Price Priority line item targeting hb_pb=3.50 competes with direct campaigns, Open Bidding, and house ads. Highest priority and best CPM wins.

10
Creative renders

If the header bidding line item wins, GAM returns a Prebid creative snippet. The snippet calls pbjs.renderAd(), which serves the winning DSP’s ad markup into the slot.

Complete Prebid.js + GAM setup

This is the standard integration used by most web publishers. It assumes you have a GAM account and a page that already loads GPT.

Step 1 — Define ad units and adapters

JavaScript pbjs.addAdUnits() — multi-format, multi-bidder
var adUnits = [
  {
    code: 'div-gpt-ad-leaderboard',       // must match GPT slot div ID exactly
    mediaTypes: {
      banner: { sizes: [[728,90], [970,90], [970,250]] }
    },
    bids: [
      { bidder: 'appnexus', params: { placementId: 12345678 } },
      { bidder: 'rubicon',  params: { accountId: 9001, siteId: 283, zoneId: 1234 } },
      { bidder: 'ix',       params: { siteId: '47777', size: [728,90] } },
      { bidder: 'pubmatic', params: { publisherId: '158355', adSlot: 'hb-leaderboard@728x90' } },
      { bidder: 'openx',   params: { unit: '540191387', delDomain: 'pub.openx.net' } }
    ]
  },
  {
    code: 'div-gpt-ad-mrec',
    mediaTypes: {
      banner: { sizes: [[300,250], [300,600]] }
    },
    bids: [
      { bidder: 'appnexus', params: { placementId: 87654321 } },
      { bidder: 'rubicon',  params: { accountId: 9001, siteId: 283, zoneId: 5678 } }
    ]
  },
  {
    code: 'div-gpt-ad-video',
    mediaTypes: {
      video: {
        context: 'instream',
        playerSize: [[640, 480]],
        mimes: ['video/mp4'],
        protocols: [2, 3, 5, 6, 7, 8],
        playbackmethod: [2],
        skip: 1,
        skipafter: 5
      }
    },
    bids: [
      { bidder: 'appnexus', params: { placementId: 11223344, video: { skippable: true } } }
    ]
  }
];

Step 2 — Global config

JavaScript pbjs.setConfig() — production-ready
pbjs.setConfig({
  bidderTimeout: 1500,
  enableSendAllBids: false,                    // one winner per slot — cleaner GAM targeting

  priceGranularity: {
    buckets: [
      { max: 3,   increment: 0.05 },    // fine buckets where most bids land
      { max: 8,   increment: 0.10 },
      { max: 20,  increment: 0.25 },
      { max: 100, increment: 1.00 }
    ]
  },

  userSync: {
    syncEnabled: true,
    filterSettings: {
      iframe: { bidders: ['appnexus', 'rubicon'], filter: 'include' },
      image:  { bidders: '*',                             filter: 'include' }
    },
    syncsPerBidder: 5,
    syncDelay: 3000
  },

  currency: { adServerCurrency: 'USD' },

  schain: {
    validation: 'strict',
    config: {
      ver: '1.0', complete: 1,
      nodes: [{ asi: 'your-exchange.com', sid: 'pub-001', hp: 1 }]
    }
  },

  consentManagement: {                               // GDPR / TCF integration
    gdpr: {
      cmpApi: 'iab',                              // reads from window.__tcfapi
      timeout: 10000,                            // ms to wait for CMP before continuing
      allowAuctionWithoutConsent: false          // do NOT run auction if no consent signal
    },
    usp: { cmpApi: 'iab', timeout: 1000 }   // CCPA
  }
});

Step 3 — requestBids and GAM handoff

JavaScript Full page integration — GPT + Prebid
var googletag = googletag || {};
googletag.cmd = googletag.cmd || [];
var pbjs = pbjs || {};
pbjs.que = pbjs.que || [];

// Define GPT slots — these are the ad unit paths in your GAM network
googletag.cmd.push(function() {
  googletag.defineSlot('/12345/homepage-leaderboard', [[728,90],[970,90]], 'div-gpt-ad-leaderboard')
    .addService(googletag.pubads());
  googletag.defineSlot('/12345/homepage-mrec', [[300,250],[300,600]], 'div-gpt-ad-mrec')
    .addService(googletag.pubads());

  googletag.pubads().disableInitialLoad();   // prevent GPT from loading ads before Prebid runs
  googletag.pubads().enableSingleRequest();
  googletag.enableServices();
});

// Prebid configuration and auction
pbjs.que.push(function() {
  pbjs.setConfig({ /* ...setConfig as above... */ });
  pbjs.addAdUnits(adUnits);

  pbjs.requestBids({
    timeout: 1500,
    bidsBackHandler: function() {
      pbjs.setTargetingForGPTAsync();        // write hb_pb, hb_bidder, hb_adid to GPT slots
      googletag.cmd.push(function() {
        googletag.pubads().refresh();        // fire GAM ad request with key-values attached
      });
    }
  });
});

// === Useful console commands for QA ===
// pbjs.getBidResponses()          — all bids per ad unit (bid, no-bid, timeout)
// pbjs.getHighestCpmBids()        — winning bid per ad unit
// pbjs.getAdserverTargeting()     — key-values written to each GPT slot
// pbjs.getAllWinningBids()        — bids that won AND rendered

Step 4 — GAM line item configuration

FieldValueWhy
Line item typePrice PriorityCompetes by CPM against Open Auction — the only type that lets header bidding win on price
Rate (CPM)$3.50 (one line item per bucket)Must match the exact hb_pb key-value bucket your granularity config produces
Key-value targetinghb_pb equals 3.50This is how GAM knows to serve this line item when Prebid sets that key-value on the ad request
Creative typeThird-party — JavaScript snippetPrebid standard creative calls pbjs.renderAd() to serve the actual DSP creative
Creative snippet<script>pbjs.renderAd(document,'%%PATTERN:hb_adid%%')</script>%%PATTERN:hb_adid%% macro is replaced by GAM with the actual bid ID at serve time
SizesAll sizes you want to supportOne creative per size or universal creative — wrong size causes blank slot
Priority12Competes by price at the same level as Open Auction and Open Bidding demand
End dateFar future (e.g. Dec 31, 2099)Header bidding line items are evergreen — they should never expire
You need one line item per price bucket. If your granularity produces 200 buckets ($0.05 increments to $10), you need 200 GAM line items. Use the Prebid Line Item Manager tool or a custom script to generate them in bulk — doing it manually is error-prone and takes hours.

Prebid Server (PBS) — when and why

Prebid.js runs in the browser. Prebid Server (PBS) moves the auction to a server. This is essential for CTV apps and mobile apps where there is no browser JavaScript runtime, and also beneficial for web when you want to reduce page-level auction latency by offloading adapter calls to the server.

Use Prebid.js (client-side) when:

  • You are building for web browsers
  • You need maximum adapter coverage (350+ adapters)
  • Browser cookie sync is needed for high match rates
  • You want the simplest integration path

Use Prebid Server when:

  • Building CTV / OTT apps (no browser runtime)
  • Building mobile apps (Android, iOS, Roku SDK)
  • You want to reduce page weight and auction latency
  • You need server-side identity resolution via eids

PBS auction API — direct OpenRTB call

A CTV app calls PBS at /openrtb2/auction with a standard OpenRTB 2.6 payload. PBS fans out to all configured SSP adapters on the server and returns a single aggregated response.

JSON PBS /openrtb2/auction request from a CTV app
POST https://prebid-server.example.com/openrtb2/auction
Content-Type: application/json

{
  "id": "auction-ctv-001",
  "tmax": 1000,
  "imp": [
    {
      "id": "1",
      "video": {
        "mimes": ["video/mp4"],
        "protocols": [2, 3, 5, 6],
        "w": 1920, "h": 1080,
        "startdelay": 0,                       // 0 = pre-roll
        "playbackmethod": [2],
        "skip": 1, "skipafter": 5
      },
      "bidfloor": 5.00,
      "bidfloorcur": "USD",
      "ext": {
        "prebid": {
          "bidder": {                               // per-bidder params — replaces Prebid.js adapter config
            "appnexus": { "placementId": 11223344 },
            "rubicon":   { "accountId": 9001, "siteId": 283, "zoneId": 9999 },
            "ix":        { "siteId": "47777" }
          },
          "storedAuctionResponse": "stored-resp-001"   // optional: use cached test response for dev
        }
      }
    }
  ],
  "app": {
    "bundle": "com.example.ctvapp",
    "name": "Example CTV",
    "storeurl": "https://channelstore.roku.com/details/123",
    "publisher": { "id": "pub-456" }
  },
  "device": {
    "ua": "Roku/DVP-9.10 (519.10E04142A)",
    "devicetype": 3,                             // 3 = Connected TV / SmartTV
    "ifa": "aebe0a86-1f3a-4c65-b2bc-4f7b9c4e9d01",  // device advertising ID (RIDA on Roku)
    "lmt": 0,                                    // Limit Ad Tracking: 0 = not limited
    "geo": { "country": "USA", "region": "CA" }
  },
  "user": {
    "ext": {
      "eids": [
        {
          "source": "liveramp.com",
          "uids": [{ "id": "RampID-abc123", "atype": 3 }]
        }
      ]
    }
  },
  "regs": { "coppa": 0, "ext": { "gdpr": 0, "us_privacy": "1YNN" } }
}

Stored requests — simplifying app integrations

Instead of sending the full bidder config in every request, PBS supports stored requests: pre-configured templates stored in the PBS database. The app sends only an ID; PBS injects the full bidder params server-side.

JSON App request using stored request ID (simplified)
{
  "id": "auction-ctv-002",
  "imp": [
    {
      "id": "1",
      "ext": {
        "prebid": {
          "storedrequest": { "id": "ctv-preroll-720" }
          // PBS looks up "ctv-preroll-720" from its database and merges
          // the stored bidder config, floor, and media type definition
        }
      }
    }
  ],
  "app": { "bundle": "com.example.ctvapp" },
  "device": { "devicetype": 3, "ifa": "aebe0a86-..." }
  // Bidder params, video sizes, floors etc. come from the stored request template
}

Prebid.js adapter anatomy

A Prebid.js adapter is a JavaScript module that translates between Prebid’s internal auction format and your SSP’s API. It must implement five functions. The adapter lives in modules/mybidderBidAdapter.js in the Prebid.js repo. Every adapter must pass Prebid’s automated test suite before it can be merged.

FunctionCalled by Prebid when…Must return
isBidRequestValid(bid)Checking each ad unit before the auctionBoolean — true if params are valid, false skips this adapter for this unit
buildRequests(validBids, bidderRequest)Building the HTTP request to your SSPObject {method, url, data} or array of such objects
interpretResponse(response, request)SSP responds with bidsArray of bid objects in Prebid’s internal format
getUserSyncs(options, responses, gdpr, usp)After auction — cookie/user syncArray of {type: 'iframe'|'image', url} sync descriptors
onBidWon(bid)A bid from this adapter wins the GAM auctionNothing — fire win notification side-effect here
JavaScript modules/mybidderBidAdapter.js — complete working adapter
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { BANNER, VIDEO } from '../src/mediaTypes.js';
import { deepAccess, logWarn } from '../src/utils.js';

const BIDDER_CODE    = 'mybidder';
const ENDPOINT_URL   = 'https://bid.mybidder.example.com/openrtb2/auction';
const SYNC_URL       = 'https://sync.mybidder.example.com/usersync';
const SUPPORTED_MEDIA = [BANNER, VIDEO];

export const spec = {
  code: BIDDER_CODE,
  supportedMediaTypes: SUPPORTED_MEDIA,

  // ── 1. Validate params before the auction ──────────────────────────────
  isBidRequestValid: function(bid) {
    if (!bid.params || !bid.params.placementId) {
      logWarn(`[mybidder] missing required param: placementId on bid `, bid);
      return false;
    }
    return true;
  },

  // ── 2. Build the HTTP request to send to your SSP ─────────────────────
  buildRequests: function(validBidRequests, bidderRequest) {
    const gdpr    = bidderRequest.gdprConsent;
    const usp     = bidderRequest.uspConsent;
    const referer = deepAccess(bidderRequest, 'refererInfo.page') || '';

    const openRtbRequest = {
      id: bidderRequest.auctionId,
      tmax: bidderRequest.timeout,
      imp: validBidRequests.map(bid => {
        const imp = {
          id: bid.bidId,                            // Prebid-generated bid ID — must echo back in response
          bidfloor: bid.params.floor || 0,
          bidfloorcur: 'USD',
          ext: { placementId: bid.params.placementId }
        };

        const bannerSizes = deepAccess(bid, 'mediaTypes.banner.sizes');
        if (bannerSizes) {
          imp.banner = { format: bannerSizes.map(s => ({ w: s[0], h: s[1] })) };
        }

        const videoParams = deepAccess(bid, 'mediaTypes.video');
        if (videoParams) {
          imp.video = {
            mimes:          videoParams.mimes    || ['video/mp4'],
            protocols:      videoParams.protocols || [2, 3, 5, 6],
            w:              videoParams.playerSize?.[0]?.[0],
            h:              videoParams.playerSize?.[0]?.[1],
            playbackmethod: videoParams.playbackmethod,
            skip:           videoParams.skip       || 0,
            skipafter:      videoParams.skipafter   || 5
          };
        }

        return imp;
      }),

      site: {
        page: referer,
        domain: new URL(referer || 'https://unknown.com').hostname,
        publisher: { id: validBidRequests[0]?.params?.publisherId || '' }
      },

      // Privacy signals — always include these
      regs: {
        coppa: 0,
        ext: {
          gdpr: gdpr?.gdprApplies ? 1 : 0,
          us_privacy: usp || ''
        }
      },
      user: {
        ext: {
          consent: gdpr?.consentString || '',
          eids: validBidRequests[0]?.userIdAsEids || []   // Unified IDs from Prebid ID modules
        }
      }
    };

    return {
      method: 'POST',
      url: ENDPOINT_URL,
      data: JSON.stringify(openRtbRequest),
      options: { contentType: 'application/json' }
    };
  },

  // ── 3. Parse the SSP response into Prebid bid objects ─────────────────
  interpretResponse: function(serverResponse, request) {
    const body = serverResponse?.body;
    if (!body?.seatbid?.length) return [];

    const bids = [];
    body.seatbid.forEach(seatbid => {
      (seatbid.bid || []).forEach(bid => {
        const pbBid = {
          requestId:  bid.impid,            // must match imp[].id from buildRequests
          cpm:        bid.price,
          currency:   body.cur || 'USD',
          width:      bid.w,
          height:     bid.h,
          creativeId: bid.crid || bid.id,
          ttl:        300,                 // seconds this bid is valid — player must render within TTL
          netRevenue: true,               // true = price is net of SSP fee
          meta: {
            advertiserDomains: bid.adomain || [],
            mediaType: bid.ext?.prebid?.type || BANNER
          }
        };

        // Banner creative
        if (bid.adm && !bid.ext?.prebid?.type?.includes('video')) {
          pbBid.ad = bid.adm;
        }

        // Video creative (VAST XML or URL)
        if (bid.ext?.prebid?.type === 'video') {
          pbBid.vastXml = bid.adm;
          pbBid.vastUrl = bid.ext?.prebid?.targeting?.hb_uuid
            ? `https://prebid-cache.example.com/cache?uuid=` + bid.ext.prebid.targeting.hb_uuid
            : undefined;
          pbBid.mediaType = VIDEO;
        }

        bids.push(pbBid);
      });
    });
    return bids;
  },

  // ── 4. User sync — after auction, fire sync pixels ────────────────────
  getUserSyncs: function(syncOptions, serverResponses, gdprConsent, uspConsent) {
    if (!serverResponses?.length) return [];

    const gdprParam    = gdprConsent?.gdprApplies ? 1 : 0;
    const consentParam = encodeURIComponent(gdprConsent?.consentString || '');
    const uspParam     = encodeURIComponent(uspConsent || '');

    if (syncOptions.iframeEnabled) {
      return [{
        type: 'iframe',
        url:  `${SYNC_URL}?gdpr=${gdprParam}&consent=${consentParam}&us_privacy=${uspParam}&type=iframe`
      }];
    }

    if (syncOptions.pixelEnabled) {
      return [{
        type: 'image',
        url:  `${SYNC_URL}?gdpr=${gdprParam}&consent=${consentParam}&type=pixel`
      }];
    }

    return [];
  },

  // ── 5. Win notification — fires when this adapter's bid renders ────────
  onBidWon: function(bid) {
    if (bid.nurl) {
      // Replace auction price macro with clearing price
      const url = bid.nurl.replace('${AUCTION_PRICE}', bid.cpm);
      navigator.sendBeacon(url);
    }
  }
};

registerBidder(spec);

Testing the adapter

JavaScript test/spec/modules/mybidderBidAdapter_spec.js — unit test skeleton
import { spec } from 'modules/mybidderBidAdapter.js';
import { newBidder } from 'src/adapters/bidderFactory.js';

const ENDPOINT = 'https://bid.mybidder.example.com/openrtb2/auction';

describe('mybidderBidAdapter', function() {
  const adapter = newBidder(spec);

  describe('isBidRequestValid', function() {
    it('returns true for a bid with placementId', function() {
      const bid = { params: { placementId: 12345 } };
      expect(spec.isBidRequestValid(bid)).to.equal(true);
    });
    it('returns false when placementId is missing', function() {
      const bid = { params: {} };
      expect(spec.isBidRequestValid(bid)).to.equal(false);
    });
  });

  describe('buildRequests', function() {
    const validBidRequests = [{
      bidId: 'bid-abc-001',
      params: { placementId: 12345 },
      mediaTypes: { banner: { sizes: [[300,250]] } },
      userIdAsEids: []
    }];
    const bidderRequest = {
      auctionId: 'auct-001',
      timeout: 1500,
      refererInfo: { page: 'https://example.com/' },
      gdprConsent: { gdprApplies: true, consentString: 'CPXx...' }
    };

    it('sends POST to the correct endpoint', function() {
      const request = spec.buildRequests(validBidRequests, bidderRequest);
      expect(request.method).to.equal('POST');
      expect(request.url).to.equal(ENDPOINT);
    });

    it('includes GDPR consent in the request body', function() {
      const request = spec.buildRequests(validBidRequests, bidderRequest);
      const body = JSON.parse(request.data);
      expect(body.regs.ext.gdpr).to.equal(1);
      expect(body.user.ext.consent).to.equal('CPXx...');
    });
  });

  describe('interpretResponse', function() {
    it('returns an empty array for empty seatbid', function() {
      expect(spec.interpretResponse({ body: { seatbid: [] } }, {})).to.deep.equal([]);
    });

    it('correctly maps a banner bid response', function() {
      const resp = {
        body: {
          cur: 'USD',
          seatbid: [{ bid: [{ impid: 'bid-abc-001', price: 2.5, w: 300, h: 250, adm: '<div>ad</div>', crid: 'cr-1' }] }]
        }
      };
      const bids = spec.interpretResponse(resp, {});
      expect(bids[0].requestId).to.equal('bid-abc-001');
      expect(bids[0].cpm).to.equal(2.5);
    });
  });
});
Submitting to the Prebid repo: every adapter PR must include a passing unit test file, a param validation table in the adapter README, GDPR/USP consent propagation, and meta.advertiserDomains in the bid response. Run gulp test --file test/spec/modules/mybidderBidAdapter_spec.js before opening the PR.

Operating header bidding in production

Getting Prebid running is Level 2. Keeping it healthy at scale — catching revenue regressions before your yield team does, staying compliant with privacy law, and passing supply chain audits — is what separates practitioners from engineers.

Key metrics to monitor continuously

MetricHealthy rangeWhat degrades itAlert threshold
Bid rate per adapter 40%–80% of requests receive a bid Floor too high, wrong params, geo targeting by DSP Drop >15% from 7-day baseline
Timeout rate per adapter <5% DSP latency spike, bidderTimeout too low, network issues >10% — escalate to adapter team
Win rate (header bidding vs GAM) Depends on direct sell-through — track trend not absolute Direct campaigns taking more inventory, floor changes 20%+ week-on-week drop without a known direct campaign change
Revenue per 1000 auctions (RPM) Site-specific baseline Adapter going dark, price granularity regression, floor miscalibration 5%+ drop vs same-day last week
User sync rate >60% of users have at least one sync CMP blocking iframe syncs, syncDelay too long, filterSettings too restrictive <40% — diagnose CMP or filterSettings config

Supply chain compliance — ads.txt, sellers.json, schain

Text ads.txt — place at https://yourdomain.com/ads.txt
# Format: SSP domain, publisher account ID, relationship type, TAG-ID
appnexus.com, 9001, DIRECT, f08c47fec0942fa0     # DIRECT = you have a direct deal with this SSP
rubiconproject.com, 158355, DIRECT, 0bfd66d529a55807
openx.com, 540191387, DIRECT, 6a698e965929d969
indexexchange.com, 185001, DIRECT
pubmatic.com, 158355, DIRECT, 5d62403b186f2ace
triplelift.com, 1001, RESELLER, 6c33edb13117fd86 # RESELLER = SSP sells your inventory on behalf of another
# Rule: every SSP in your Prebid adapter config must appear here
# Missing entries = DSPs with brand safety filters will not buy your inventory

Performance optimisation strategies

Lazy-load below-the-fold slots

Do not run header bidding for slots the user has not scrolled to yet. Use an Intersection Observer to trigger requestBids only when the slot enters the viewport.

▸ Reduces wasted auctions. Improves timeout budget for visible slots. Decreases page weight on initial load by deferring adapter JS execution.

Adaptive timeout per device

CTV devices and older mobile hardware are slower. A 1500 ms timeout on desktop may need to be 2500 ms on a 2018 Roku device to capture the same percentage of bids.

▸ Detect device type from user agent or app metadata. Set bidderTimeout dynamically. Monitor timeout rate per device segment in your analytics.

Prebid Server to reduce page weight

Loading 10 adapter JS files on the page adds 100–400 KB. Route slow or less-critical adapters through Prebid Server via the s2sConfig option, keeping only fast adapters client-side.

pbjs.setConfig({ s2sConfig: { accountId: '...', bidders: ['rubicon','openx'], endpoint: '...' } }) — adapters listed here run server-side, not in the browser.

A/B test price granularity

Run two versions of your price granularity config on 50/50 split of users. Measure average clearing price and fill rate per variant over 7 days. Fine-grained buckets improve revenue but add GAM key-value complexity.

▸ Use a feature flag or A/B test framework to toggle between configs. Control group: medium (0.10 increment). Test group: custom fine-grained. Compare via GAM delivery report.

Floor price calibration

Static floors leave money on the table when demand is high and reduce fill when demand is low. Dynamic floor providers (PriceFloors module in Prebid) set floors per-impression based on historical CPM data.

▸ Enable floors module in Prebid. Configure a floor data URL returning JSON with floor rules per ad unit, geo, and device type. Monitor fill rate vs CPM tradeoff weekly.

GDPR / CCPA — keeping demand live

Under GDPR, if your CMP does not grant Purpose 1 (storage and access) in the TCF consent string, most adapters will not send a user ID — dramatically lowering CPMs. CMP configuration is a yield management decision, not just a legal one.

▸ Test your consent string with the IAB TCF decoder. Confirm Purpose 1, 3, and 4 are granted for typical users in your consented population. Track CPM correlation with consent rate across markets.

Debugging toolkit

ToolWhat it showsHow to use
pbjs.getBidResponses() All bids, no-bids, timeouts per ad unit Browser console on any Prebid page — first diagnostic step
pbjs.getHighestCpmBids() Winning bid per slot with CPM and adapter name Confirm which adapter is winning and at what price
pbjs.getAdserverTargeting() Key-values written to each GPT slot Compare hb_pb value against GAM line item targeting
pbjs.setConfig({debug: true}) Full verbose auction log in browser console Enable temporarily to see per-adapter rejection reasons
Prebid Analyst (Chrome extension) Visual auction dashboard — bids, timeouts, key-values Install from Chrome Web Store — no code needed
GPT console (googletag.openConsole()) GAM line item evaluation, targeting matches, creative selected Run in browser console — Targeting tab shows all key-values GAM received
Browser Network tab (HAR export) Actual HTTP requests to each SSP endpoint and response times Filter by adapter endpoint domain — diagnose latency per SSP
PBS /openrtb2/auction?debug=true Full debug response from Prebid Server including per-adapter logs Add ?debug=true to PBS requests in dev — never in production

Common header bidding failure patterns

Across all levels — the failures that surface most often in production, with diagnostic steps.

No bids from all adapters

Every adapter returns empty. The auction fires but GAM receives no Prebid key-values. Likely: Prebid.js failed to load, addAdUnits was not called, or a JS error broke the entire auction queue.

▸ Check console for JS errors. Confirm pbjs.adUnits is populated. Enable debug mode. Verify the Prebid.js script tag is loading — use Network tab to confirm 200 response.

Bids return but GAM ignores them

Key-values are set on GPT slots but the Price Priority line item never wins. Usually: hb_pb bucket value in the key-value does not match the CPM in the GAM line item targeting.

▸ Run googletag.openConsole() → Targeting. Compare the exact hb_pb string (e.g. “3.50” not “3.5”) against GAM line item key-value rule. Decimal format and leading zeros must match exactly.

One adapter dominates — others never win

One SSP consistently submits the highest bid, even suspiciously so. Other adapters are effectively excluded from revenue even though they have active demand. Could be a DSP bidding strategy or a floor set below that SSP’s minimum.

▸ Pull bid distribution per adapter from your analytics. If one adapter is winning 90%+ of auctions, check whether it is CPC or CPM-bidding, and whether it has a preferential floor. Run an experiment with that adapter removed to measure true competitive pressure.

CTV app getting no fill

PBS is being called but returning empty seatbid. Likely: device type is not set to 3 (SmartTV) or 4 (SetTopBox) in the request, so CTV-specific DSPs exclude the impression. Or: the app bundle is not in the publisher’s ads.txt equivalent (app-ads.txt).

▸ Confirm device.devicetype is 3 or 4 in the PBS request. Check that your app bundle (app.bundle) is listed in your app-ads.txt file hosted at your developer domain. Verify floors are not above CTV CPM expectations for your genre/daypart.

Revenue dropped overnight with no deployment

CPMs dropped 20%+ on a Tuesday with no code change. A connected SSP or DSP changed their adapter behaviour, pulled demand from certain geos, or a privacy regulation change went live.

▸ Compare bid rate and CPM per adapter before vs after the drop date. Isolate which adapter(s) changed. Check if the date coincides with a regulatory event (e.g. CPRA enforcement) or an SSP product announcement. Contact the adapter team with specific date-range data.

Prebid adapter merged but still getting 0 bids

The Prebid.js adapter passed review and was merged. A publisher integrates it. All other adapters bid fine but the new adapter returns 0 bids. The adapter was built with test/mock endpoint URLs that only worked in the PR test environment.

▸ Confirm the endpoint URL in the adapter points to production, not a dev/staging server. Check that the publisher’s placementId or account ID was properly provisioned on the SSP side. Use the HAR trace to confirm the HTTP request reaches the SSP and inspect the raw response body.

Practice drills — one per level

Level 0

Draw the header bidding flow

  • Without looking at this page, draw the end-to-end header bidding flow on paper.
  • Label each step: page load, requestBids, adapter call, SSP auction, bid return, key-value, GAM, creative render.
  • Mark where the bidder timeout boundary sits — which steps happen before and after it.
  • Explain the difference between hb_pb and hb_adid to a non-technical colleague.

Level 1

Inspect a live Prebid auction

  • Find any large publisher website running Prebid (many news sites do — check window.pbjs in the console).
  • Run pbjs.getBidResponses() and list every adapter: bid amount, status, latency.
  • Run pbjs.getAdserverTargeting() and note the hb_pb and hb_bidder values.
  • Output: live-auction-analysis.md — adapters found, bid distribution, fastest/slowest adapter.

Level 2

Build a working Prebid test page

  • Set up a local HTML page with Prebid.js, GPT, and at least 3 adapters (use AppNexus test placement IDs from Prebid.org).
  • Configure custom 3-tier price granularity. Confirm the hb_pb values match your bucket boundaries.
  • Log the full auction results and compare bid rates across adapters at different times of day.
  • Output: hb-test-page.html + auction-log.json.

Level 3

Send a PBS auction request via cURL

  • Use the public Prebid Server demo endpoint or run PBS locally via Docker.
  • Craft an OpenRTB 2.6 JSON payload for a 300x250 banner unit with at least 2 SSPs in the imp.ext.prebid.bidder object.
  • Inspect the raw auction response: which seatbids came back, what CPMs, what creative markup.
  • Output: pbs-request.json + pbs-response-annotated.json.

Level 4

Write and test a custom adapter

  • Fork the Prebid.js repo. Create modules/mybidderBidAdapter.js using the code above as a starting point.
  • Modify it to hit a local mock server (use json-server or similar to return a static OpenRTB response).
  • Write unit tests covering: valid/invalid params, GDPR consent propagation, empty seatbid handling.
  • Run gulp test --file test/spec/modules/mybidderBidAdapter_spec.js and get all tests passing.

Level 5

Revenue regression audit

  • Pull 30 days of header bidding data from GAM: impressions, revenue, win rate per adapter per day.
  • Identify the 3 largest week-over-week revenue drops. For each: determine if it was bid rate, CPM, or timeout-related.
  • Verify your ads.txt covers every active SSP. Run a supply chain audit against sellers.json for each SSP.
  • Output: hb-ops-report.md — revenue trend, adapter health, compliance status, and recommended actions.

References and further reading

  • Prebid.org — complete documentation, adapter list, setConfig reference
  • Prebid.js GitHub — adapter source code, test examples, contribution guide
  • Prebid Server GitHub (Go & Java) — PBS architecture and stored request format
  • IAB OpenRTB 2.6 spec — the wire format every adapter builds against
  • IAB ads.txt & app-ads.txt specification — supply chain compliance
  • IAB sellers.json specification — SSP-side supply transparency
  • IAB TCF v2.2 — GDPR consent string format for consentManagement config
  • Prebid Analyst Chrome extension — live auction inspector

AdTech Toolkit

Enter any two values
to calculate the third

More tools coming soon