Google Tag Manager & Enhanced Conversions: How to Stop Burning Ad Spend on Broken Attribution
Broken tracking does not look broken. It looks like a campaign that underperforms for reasons nobody can explain. Here is how to verify the tag layer end to end.

A conversion tag that does not fire costs you twice. You lose the reporting, which you notice eventually, and you lose the optimisation signal, which you never notice at all — Smart Bidding simply learns from incomplete data and spends accordingly. The account underperforms and the diagnosis lands on creative or audience, because the tracking layer looks green in the GTM preview.
Four failures cover most of it. None of them produce an error message.
1. The duplicate container
The most common and the most misleading. A GTM snippet gets added to the theme, then a developer adds it to the layout, then a plugin injects it again. Every event fires two or three times. Conversions inflate, cost per acquisition looks excellent, and the bidding algorithm optimises toward a fiction.
Check it in the console on the live page:
// How many GTM containers are on the page?
performance.getEntriesByType('resource')
.filter(r => r.name.includes('gtm.js'))
.map(r => new URL(r.name).searchParams.get('id'))
// → ["GTM-XXXXXXX"] good
// → ["GTM-XXXXXXX", "GTM-XXXXXXX"] firing twiceThe same applies to gtag.js loaded alongside a GTM container that also configures GA4. Pick one path — either gtag directly or GTM — and remove the other. Running both is the fastest way to double your page_view count.
2. The trigger fires on the wrong thing
A conversion trigger attached to a button click records intent, not outcome. If the form submission then fails validation, hits a 500, or is rejected by the CRM, you have recorded a conversion that never happened.
The fix is to fire from the success state, which means the application has to tell the tag layer something happened. A dataLayer.push from the code path that actually succeeded:
// Fire only after the server confirms the lead was stored
async function submitLead(payload) {
const res = await fetch('/api/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error(`lead failed: ${res.status}`)
const data = await res.json()
window.dataLayer = window.dataLayer || []
window.dataLayer.push({
event: 'generate_lead',
lead_id: data.id,
form_name: 'demo_request',
value: 1500,
currency: 'INR',
})
return data
}For ecommerce, the equivalent is pushing purchase from the order-confirmation render with the real order ID, and deduplicating on that ID so a page refresh does not double-count.
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: order.id, // dedupe key — must be the real order id
value: order.total,
currency: 'INR',
items: order.items.map(i => ({
item_id: i.sku,
item_name: i.name,
price: i.price,
quantity: i.qty,
})),
},
})3. Enhanced Conversions sends nothing usable
Enhanced Conversions improves attribution by matching a hashed identifier — email, phone, name — against Google's signed-in users. It fails quietly in two ways: the field selector no longer matches after a form redesign, or the data is sent in a format that cannot match.
Two rules matter more than the rest. Normalise before hashing: lowercase, trim whitespace, and put phone numbers in E.164 format with the country code. An un-normalised value hashes to something that will never match. And do not hash it yourself in the browser unless you have a reason to — the Google tag hashes with SHA-256 on its own if you hand it plain values, and a hand-rolled hash of a badly normalised string is worse than no hash.
// Push clean, normalised values. Let the tag hash them.
window.dataLayer.push({
event: 'enhanced_conversion_data',
enhanced_conversion_data: {
email: user.email.trim().toLowerCase(),
phone_number: '+91' + user.phone.replace(/\D/g, '').slice(-10),
address: {
first_name: user.firstName.trim().toLowerCase(),
last_name: user.lastName.trim().toLowerCase(),
postal_code: user.pin,
country: 'IN',
},
},
})4. Consent Mode v2 defaults that never update
Consent Mode has two halves and teams routinely ship only the first. The defaults must be set before any Google tag loads, and the banner must then call an update when the visitor answers. Ship only the defaults and every visitor is permanently denied; ship only the update and you are sending data before consent.
<!-- BEFORE the GTM / gtag snippet -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500
});
</script>The function gtag(){dataLayer.push(arguments);} line is not optional and is missing from a surprising number of published snippets — without it gtag is undefined and the consent call throws. wait_for_update gives the banner a window to respond before tags decide.
Then, from the banner's accept handler:
gtag('consent', 'update', {
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
analytics_storage: 'granted',
})A verification pass that takes five minutes
- Open the live page in a private window with the GA4 DebugView open in another tab.
- Run the container-count snippet above. Expect exactly one ID.
- Confirm
page_viewarrives in DebugView within a few seconds. - Accept the cookie banner. Confirm a
consent updateevent follows, and that tags that were held now fire. - Submit the form with a real address. Confirm the conversion event fires after the success response, once.
- Check the Enhanced Conversions diagnostic in the Google Ads conversion action for a match rate above zero.
The Tracking & Tag Verification module checks for duplicate containers, missing GA4 page_view, Meta Pixel presence and Consent Mode v2 signals on any URL — no container access needed.
Check my tracking setupMake it a standing check, not a launch-day scramble
Tag stacks decay. A theme update re-adds a container, a form redesign breaks a selector, a consent plugin changes its callback name. None of it throws an error, and the first symptom is a reporting number that looks slightly wrong three weeks later.
The teams that avoid this run the tracking check on a schedule against their main landing pages rather than only before a launch, and treat a change in tag presence the same way they would treat a failing test. It is a five-minute habit that protects the data every optimisation decision depends on.
