A Cookie Banner That Loads GTM and GA4 Only After Consent
A cookie banner that appears after the page finishes loading, when data collection scripts have already run from the first line, is just a notice, not a consent system. The difference comes down to a few lines controlling the loading order, but those few lines determine whether the site does what its policy page says.
I was working on a site that used Google Tag Manager, GA4, and AdSense together, and needed a cookie banner to match what the policy page said. While reviewing the existing implementation, I found that the banner did appear, the buttons did work, and the choice was saved, but opening the Network tab while the banner was still on screen showed that the GTM container had already loaded and GA4 had already sent one collect request, even though the user had not touched a button. So I rebuilt the whole setup.
Every ID in this article is an example value; replace them with your own before use. The structure and loading order are what I actually use.
The problem: a banner that blocks nothing
The code I see most often looks like this: put Google's standard script at the top of <head> as the instructions say, then write the banner in a separate file.
<!-- แบบที่เจอบ่อย และไม่ได้กั้นอะไรเลย -->
<head>
<!-- GTM โหลดทันทีที่หน้าเริ่มพาร์ส -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
var f=d.getElementsByTagName(s)[0],j=d.createElement(s);j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');</script>
<!-- GA4 โหลดตรงอีกตัว ซ้ำกับที่ตั้งไว้ใน GTM -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
</head>
<body>
<div id="cookie-bar">เว็บนี้ใช้คุกกี้ <button>ยอมรับ</button></div>
</body>
There are two overlapping problems here. First, consent is requested after data has already been collected, which directly contradicts the principle of prior consent. Second, GA4 loads through two paths: the tag configured in GTM and the manually added gtag/js line. The result is double-counted visitors that nobody notices because the numbers look better.
The only thing that can actually be verified is the number of requests sent to those services before the user clicks Accept. If that number is not zero, it does not matter how nice the banner looks or how long its text is. Anyone can open DevTools and check this number in ten seconds, including someone looking for a reason to give us trouble.
Choose before starting: gate the entire container or use consent mode
There are two approaches in actual use, and both work. They differ in how strict they are.
The first is consent mode: load GTM as usual, but declare the initial state as denied, then update it when the user clicks Accept. The advantage is that it provides anonymous data before acceptance, and many tags already support it.
// ทางที่หนึ่ง: consent mode — ยังโหลด GTM แต่ประกาศสถานะเริ่มต้นไว้ก่อน
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,
});
// ...เมื่อผู้ใช้กดยอมรับ
gtag('consent', 'update', { ad_storage: 'granted', analytics_storage: 'granted' });
The second approach is to load no container at all until the user clicks Accept. The downside is losing data from before that click and some consent mode capabilities. The upside is that it can be checked directly: open Network, and if no requests go out, that settles it. There is no need to trust whether the remote scripts actually respect the flags I set.
Personally, I mainly use the second approach because the sites I maintain do not run ad campaigns that depend on data from before consent. Losing a little data in exchange for clear proof that nothing leaves the browser is well worth it. Anyone who seriously depends on ad data can choose the first approach, but must accept the need to check each tag individually to see whether it respects the flags.
Load first, then instruct it not to collect
- Provides anonymous data before the click
- Tags in the same ecosystem support it directly
- Hard to prove; requires trusting the destination
- Suits sites that depend on ad data
Do not load until the click
- No outgoing requests before the click
- Verifiable through Network or a script
- Loses all data from before the click
- Suits content sites running AdSense
Design for all three states
People often design for only two states: no choice yet and accepted. They forget the third state, already declined, which must not prompt users again on every page change. Otherwise, users become annoyed enough to click Accept just to make it disappear, and that is not freely given consent.
The banner: both buttons must be equal
There is one visual detail I consider important: the Accept and Decline buttons must have equal weight, equal size, and equal readability. Making Decline a tiny, faint text link in a bottom corner is designing for accidental clicks, and it undermines the consent obtained.
<!-- วางท้าย body — ไม่ต้องรีบ ไม่ต้องบล็อกการเรนเดอร์ -->
<div id="ck-bar" class="ck-bar" hidden role="dialog" aria-label="การใช้คุกกี้">
<p class="ck-txt">เว็บนี้ใช้คุกกี้เพื่อวัดผลการใช้งานและแสดงโฆษณา
ถ้ายังไม่ยอมรับ เว็บยังใช้งานได้ตามปกติทุกส่วน
<a href="/privacy">อ่านนโยบายความเป็นส่วนตัว</a></p>
<div class="ck-act">
<button type="button" id="ck-no" class="ck-btn">ปฏิเสธ</button>
<button type="button" id="ck-yes" class="ck-btn ck-primary">ยอมรับ</button>
</div>
</div>
.ck-bar{position:fixed;left:0;right:0;bottom:0;z-index:9999;display:flex;gap:16px;
align-items:center;flex-wrap:wrap;padding:16px 20px;
background:#17130e;color:#f1ece0;font-size:.92rem;line-height:1.7}
.ck-txt{margin:0;flex:1 1 320px}
.ck-txt a{color:#f3713f}
.ck-act{display:flex;gap:10px;flex:0 0 auto}
/* ปุ่มสองปุ่มขนาดเท่ากัน อ่านง่ายเท่ากัน */
.ck-btn{min-width:128px;padding:11px 18px;border:1px solid #f1ece0;border-radius:2px;
background:transparent;color:#f1ece0;font-size:.92rem;cursor:pointer}
.ck-primary{background:#e5330b;border-color:#e5330b;color:#fff}
@media (max-width:480px){.ck-act{width:100%}.ck-btn{flex:1 1 0}}
The core: a GTM loader that cannot run twice
This is the heart of it. The GTM script must live inside a function, and that function must be called through only one path: when the state is accepted. It also needs a guard against repeated calls, because loading the container twice processes dataLayer again and skews the numbers.
// consent.js — ทั้งหมดของการกั้นอยู่ในไฟล์นี้ไฟล์เดียว
(function () {
'use strict';
var KEY = 'ck_consent'; // accepted | declined | (ไม่มีค่า = ยังไม่เลือก)
var GTM_ID = 'GTM-XXXXXXX'; // เปลี่ยนเป็นของตัวเอง
var loaded = false;
function readState() {
try { return localStorage.getItem(KEY); } catch (e) { return null; }
}
function writeState(v) {
try { localStorage.setItem(KEY, v); } catch (e) { /* โหมดส่วนตัว เขียนไม่ได้ ไม่ต้องพัง */ }
}
function loadGTM() {
if (loaded) return; // กันเรียกซ้ำ สำคัญมาก
loaded = true;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' });
var j = document.createElement('script');
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + GTM_ID;
document.head.appendChild(j);
}
function accept() {
writeState('accepted');
hideBar();
document.documentElement.classList.remove('ck-noads');
loadGTM();
showAds();
}
function decline() {
writeState('declined');
hideBar(); // ไม่โหลดอะไรทั้งนั้น ช่องโฆษณายังซ่อนอยู่
}
function hideBar() { var b = document.getElementById('ck-bar'); if (b) b.hidden = true; }
function showBar() {
var b = document.getElementById('ck-bar');
if (!b) return;
b.hidden = false;
document.getElementById('ck-yes').addEventListener('click', accept);
document.getElementById('ck-no').addEventListener('click', decline);
}
var state = readState();
if (state !== 'accepted') document.documentElement.classList.add('ck-noads');
if (state === 'accepted') { loadGTM(); showAds(); }
else if (state === null) { document.addEventListener('DOMContentLoaded', showBar); }
window.ckManage = function () { // ให้ปุ่มท้ายเว็บเรียกเพื่อเปลี่ยนใจ
try { localStorage.removeItem(KEY); } catch (e) {}
location.reload();
};
})();
In some browsers' private windows, or when users have configured their browsers to block site data, localStorage.setItem throws an error rather than returning false. Without a try wrapper, clicking Accept does nothing because the script fails before reaching the line that loads GTM. The path where the choice cannot be saved must still work: ask again next time, rather than breaking the page.
GA4 must be a tag inside GTM, with noscript deliberately omitted
If I am using GTM, I configure GA4 as a tag inside the container. Do not also put gtag/js on the page, because that sends data through two paths to the same destination. The numbers will be inflated, and the cause is very hard to track down when the figures do not match what I would expect.
// ถ้ามีอีเวนต์ของเราเอง ให้ push เข้า dataLayer แล้วให้ GTM เป็นคนตัดสินใจส่งต่อ
function track(name, params) {
if (!window.dataLayer) return; // ยังไม่ยอมรับ ก็ไม่มี dataLayer ให้ push
window.dataLayer.push(Object.assign({ event: name }, params || {}));
}
// ตัวอย่าง: track('form_submit', { form_id: 'contact' });
// GA4 config อยู่ใน GTM ตัวเดียว ไม่มี gtag/js ในหน้าอีกแล้ว
Another thing I deliberately omit is the noscript iframe that the GTM instructions say to place at the beginning of <body>.
<!-- ตัวนี้ผมตั้งใจไม่ใส่ เพราะมันทำงานนอกเหนือการควบคุมของ JS ทั้งหมด -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
The reason is straightforward. All of this gating is JavaScript. If a user disables JavaScript, the banner does not appear and the buttons cannot be clicked, but this iframe still loads normally. That means the most cautious users are the ones whose data gets collected without any chance to decline. What I lose is data from users who disable JavaScript, an already very small group. Compared with leaving a loophole I cannot explain when someone checks it, I choose to leave it out.
AdSense: hide slots first, then push on acceptance
Sites running ads have an extra condition: the <ins class="adsbygoogle"> slots are on the page from the start. Without any action, they leave empty boxes in the middle of the content while consent is still pending. My approach is to hide them with CSS tied to a class on <html>, then reveal them on acceptance.
/* ซ่อนช่องโฆษณาตราบใดที่ยังไม่ได้รับความยินยอม — ไม่ให้เหลือช่องว่างในเนื้อหา */
html.ck-noads .adsbygoogle,
html.ck-noads .ad-slot { display: none !important; }
// ส่วนของ AdSense ใน consent.js — เรียกจาก accept() เท่านั้น
var ADS_CLIENT = 'ca-pub-XXXXXXXXXXXXXXXX'; // เปลี่ยนเป็นของตัวเอง
var adsLoaded = false;
function showAds() {
if (adsLoaded) return;
adsLoaded = true;
var s = document.createElement('script');
s.async = true;
s.crossOrigin = 'anonymous';
s.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=' + ADS_CLIENT;
s.onload = function () {
// สั่งเรนเดอร์ช่องที่มีอยู่แล้วในหน้า หนึ่ง push ต่อหนึ่งช่อง
var slots = document.querySelectorAll('.adsbygoogle:not([data-adsbygoogle-status])');
for (var i = 0; i < slots.length; i++) {
try { (window.adsbygoogle = window.adsbygoogle || []).push({}); } catch (e) {}
}
};
document.head.appendChild(s);
}
The selector .adsbygoogle:not([data-adsbygoogle-status]) matters more than it might seem, because slots the script has already rendered have that attribute. Including them in the count and making more push calls than there are available slots produces the error All ins elements already have ads in them in the console. It does not break the page, but it buries other, more important errors until the console becomes unreadable.
Check in a real browser: requests to those services must be zero
Everything above is only credible when there are numbers to back it up. I wrote a check that opens the page, intercepts every request, and counts only those going to the destination domains for GTM, GA4, and AdSense. The count must be zero before the click and greater than zero after clicking Accept.
// tools/consent-check.cjs — ด่านที่ตอบว่าแบนเนอร์กั้นได้จริงหรือเปล่า
const { chromium } = require('playwright');
const WATCH = ['googletagmanager.com', 'google-analytics.com', 'analytics.google.com',
'googlesyndication.com', 'doubleclick.net'];
const hits = [];
(async () => {
const b = await chromium.launch();
const p = await b.newPage();
p.on('request', r => { if (WATCH.some(d => r.url().includes(d))) hits.push(r.url()); });
await p.goto('https://example.com/?v=' + Date.now(), { waitUntil: 'networkidle' });
await p.waitForTimeout(2500);
const before = hits.length;
console.log(`[${before === 0 ? 'OK' : 'FAIL'}] ก่อนกดยอมรับ: ${before} request`);
hits.forEach(u => console.log(' ', u));
const bar = await p.$('#ck-bar:not([hidden])');
console.log(`[${bar ? 'OK' : 'FAIL'}] แบนเนอร์แสดงอยู่`);
await p.click('#ck-yes');
await p.waitForTimeout(3000);
const after = hits.length - before;
console.log(`[${after > 0 ? 'OK' : 'FAIL'}] หลังกดยอมรับ: ${after} request`);
// กดยอมรับแล้วโหลดใหม่ ต้องไม่ถามซ้ำ และต้องไม่โหลด container ซ้ำซ้อน
const mark = hits.length;
await p.reload({ waitUntil: 'networkidle' });
await p.waitForTimeout(2000);
const gtm = hits.slice(mark).filter(u => u.includes('gtm.js')).length;
console.log(`[${gtm === 1 ? 'OK' : 'FAIL'}] โหลดซ้ำหน้าเดิม: gtm.js = ${gtm} ครั้ง`);
await b.close();
process.exit(before === 0 && after > 0 && gtm === 1 ? 0 : 1);
})();
$ node tools/consent-check.cjs
[OK] ก่อนกดยอมรับ: 0 request
[OK] แบนเนอร์แสดงอยู่
[OK] หลังกดยอมรับ: 7 request
[OK] โหลดซ้ำหน้าเดิม: gtm.js = 1 ครั้ง
The last line is one I added later, after accidentally calling loadGTM() in two places in an earlier version of the code and leaving visitor counts at twice their actual level for three days. The guard against repeated calls does help, but a check that shows the actual count is better than trusting that I already wrote the guard.
Allow consent withdrawal, and put it all into steps
The last point people often forget is that users who have already clicked must be able to change their minds. Without a way back, it amounts to asking once for their entire lifetime. I put a small button in the footer of every page.
<!-- ท้ายเว็บ ข้าง ๆ ลิงก์นโยบาย -->
<button type="button" class="foot-link" onclick="ckManage()">จัดการคุกกี้</button>
- Remove all GTM and GA4 scripts from
<head>and move them into theloadGTM()function. - Configure GA4 as a tag inside a single container. Do not also put
gtag/json the page. - Design for all three states, and do not ask again after someone has declined.
- Give the Accept and Decline buttons equal weight, in both size and visibility.
- Hide ad slots with a class on
<html>until acceptance, then push according to the number of slots that have not been rendered. - Wrap every
localStorageread and write in try, and keep the site working normally when the choice cannot be saved. - Run the check that counts requests to those services. It must report zero before the click, and
gtm.jsmust be exactly one per page. - Put a cookie management button in the footer of every page so users can change their minds.
I see this as more than just a compliance task. It is something anyone can verify with tools they already have. Anyone can open DevTools and inspect the site at any time, and whether what they see matches what I wrote on the policy page has only one answer. As for choosing consent mode or gating the entire container, that depends on how much the site relies on data from before consent. Choose whichever fits. If I have missed anything in the explanation or made a typo, I will come back and fix it later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first