Google (GPT) passback tags in a placement
A Google Ad Manager passback tag serves once and then leaves the slot blank on every reload, Google returns no ad and the impression is lost, or one localized path never fills — why each happens and how to write the tag so they don't.
An HTML Code banner whose markup is a Google Publisher Tag hands your inventory to Google Ad Manager and shows whatever GAM returns. Three things go wrong with such tags specifically, and none of them is visible in Epom’s reports — the placement looks filled every time:
- the tag renders on the first impression and leaves a blank slot on every reload;
- Google returns no ad and the request is simply lost, because Epom already considers it filled;
- one localized path fills nothing while the rest of the site serves normally.
All three are addressed in the banner’s markup. The rest of this page is the reasoning and the finished tag.
The Epom side of each fix — the message the creative posts and how the loader and the ad server
react to it — is checked against the ad server’s code. The googletag calls are Google’s API,
not Epom’s: confirm them against Google’s current GPT reference before rolling a tag out,
because Epom does not control how they behave.
Who this is for
Publishers and network managers who paste GPT passback markup into an HTML Code banner. Nothing here needs anything beyond the banner editing you already do. A slot that Epom’s own header-bidding runtime fills is a different mechanism — see Header bidding.
Symptom: renders once, then every reload is blank
The placement has Refresh after (sec) set (Advanced settings), the first impression looks right, and every reload after it leaves an empty slot.
This is not a duplicate-id problem. Before it injects the new ad, the Universal JS loader
hides the previous one and strips the id attribute off every element inside it, so two
<div id="gpt-passback"> never coexist on the page.
What survives the swap is GPT’s own state. googletag lives in the page’s window, not in the
DOM, and it keeps a slot registry keyed by element id. Replacing the markup does not reset
it. So a tag written as “reuse the slot if it already exists” finds the slot left by the
previous impression, skips defineSlot, and display() on an already-displayed slot does
nothing:
// renders once, then never again
var slot = googletag.pubads().getSlots().find(function (s) {
return s.getSlotElementId() === "gpt-passback";
});
if (!slot) {
slot = googletag.defineSlot(AD_UNIT, SIZES, "gpt-passback");
}
Destroy the stale slot instead of reusing it, and define a fresh one on every impression:
var stale = googletag.pubads().getSlots().filter(function (s) {
return s.getSlotElementId() === "gpt-passback";
});
if (stale.length) {
googletag.destroySlots(stale);
}
var slot = googletag.defineSlot(AD_UNIT, SIZES, "gpt-passback");
destroySlots also removes the previous ad’s iframe from the old container, so nothing is left
behind.
Symptom: Google returns no ad and the slot stays empty
GAM answered “no ad”. Epom cannot see that: from its side the request was filled — by the banner carrying the passback tag — so the placement’s Default code never fires. The blank happens later, in the browser, and the request is written off as delivered.
Have the creative report the empty fill. The loader then re-requests the ad with that banner excluded, and the next eligible banner takes the slot:
googletag.pubads().addEventListener('slotRenderEnded', function (e) {
if (e.isEmpty && e.slot === slot) {
window.postMessage(JSON.stringify({
by: "$$AJAX_INVOCATION_CODE_KEY$$",
key: "$$PLACEMENT_KEY$$",
bId: $$BANNER_ID$$,
pb: true
}), '*');
}
});
The macros are substituted when the markup is generated — paste them as-is. The message format and the parameters that control what gets excluded are documented under Templates; the same mechanism works from any creative that can detect its own failure, not only GPT.
Then make sure something can actually serve second — the exclusion is pointless if the placement has nothing else to offer:
- Link a backfill banner (house, direct or programmatic) to the placement and put the passback banner first with daisy chaining. Without daisy chaining, normal weighted rotation shows the backfill on ordinary requests too, not only on unfilled ones.
- Or set the placement’s Default code (Advanced settings; it needs the Placement Default Code permission) as the last resort — it is returned once no eligible banner is left after the exclusions.
Symptom: one localized path fills nothing while the rest of the site serves
Fill on /de (or any other localized path) sits at zero while the same ad units, the same
placement and the same tag fill normally everywhere else. Nothing on your side distinguishes
those requests.
GPT decides the page’s language on its own, and Google’s classification of a URL can get it wrong — a path misclassified as an unsupported language stops being monetized entirely, so the slot is never filled rather than filled cheaply. The tag can override the detection per request by reading the page’s declared language:
var language = document.documentElement.lang;
if (language) {
googletag.pubads().set('document_language', language);
}
This runs before googletag.enableServices() — after services are enabled the value no
longer reaches the request. If the page does not declare lang on <html>, pass the language
code as a literal instead of reading the attribute.
The misclassification itself is on Google’s side, and only Google can confirm it for a given URL — the question to ask their support is what their system holds against that exact URL, not why it is unfilled. But the override lives in the markup you paste here, so it is worth setting on every localized site before escalating anything.
Corner case: measure this with an interleaved A/B, never with single page views. When overall fill is around 20%, an empty slot is the expected outcome for a large share of views, and any single observation “proves” whatever you hoped for.
The finished tag
Both fixes together. Only the element id, the ad unit path and the sizes change per placement:
<script async src="https://securepubads.g.doubleclick.net/tag/js/gpt.js" crossorigin="anonymous"></script>
<div id="gpt-passback" style="width:300px; height:250px;"></div>
<script>
window.googletag = window.googletag || { cmd: [] };
googletag.cmd.push(function () {
var passbackMessage = JSON.stringify({
by: "$$AJAX_INVOCATION_CODE_KEY$$",
key: "$$PLACEMENT_KEY$$",
bId: $$BANNER_ID$$,
pb: true
});
var language = document.documentElement.lang;
if (language) {
googletag.pubads().set('document_language', language);
}
if (!window.gptServicesEnabled) {
googletag.enableServices();
window.gptServicesEnabled = true;
}
var stale = googletag.pubads().getSlots().filter(function (s) {
return s.getSlotElementId() === "gpt-passback";
});
if (stale.length) {
googletag.destroySlots(stale);
}
var slot = googletag.defineSlot(
"/NETWORK_CODE/AD_UNIT",
[300, 250],
"gpt-passback"
);
if (slot) {
slot.addService(googletag.pubads());
googletag.pubads().addEventListener('slotRenderEnded', function (e) {
if (e.isEmpty && e.slot === slot) {
window.postMessage(passbackMessage, '*');
}
});
googletag.display("gpt-passback");
} else {
window.postMessage(passbackMessage, '*');
}
});
</script>
The else branch matters: defineSlot returns null when the element id is already claimed or
the ad unit path is malformed, and in that case no slotRenderEnded ever arrives — without it
the request would be lost silently.
The element id must be unique on the page
gpt-passback above is a placeholder. The id has to be unique across the whole page, not
just within the banner — give each placement its own (gpt-passback-leaderboard,
gpt-passback-skyscraper-2, …) and use that same string in all four places: the <div>, the
filter, defineSlot and display.
Reusing one id for two ads on the same page breaks all three mechanisms at once:
getElementByIdresolves to the first matching<div>, so the second ad renders into its neighbour’s container or nowhere;defineSlotreturnsnullfor the second slot because the id is already claimed — with theelsebranch above that at least passes the request back instead of losing it, but the GAM demand for that slot is gone;- on reload, each tag’s
destroySlotsmatches by id and would tear down the other ad’s slot.
Two placements may serve the same ad unit path — that part is fine. It is the element id that must differ.
Corner cases and gotchas
Slot.addEventListeneris missing from some GPT builds and throwsTypeError. GPT catches it, logs it, and abandons the rest of the queued command — so a failure while wiring up a listener also skips thegoogletag.display()below it, and nothing renders at all. Register listeners ongoogletag.pubads(), and keep anything optional out of the path that leads todisplay().
Listeners registered on
googletag.pubads()are not removed on reload. They accumulate for as long as the page lives — harmless (each closure only matches its own slot, and stale slots never fire), but on very long sessions consider registering one listener per page and keying the message bye.slot.getSlotElementId().
Exclusions last for the current cycle only. The next scheduled reload starts clean, so the passback banner gets the first chance again on every rotation.
The empty impression is recorded first and corrected afterwards. The banner’s tracking fires when the markup renders, before GPT decides it has no ad — so the impression is real at that moment. Sending the passback message is what corrects it: when the hour’s statistics are processed, every passback subtracts one impression (count, gross and net) from the matching aggregate, so General reports settle on the corrected figure. This only happens for banners whose creative actually reports the empty fill — a passback tag left as-is keeps every blank counted as an impression.
Advanced reports do not subtract. They keep both raw events, which is the honest record — read Impressions against Valid passbacks (a passback matched to the impression it corrects) and Rejected passbacks (no matching impression was found) there; see the metrics reference. One consequence: if the impression was recorded by a different ad server than the passback, the correction is written as a negative impression row so the two report families agree — and only when the network runs Advanced reporting. Without it the correction is dropped rather than pushing a counter below zero, so a small over-count can survive. See My fill rate is low.
The banner’s own Enable passback functionality checkbox is not needed for this tag. It generates a ready-made snippet for a creative that simply has nothing to show (see HTML Code banners), and it needs the Ajax invocation-code permission of the placement type, which the stock Advertiser role does not hold. The message above goes through the Universal JS loader instead, and the ad server logs a passback for any call that reaches it on an ajax (Universal JS) domain.
wi: trueadds an impression that the subtraction then cancels. It exists for flows where the impression beacon never fired at all, so the passback has something to subtract from. On a GPT tag the beacon has already fired, so leave the flag out — with it the two cancel and the correction is lost.
The Friendly Frame ad wrapper sidesteps the reload problem entirely. Set it as the placement’s Ad wrapper (Ad tags & API → Settings) and each reload renders into its own iframe document with its own
window, so no GPT state carries over. The trade-off: the creative then reads the frame’s document, sodocument.documentElement.langis empty and language targeting derived from it is lost. The frame is same-origin, and the loader setsinDapIF = trueinside it.
Libraries other than GPT need the same idea in their own vocabulary. If a teardown API exists (most video players), call it before re-initialising. If none exists (
adsbygoogleand similar), make the element id unique per impression with the$$RANDOM$$macro (id="passback-$$RANDOM$$") and use the same value in the script.