Design a cookie consent manager
The consent banner and preferences center that decide which cookies, scripts, and embeds may run, like OneTrust, Cookiebot, or Klaro. The hard parts are blocking non-essential scripts before the user chooses, a fair banner that regulators accept, storing proof of the choice, rules that differ by region, and doing all of it without hurting page speed or accessibility.
The question
Our company runs a large store and a content site, with visitors from the EU, the UK, the US, and everywhere else. Marketing uses Google Analytics, a few ad pixels, YouTube embeds, and a chat widget.
Design the cookie consent manager: the banner, the preferences screen, and the code that makes sure nothing non-essential runs until the visitor allows it. It must be fair enough for regulators and must not slow the site down.
Real-life examples: OneTrust and Cookiebot banners on large news and retail sites, Didomi and Osano consent platforms, Klaro, an open source consent manager, In-house banners at companies like the BBC or GitHub
Requirements exploration
Most people think a consent banner is a box with two buttons. The box is the easy part. The real product is a gate: a small piece of code that stands between the page and every analytics tag, ad pixel, and video embed, and only opens the gate for the purposes the visitor agreed to.
A few words first. A cookie is a small value the browser stores for a site and sends back with requests. Similar storage means anything else that keeps data on the device, such as localStorage, IndexedDB, or a tracking pixel that reads a stored id.
A consent management platform (CMP) is the product we are designing: the banner, the preferences screen, the stored choice, and the API other code asks. A purpose (or category) is a reason to store data, such as analytics or marketing.
Questions to ask first
- Where are the visitors? EU and UK visitors generally need to opt in before non-essential storage. Most US state laws are opt-out instead. If the site only serves one country, the design gets much simpler.
- Which third parties run today? List them: analytics, ad pixels, A/B testing, chat, video, maps. Each one must be put in a category and blocked until allowed. A store often has 10 to 30 of these tags.
- How are tags added? Hard-coded in the HTML, through a tag manager such as Google Tag Manager, or bundled in our JavaScript? Each path needs its own way of blocking.
- Build or buy? OneTrust, Cookiebot, Didomi, and others sell this. We design it either way, because even a bought CMP must be wired into our pages, tests, and SDKs.
- Do we serve ads through ad exchanges? If yes, ad partners usually expect the IAB Transparency and Consent Framework (TCF) signal. If no, a simple category model is enough.
- Do we need proof? Regulators can ask a company to show that consent was given. That means storing a record on the server, not only in the browser.
- How many languages and pages? The banner text must be translated and it shows on every page, so it is part of every page's performance budget.
Functional requirements
- Show a first-layer banner on the first visit with Accept all, Reject all, and Customize, with equal weight.
- A preferences screen (the second layer) with one toggle per category, a short description, and the list of vendors in each.
- Block every non-essential script, pixel, and iframe until its category is allowed, and run it right after it is allowed, without a reload.
- Replace blocked embeds (YouTube, maps) with a click-to-load placeholder.
- Store the choice with a version and a timestamp, send a consent receipt to the server, and ask again when the purposes change or the choice expires.
- Show the right banner for the visitor's region and honor the Global Privacy Control signal.
- A link in the footer that reopens the preferences at any time, so withdrawing is as easy as agreeing.
- A JavaScript API for our own code and SDKs: read the current consent and listen for changes. Send Google Consent Mode signals.
Non-functional requirements
| Requirement | Target | Why this number |
|---|---|---|
| Requests before consent (EU, UK) | Zero to non-essential third parties | The whole point. One analytics hit before the click is the most common finding in audits. |
| CMP script size | Under 15 KB gzipped for banner and gate | It loads early on every page, before most of the app. Some bought CMPs ship several times that. |
| Layout shift from the banner | 0 added to CLS (Cumulative Layout Shift) | A banner that pushes content down hurts CLS on every first visit. |
| Effect on LCP (Largest Contentful Paint) | None, the banner must not become the LCP element | A large text banner can be the biggest element on a small screen. |
| Click to next paint (INP) on Accept or Reject | Under 200 ms at the 75th percentile | Accepting can insert many tags at once; the banner must close before they run. |
| Accessibility | WCAG 2.2 AA, fully keyboard usable | Every visitor must be able to make the choice, and reject is not optional. |
Out of scope
- The analytics SDK itself and how it queues events under consent: see the analytics-sdk guide.
- How third-party loaders and widgets are built: see the embeddable-widget guide.
- Writing the privacy policy and deciding the legal basis for each purpose: that is the legal team's job. We build the tool that shows and enforces their decisions.
- Server-side tracking pipelines. They still need the consent signal, which our API provides.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design the most? Select all that apply.
What makes this hard
A consent manager has a small UI and a large blast radius. Five things drive every later decision.
- It must act before anything else. A tag that runs 50 ms before the gate is ready has already sent a request and maybe set a cookie. You cannot take that back. So the gate must load first, and everything it guards must wait.
- It does not own the code it blocks. Analytics, pixels, and embeds are written by other companies and added by other teams. The gate has to stop code it has never seen, added in ways it does not control.
- It is judged by regulators, not only users. Button colors, the number of clicks to reject, and the wording are all things regulators have fined companies for. UX choices here are compliance choices.
- The rules depend on where the visitor is. Opt-in in the EU and UK, opt-out in California, and different again elsewhere. The same page must behave differently by region, and the page is usually cached on a CDN.
- It sits on every page, on the critical path. It loads early and shows on first visits, which are exactly the visits that Core Web Vitals and first impressions are measured on.
Notice the tension between the first and the last point. The gate must load before every tag, which pushes it to the very top of the page. But anything at the top of the page can slow down the first paint. Most of the performance chapter is about getting both.
Think first
Why can't we just let tags run and delete their cookies if the visitor clicks Reject?
Two reasons. First, the harm happens at the request, not the cookie: the tag has already sent the page URL, the visitor's IP address, and often an id to the vendor's server. Deleting a cookie later does not delete their copy.
Second, many cookies are out of our reach. A cookie set by ad.vendor.example while our page loads belongs to that vendor's domain. Our JavaScript can only read and delete cookies for our own site, and not even those if they are HttpOnly. Blocking at the source is the only approach that works.
What must wait for consent
Before we design the gate, we need to know what it guards. Here is the simplified picture an engineer needs. Again, this is not legal advice: the legal team decides, and we build a design that can follow whatever they decide.
The EU and the UK: opt in first
In the EU, the ePrivacy Directive (Article 5(3)) says that storing or reading information on a visitor's device needs their consent. The exception is storage that is strictly necessary for a service the visitor asked for. The rule covers cookies and similar storage: the EDPB (the board of EU data protection authorities) has said it also covers localStorage, tracking pixels, and some fingerprinting.
GDPR sets the bar for what counts as consent: freely given, specific, informed, and an unambiguous action. Silence, scrolling, or a pre-ticked box is not consent. The EU Court of Justice confirmed the pre-ticked box point for cookies in the Planet49 case in 2019.
The UK has the same model under PECR (the Privacy and Electronic Communications Regulations), enforced by the ICO. The Data (Use and Access) Act 2025, whose PECR changes took effect in early 2026, added a few narrow exceptions. One covers analytics used only to collect statistics that improve the site; another covers remembering how a visitor wants the site to look or work. Both apply only if visitors get clear information and a simple, free way to object.
Whether a given analytics setup fits that exception is a question for the legal team, and the ICO's guidance treats it narrowly. So design for it: a category can be switched from opt-in to opt-out per region by configuration, with the objection control in the preferences screen.
What counts as strictly necessary
| Storage | Usually needs consent in the EU and UK? | Why |
|---|---|---|
| Session cookie for login | No | The visitor asked to be logged in. It cannot work without it. |
| Shopping cart cookie | No | Needed for the cart the visitor is filling. |
| Load balancer or CSRF token cookie | No | Needed to deliver the page securely. |
| The consent cookie itself | No | Needed to remember the choice, including a Reject. |
| Remembered language or theme | Often no, if the visitor set it | Regulators differ. Many CMPs put it in a preferences category to be safe. |
| Analytics (Google Analytics, Amplitude) | Yes, with narrow exceptions | Useful to us, not needed by the visitor. Some regulators allow tightly limited first-party audience measurement. |
| Ad pixels, retargeting, A/B tests by vendors | Yes | Clearly for our benefit or a vendor's. |
| YouTube or map embed | Yes | The embed sets the vendor's cookies as soon as it loads. |
The US: opt out, and honor GPC
Most US state privacy laws, led by California's CCPA as amended by the CPRA, are opt-out. Tags may run by default, but the visitor must be able to say no to the "sale" or "sharing" of their data, usually through a link named "Do Not Sell or Share My Personal Information". Sharing includes giving data to ad networks for cross-context behavioral ads, the legal term for ads based on activity across other sites.
California's regulations also require honoring the Global Privacy Control (GPC), a browser setting that tells every site "do not sell or share my data". The California Attorney General's 2022 settlement with Sephora was partly about ignoring it. Several other states now require similar opt-out signals, and the list keeps growing, so treat it as a per-region setting the legal team owns.
| Region | Default before a choice | First-layer buttons | GPC signal |
|---|---|---|---|
| EU, EEA | Non-essential blocked | Accept all, Reject all, Customize | Not legally required; many sites treat it as a no to marketing |
| UK | Non-essential blocked (narrow exceptions) | Accept all, Reject all, Customize | Not legally required; many sites treat it as a no to marketing |
| California and similar states | Allowed, opt-out available | A notice and a Do Not Sell or Share link | Required: opt the visitor out of sale and sharing |
| Unknown region | Blocked (strictest) | Same as the EU | Honored |
Check your understanding · Exercise 2
A visitor from France has not answered the banner yet. Which of these may the page set or send? Select all that apply.
Blocking scripts until consent
This is the core of the design. The naive version is what many sites shipped first: load every tag as usual, show a banner, and if the visitor clicks Reject, delete the cookies.
Picture a product page with Google Analytics, a Meta pixel, and a YouTube video. Within 300 ms of the first byte, all three have contacted their servers, sent the page URL and IP address, and set cookies. The visitor reads the banner for 4 seconds and clicks Reject.
The gate deletes the _ga cookie it can see. The pixel's cookies on the vendor's domain are untouched, and the requests are already logged on three servers.
_ga later cannot recall the requests or reach cookies on the vendor's domain. Blocking at the source sends nothing at all.Decision How do we stop non-essential code from running before consent?
What we pick: Block at the source with inert tags for anything in our HTML, plus consent checks in the tag manager for the tags marketing adds there. Both share one rule: nothing non-essential runs unless the gate says yes. Automatic blocking is a useful safety net in some bought CMPs, but we don't rely on it. Instead, a CI test (see the quality chapter) catches any tag that slips through.
How inert tags work
The browser only runs a script whose type is empty, a JavaScript MIME type, or module. Any other type, such as text/plain, makes the element a data block: it stays in the DOM and does nothing. One detail trips people up: changing the type of a script that is already in the document does not run it. The browser only prepares a script at a few moments, mainly when it is inserted, and a change of type is not one of them.
So the gate creates a new element, copies the attributes, and inserts it. Two details matter in that copy. Under a Content Security Policy (CSP) with nonces, the browser hides the nonce attribute after the page loads, so getAttribute("nonce") returns an empty string: copy the nonce property instead. And a script inserted from JavaScript runs asynchronously by default, so an inline tag that needs its vendor's library must wait for that script's load event.
function activateScripts(allowed: Set<Category>) {
const inert = document.querySelectorAll<HTMLScriptElement>(
'script[type="text/plain"][data-category]'
);
for (const old of inert) {
const cat = old.dataset.category as Category;
if (!allowed.has(cat)) continue;
const s = document.createElement("script");
for (const { name, value } of old.attributes) {
if (name !== "type" && name !== "nonce") s.setAttribute(name, value);
}
s.nonce = old.nonce; // the attribute reads as "" once hidden
s.text = old.text; // inline tags keep their body
old.replaceWith(s); // a new element, so the browser runs it
}
}
<script type="text/plain" data-category="analytics"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" async></script>
<script type="text/plain" data-category="marketing">
/* inline pixel snippet */
</script>
<div class="embed-placeholder" data-category="marketing"
data-src="https://www.youtube.com/embed/abc123">
<button>Load video (YouTube sets cookies)</button>
</div>
With the nonce copied, the new element passes the same CSP as the original. Read the third-party-scripts lesson for how CSP and third-party tags interact.
Iframes: click to load
An iframe has the same problem as a script: the moment its src is set, the browser contacts the vendor. So embeds are rendered as a placeholder with the video's thumbnail stored on our own server, a title, and a button. Clicking the button can either load just this one embed, or grant the whole category. Loading just this one is kinder: the visitor wanted one video, not every marketing tag.
YouTube's privacy-enhanced mode (youtube-nocookie.com) does not remove the need for the placeholder. Google describes it as keeping the view from personalizing YouTube and ads, not as storing nothing. Independent tests show the player still contacts Google when it loads and writes to localStorage, with cookies once the video plays. It is a good default after consent, not a replacement for it.
Check your understanding · Exercise 3
The gate runs, analytics is granted, and the code sets script.type = "text/javascript" on the existing inert tag. What happens? Select all that apply.
Categories, purposes, and vendors
Consent has to be specific: the visitor agrees to a purpose, not to "cookies" in general. The naive design has one switch, on or off. It fails the moment a visitor wants analytics but not ads: they click Reject all, and we lose the analytics we could have had.
Decision How fine-grained should the choices be?
What we pick: Four categories for the core design, with a vendor list inside each. If the business sells ads through exchanges, add a TCF layer on top for ad tech vendors only, generated from the same configuration. Most sites outside publishing never need TCF.
| Category | Default (EU) | Examples | Consent Mode signal |
|---|---|---|---|
| Necessary | Always on, no toggle | Session, cart, CSRF, load balancer, the consent cookie | security_storage, always granted |
| Preferences | Off | Remembered region, chat widget history | functionality_storage, personalization_storage |
| Analytics | Off | Google Analytics, Amplitude, heatmaps | analytics_storage |
| Marketing | Off | Ad pixels, retargeting, YouTube and social embeds | ad_storage, ad_user_data, ad_personalization |
The configuration is one JSON file owned by the privacy team: categories with their descriptions in every language, and vendors with their category, cookie names, lifetime, privacy policy link, and the config version that added them. The banner, the preferences screen, the gate, the CI test, and the cookie table in the privacy policy are all generated from it. One source means the policy page never disagrees with what the code blocks.
A short note on IAB TCF
The IAB Transparency and Consent Framework is an industry standard for ad tech. A TCF CMP must be registered with IAB Europe, expose a global __tcfapi function, and store the choice as a compact TC string that it passes to ad vendors.
Version 2.2 (2023) removed legitimate interest as a basis for ad and content personalization. Version 2.3, required for new TC strings since March 2026, makes the string always record which vendors were shown to the visitor. Treat TCF as an add-on in the interview: mention it when ads are in scope and move on.
Check your understanding · Exercise 4
A support chat widget stores the conversation in localStorage and also sends page views to the vendor for their marketing reports. How should we handle it? Select all that apply.
Storing the choice, regions, and GPC
Once the visitor chooses, we must remember it, so the banner does not come back on the next page, and we must be able to show later that consent was given. The naive version stores consent=yes in localStorage.
Three problems follow. The server cannot read localStorage, so server-rendered pages cannot know whether to include tags or the banner. There is no version, so when marketing adds a new ad vendor next month, old consents silently cover it. And there is no proof on our side if a regulator asks.
Decision Where does the choice live?
What we pick: A first-party cookie as the source of truth, set by our server in the response to the consent receipt so Safari does not cut its lifetime to 7 days. The receipt endpoint must be on our own site and servers: Safari applies the same 7-day cap to cookies set through a CNAME or IP address that belongs to a third party, such as a CMP vendor's. Logged-in users also get the choice saved to their account, and it is applied on other devices after login. localStorage is not needed.
type Category = "preferences" | "analytics" | "marketing";
type ConsentRecord = {
v: number; // config version the visitor saw, such as 7
id: string; // random receipt id, not a user id
ts: number; // when they chose, epoch seconds
region: "eu" | "uk" | "us-opt-out" | "other";
granted: Category[]; // empty after Reject all
src: "banner" | "preferences" | "gpc";
};
// Cookie value, URL-encoded JSON, about 120 bytes:
// {"v":7,"id":"k3f9","ts":1792572000,"region":"eu","granted":["analytics"],"src":"banner"}
Consent receipts
GDPR expects a company to be able to show that a person consented. So every choice also creates a consent receipt on the server: the receipt id, the config version and its text in that language, the categories granted, the timestamp, the region, and the source. No name or email is needed: the random id in the cookie links the browser to its receipt. The banner sends it with fetch and keepalive: true, so the request finishes even if the visitor clicks a link right away.
Set-Cookie, which Safari does not cap at 7 days.Note the order in the flow. The gate applies the choice in the browser first, then sends the receipt. If the receipt fails, the visitor's choice still holds for this page.
The banner writes a short-lived fallback cookie from JavaScript and retries the receipt on the next page view. We never block the visitor on our own server being up.
When to ask again
- The config version changes in a way that matters. A new vendor in an already granted category, or a new purpose, bumps
v. If the storedvis older, the banner shows again. Until the visitor answers, the gate allows only vendors whose added-in version is at or below the storedv, so the old answer covers exactly what it listed. Fixing a typo does not bump it. - The choice gets old. CNIL considers about 6 months a generally appropriate time to keep a choice, accept or reject, before asking again. Many CMPs use 6 to 12 months. We make it a per-region setting, with 6 months for the EU, and use it as the cookie's
Max-Age: when the cookie expires, the visitor is back to unknown. - The cookie is missing. Cleared cookies, a new browser, or private browsing mean a fresh start. That is expected.
Check your understanding · Exercise 6
Marketing adds TikTok's pixel to the Marketing category. What should happen for a visitor who accepted Marketing two months ago? Select all that apply.
Regional rules
Our visitors come from the EU, the UK, the US, and elsewhere, and each region needs a different default and a different first layer. The page needs to know the region before it decides what to block, which means before any non-essential tag could run.
Decision How does the page learn the visitor's region?
What we pick: Geolocate at the edge, and cache the HTML per rule set. When the country is missing or unknown, use the strictest rules. A VPN user who looks like they are in Germany gets the EU banner, which is the safe mistake to make. The feature-flag-sdk guide uses the same edge bootstrapping pattern, and the cdn-caching lesson explains cache keys.
Why put the rule set in the HTML and not read the consent cookie on the server to decide everything? Because the HTML must stay cacheable. If the server rendered different HTML per consent value, the CDN would need a variant for every combination of cookie values.
Instead, the HTML always contains the tags in their inert form plus a 1 KB inline bootstrap. The bootstrap reads the cookie in the browser and activates what is allowed. It is the same trick the dark-mode-theming guide uses to apply the theme before first paint.
cc cookie, before the first tag.Global Privacy Control
The analytics-sdk guide explains how the GPC signal reaches a page (the Sec-GPC: 1 request header and navigator.globalPrivacyControl) and which browsers send it. For the CMP, the question is where to apply it: in the bootstrap, before any tag runs, as a per-region rule. In California and the states with similar rules it is a required opt-out of sale and sharing. In the EU nothing runs without opt-in anyway, and many sites also treat it as a no to marketing.
const gpc = navigator.globalPrivacyControl === true;
const rules = window.__CC_RULES__; // written by the edge: "eu", "uk", "us-opt-out", "other"
const stored = readConsentCookie(); // null on a first visit
let granted: Category[];
if (stored) granted = stored.granted; // an expired cookie is already gone
else if (rules === "us-opt-out") granted = ["preferences", "analytics", "marketing"];
else granted = []; // opt-in regions and unknown: nothing until asked
// A per-region setting from the config; required for "us-opt-out".
if (gpc && config.gpcBlocksMarketing[rules]) granted = granted.filter((c) => c !== "marketing");
// Show a note in the preferences: "Your browser's privacy signal was applied"
The last line matters. When GPC overrides a stored Accept, tell the visitor in the preferences screen, so they understand why ads look different. If they later turn Marketing on themselves, record it with src: "preferences"; how much weight that later choice has against GPC is a question for the legal team.
Check your understanding · Exercise 7
The CDN cannot find a country for a request (the header is missing). What should the page do? Select all that apply.
Architecture and the consent API
Now we can put the pieces together. The key split is between what must run first and what can wait. The bootstrap is tiny and inline: it reads the cookie, applies the region rules and GPC, sets Consent Mode defaults, and activates already-allowed tags.
The banner UI is only needed on a first visit or when the visitor opens the preferences, so it loads later and only when needed. Returning visitors, who are most visitors, never download the UI.
| Part | Responsibility | Loads | Size target |
|---|---|---|---|
| Edge function | Maps the country to a rule set, writes __CC_RULES__ and the config version into the HTML | On the CDN | Not shipped to the browser |
| Bootstrap (inline) | Reads the cookie, applies GPC, sets Consent Mode defaults, decides if the banner is needed | Inline in head, before every tag | About 1 KB |
| Consent store | Holds the current state in memory, exposes get, set, and onChange | Part of the bootstrap | Included above |
| Gate | Activates inert scripts and placeholders for allowed categories; watches for new ones | Part of the bootstrap | Included above |
| Banner and preferences UI | First and second layer, translations, vendor lists | Lazy, only when needed | 8 to 12 KB plus text for one language |
| Receipt client | Sends receipts with keepalive, retries on the next page | With the UI | Under 1 KB |
| Receipt service | Stores receipts, sets the long-lived cookie | Our server | Not shipped |
| Config file | Categories, vendors with their added-in version, texts, version | Built into the bootstrap (categories) and the UI (texts) | Generated |
Why is the store inside the bootstrap and not in the UI bundle? Because our own app code and SDKs ask "may I track?" on every page, including pages where the banner never loads. Why is the UI not inline? Because inlining 10 KB of UI and text into every HTML response would slow every page, and most page views come from visitors who already chose.
If the CMP were a product that other companies install, like OneTrust, this bootstrap would become a one-tag loader on pages we do not own. The embeddable-widget guide covers that case: a small loader, isolating our CSS and JavaScript, and working under the host's CSP.
Data model
| Entity | Lives in | Notes |
|---|---|---|
| Consent record | First-party cookie cc, mirrored in memory | The source of truth in the browser, shown in the storing chapter |
| Rule set | Inline script from the edge | eu, uk, us-opt-out, other; never stored |
| Config (categories, vendors, texts) | Static JSON on the CDN, versioned, cached for a long time | File name contains the version, so a new version is a new URL |
| Receipt | Server database | Kept as long as the legal team decides; links to the exact text shown |
| UI state (open layer, toggles) | Component state | Thrown away on close; only Save writes to the store |
The last row is worth saying out loud in an interview. Toggles in the preferences screen are a draft. Flipping a toggle must not change the store, because a visitor who flips Marketing on and then closes the dialog has not consented. Only Save, Accept all, or Reject all commit.
Check your understanding · Exercise 8
Why does the bootstrap live inline in the HTML instead of in an external CMP script? Select all that apply.
The consent API
Inert tags cover scripts in the HTML. But our own code also needs to ask: the analytics SDK bundled in the app, the A/B testing client, the recommendations widget that remembers recent views. They need a small, stable API on window, ready before any of them run.
interface ConsentApi {
/** Current state; never waits. "unknown" means not answered yet. */
get(): { status: "unknown" | "answered"; granted: Category[]; gpc: boolean };
has(category: Category): boolean;
/** Called now with the current state, then on every change. Returns unsubscribe. */
onChange(cb: (granted: Category[]) => void): () => void;
/** Run once, as soon as the category is granted (now, or later). */
whenGranted(category: Category, run: () => void): void;
openPreferences(): void; // loads the UI on demand
version: number;
}
declare global { interface Window { consent: ConsentApi } }
// In the app:
window.consent.whenGranted("analytics", () => import("./analytics").then((m) => m.init()));
Two design points. onChange calls back right away with the current state, so a subscriber that attaches late cannot miss the answer that came before it. And whenGranted pairs well with dynamic import(): the analytics bundle is not even downloaded until it may run. The analytics-sdk guide shows how an SDK can queue events while consent is unknown and drop or send them once it is answered.
Google Consent Mode v2
Google tags read their own consent signals. Version 2, which Google has required since March 2024 for ads measurement and personalization with visitors in the EEA, has four main ones: ad_storage, analytics_storage, ad_user_data, and ad_personalization. The bootstrap sets defaults before any Google tag, and the store sends an update on every change.
gtag("consent", "default", {
ad_storage: "denied", ad_user_data: "denied",
ad_personalization: "denied", analytics_storage: "denied",
wait_for_update: 500, // ms the tags wait for an update
});
consent.onChange((granted) => {
const m = granted.includes("marketing") ? "granted" : "denied";
const a = granted.includes("analytics") ? "granted" : "denied";
gtag("consent", "update", {
ad_storage: m, ad_user_data: m, ad_personalization: m, analytics_storage: a,
});
});
The analytics-sdk guide compares Consent Mode's basic setup, where Google tags do not load until consent, with its advanced setup, where they load and send cookieless pings while denied. Our gate keeps the Google tag inert until a category is granted, which is the basic setup. The defaults still matter: when a visitor grants analytics but not marketing, the Google tag loads and must already know the ad signals are denied. Advanced mode stays behind a per-region config switch for the legal team.
Reopening and withdrawing
GDPR says withdrawing must be as easy as giving consent. So every page has a Cookie settings link in the footer that calls consent.openPreferences(). The taskforce report mentions a small floating icon or a link in a visible place as ways to do this; a footer link on every page is the common choice.
Withdrawing is harder than granting, because code that already ran cannot be un-run. When a category is turned off, the store saves the new record and sends a receipt. It notifies subscribers, so SDKs stop sending and Consent Mode gets denied. It deletes the first-party cookies listed for that category's vendors, such as _ga and _ga_XXXX, using the same Domain and Path they were set with, or the delete silently misses.
Then the preferences screen offers a reload, because a script already running in the page, like a pixel listening to clicks, cannot be fully stopped otherwise. Cookies on the vendor's own domain stay; the policy should tell visitors how to clear them.
Check your understanding · Exercise 9
A visitor turns Analytics off in the preferences. Which steps belong in the withdrawal? Select all that apply.
Performance, accessibility, and testing
Performance: on every first visit
First visits are when the banner shows, and first visits are also the slowest, with cold caches. Four mistakes are common, and each hurts a Core Web Vital (see the core-web-vitals lesson).
- The banner pushes the page down (CLS). A banner inserted at the top of the normal flow after the first paint moves every element below it. On a phone that can add a layout shift score of 0.1 or more by itself, the edge of the good range. Fix:
position: fixedat the bottom, so it overlays content and moves nothing. - The banner becomes the LCP element. LCP picks the largest text or image block in the viewport. On a 375 px wide phone, a banner with four lines of text can be bigger than the page's headline, and because it appears late, after the CMP script, it makes LCP late. Fix: keep the first-layer text short so the headline or hero image stays the largest element, have the bootstrap start loading the banner UI as soon as it knows the banner is needed, and test LCP on a phone viewport with the banner showing.
- The CMP script blocks rendering. A synchronous external CMP script in
headholds up first paint until it downloads. Fix: the inline 1 KB bootstrap decides, and the UI loads withasyncor a dynamicimport()only when needed. - Accept all makes INP slow. INP (Interaction to Next Paint) measures how long the page takes to paint after a click. If the Accept handler inserts every tag in one task, the banner stays on screen while they all parse and run. Fix: hide the banner and commit the choice first, then yield to the browser (for example with
setTimeout) before the gate activates the tags.
position: fixed nothing below the banner moves, so its CLS cost is zero.Blocking also helps performance. On a typical store, the tags behind the gate add hundreds of KB of JavaScript. Visitors who reject get a much lighter page. Measure both groups separately in real user monitoring, or the averages will hide it.
Accessibility
- First layer: a labelled region, not a modal. Use a
sectionwitharia-labelledbypointing to its heading, or a non-modaldialogopened withshow(). The visitor can keep reading with a screen reader, and nothing traps focus. - Do not steal focus on load. Moving focus into the banner on page load interrupts screen reader users mid-page. Instead, place the banner early in the DOM order, right after the skip link, so it is reached with a few Tab presses.
- Second layer: a real modal. The preferences screen is a task, so open it with
showModal(): focus moves in, the page behind is inert, Escape closes it, and focus returns to the button or footer link that opened it. The focus-management lesson covers the return focus. - Do not hide the focused element. A fixed banner can cover the link or field a keyboard user just tabbed to, which fails WCAG 2.2's Focus Not Obscured rule. Set
scroll-padding-bottomon the page to the banner's height, so the browser scrolls focused elements above it. - Toggles are switches. A
buttonwithrole="switch"andaria-checked, or a checkbox, with a visible label and the description linked byaria-describedby. Necessary shows as on and disabled, with a text reason. - Readable. Text contrast of at least 4.5 to 1, buttons at least 24 by 24 CSS pixels (WCAG 2.2), and a layout that still works at 200 percent zoom without the banner covering the whole screen.
Testing: prove nothing leaks
The most valuable test is simple: load key pages in a headless browser with no consent cookie, record every network request, and fail the build if any request goes to a host that is not on the allow list for necessary. Then click Reject all and check again, and click Accept all and check that the expected vendors do load. Run it for each rule set by faking the region.
import { test, expect } from "@playwright/test";
import { necessaryHosts, necessaryCookies } from "../consent.config";
for (const path of ["/", "/product/123", "/blog/launch"]) {
test(`no tracking before consent on ${path}`, async ({ page }) => {
const hosts = new Set<string>();
page.on("request", (r) => hosts.add(new URL(r.url()).host));
await page.goto(path, { waitUntil: "networkidle" });
await page.waitForTimeout(2000); // late tags
const leaks = [...hosts].filter((h) => !necessaryHosts.includes(h));
expect(leaks).toEqual([]);
const names = (await page.context().cookies()).map((c) => c.name);
expect(names.filter((n) => !necessaryCookies.includes(n))).toEqual([]);
});
}
Also run the check against production on a schedule, because marketing adds tags in the tag manager without a deploy. And add axe checks for the banner and the preferences in both states (see the e2e-tests lesson).
Check your understanding · Exercise 10
After launch, CLS on mobile first visits jumps from 0.02 to 0.14. What is the likely cause, and the fix? Select all that apply.
Running it in the interview
This question is often asked as an easy one, and candidates lose it by spending 25 minutes on the banner's look. The interviewer wants to see that you know the banner is the small part and the gate is the real system.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 4 | Scope | Regions, which tags exist, how they are added, build or buy, ads or not. Say "I'll design for the strictest region and relax by config." |
| 4 to 12 | The gate | Inert tags with data-category, the new-element trick, tag manager checks, click-to-load embeds. Say why deleting cookies afterwards fails. |
| 12 to 17 | The banner | Layered design, equal Accept and Reject, no pre-ticked boxes, corner box versus modal. Mention the CNIL fines. |
| 17 to 22 | Storage and regions | Cookie with version and timestamp, receipts, re-ask rules, edge geolocation, GPC. |
| 22 to 27 | Architecture and API | Draw the bootstrap, store, gate, lazy UI. Show onChange and the Consent Mode update. |
| 27 to 30 | Quality | Overlay for CLS, short text for LCP, accessibility of both layers, the CI leak test. |
Draw the gate first, not the banner: an HTML page with three inert tags, the bootstrap above them, and an arrow from the store to the gate. Everything else hangs off that picture. Say out loud that the legal rules come from the privacy team and that your design makes each rule a setting, such as opt-in or opt-out per region and the re-ask interval. That shows you know where engineering ends.
Follow-up questions to expect
Think first
Our site is a single page app. Does anything change?
The gate runs once, but the app keeps adding content. Embeds rendered later by React must use the placeholder component, which subscribes to onChange. Our own tracking uses whenGranted. And withdrawal can't rely on the next page load to stop scripts, because there is no next page load, which is why the preferences screen offers a reload.
Think first
Should we build this or buy OneTrust or Cookiebot?
Buying gives a scanner that finds cookies, a vendor database, TCF registration, translations, and legal updates. Building gives a smaller, faster script and full control of the UX. A common middle path: buy the CMP for the banner and receipts, but still own the gate wiring, the API wrapper our code calls, and the CI leak test, because the vendor cannot know how our app loads its code.
Think first
We have shop.example.com and blog.example.com. One banner or two?
One choice, if both are the same company and the same purposes: set the cookie with Domain=example.com, and share one config version. A different country domain like example.de is another site and must ask on its own, since cookies cannot be shared across sites.
Summary
- The banner is the small part. The real system is a gate that keeps every non-essential script, pixel, and embed from running until its category is allowed.
- Block at the source with inert
text/plaintags and tag manager consent checks. Deleting cookies afterwards cannot undo requests or reach other domains. - To run an inert tag, insert a new script element. Changing the type of the old one does nothing.
- A fair first layer has Accept all, Reject all, and Customize with equal weight, no pre-ticked boxes, and no cookie wall. Regulators have fined for less.
- Store a versioned record in a first-party cookie set by the server, send a receipt for proof, and ask again when purposes change or after about 6 to 12 months.
- Learn the region at the edge, default to the strictest rules when unsure, and honor Global Privacy Control.
- A tiny inline bootstrap decides before any tag; the UI loads lazily. Overlay the banner to avoid CLS and keep its text short so it does not become the LCP element.
- Prove it in CI: a headless browser with no consent must see zero requests to non-essential hosts.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- EDPB: Report of the work undertaken by the Cookie Banner Taskforce (2023) The banner practices most EU regulators agree are not valid.
- EDPB Guidelines 05/2020 on consent under GDPR Cookie walls, scrolling, and what valid consent means.
- EDPB Guidelines 2/2023 on the technical scope of Art. 5(3) of the ePrivacy Directive Why pixels, local storage, and some fingerprinting count too.
- ICO: Guidance on the use of storage and access technologies UK rules, including the exceptions added by the Data (Use and Access) Act 2025.
- CNIL: Refusing cookies should be as easy as accepting them
- CNIL: Comment mettre mon site web en conformité ? (in French) Includes the six-month recommendation for keeping a choice.
- Global Privacy Control specification
- California Attorney General: Global Privacy Control
- Google: Set up consent mode on websites
- IAB Europe: Transparency and Consent Framework
- IAB Europe: The transition to TCF v2.3
- California Attorney General: CCPA
- web.dev: Best practices for cookie notices Performance advice for banners.
- MDN: The dialog element
- MDN: nonce, including nonce hiding
- W3C: Understanding WCAG 2.2 Focus Not Obscured (Minimum) Uses a sticky cookie banner as its example.
- WebKit: Tracking Prevention in WebKit The 7-day caps on script-written cookies and cloaked third-party cookies.
Concepts behind it
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- End-to-end testsTests that drive a real browser through your real app like a user would: what to cover, how to keep them reliable, where test data comes from, and how to run them in CI.