cf.completefrontend
My progress
SYSTEM DESIGN · #70

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

RequirementTargetWhy this number
Requests before consent (EU, UK)Zero to non-essential third partiesThe whole point. One analytics hit before the click is the most common finding in audits.
CMP script sizeUnder 15 KB gzipped for banner and gateIt loads early on every page, before most of the app. Some bought CMPs ship several times that.
Layout shift from the banner0 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 elementA large text banner can be the biggest element on a small screen.
Click to next paint (INP) on Accept or RejectUnder 200 ms at the 75th percentileAccepting can insert many tags at once; the banner must close before they run.
AccessibilityWCAG 2.2 AA, fully keyboard usableEvery 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

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.

Two timelines for the same page. Top, delete cookies after Reject: the Google Analytics hit goes out at 0.2 seconds, the ad pixel sets its cookie at 0.3 seconds, and the YouTube embed loads at 0.3 seconds, all before the visitor clicks Reject at 4.3 seconds. Then only the _ga cookie is deleted; vendor cookies and logged requests stay. Bottom, block at the source: analytics and the pixel stay inert, the video is a placeholder, and Reject at 4.3 seconds changes nothing because zero requests were sent.Delete cookies after RejectGA hit 0.2 spixel 0.3 sYouTube 0.3 sReject 4.3 s_ga deleted; vendor cookies and logged requests stayBlock at the sourceGA inertpixel inertplaceholderReject 4.3 szero requests to vendors, before or after Rejecttime runs left to right from the first byte
The harm happens in the first 300 ms, long before anyone reads the banner. Deleting _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?

Let it run, delete cookies on Reject

Tags load normally.

On Reject, loop over document.cookie and expire known cookie names.

Pros

  • No changes to how tags are added.
  • Nothing to configure per tag.

Cons

  • Data has already left the page.
  • Cannot touch third-party or HttpOnly cookies.
  • Not compliant where opt-in is required.

Block at the source with inert script tags

Every non-essential tag is written as type="text/plain" with a data-category attribute, so the browser treats it as data and does not run it.

After consent, the gate creates a real script element with the same source and inserts it.

Pros

  • Nothing runs until allowed: the browser enforces it.
  • Simple, no vendor knowledge needed.
  • Works with server-rendered HTML.

Cons

  • Every tag must be tagged correctly by hand.
  • A tag added without the attribute runs freely.

Consent checks in the tag manager

Tags live in Google Tag Manager or similar.

Each tag has a consent requirement, and the tag manager fires it only when the CMP reports that category as granted.

Pros

  • Marketing keeps adding tags without touching code.
  • Central list of tags and their categories.

Cons

  • The tag manager itself must load, and its settings must be right for every tag.
  • Only covers tags inside the tag manager.

Automatic blocking by the CMP

The CMP loads first and watches for new script and iframe elements, blocking those whose source matches a list of known trackers.

Pros

  • Catches tags that teams forgot to mark.

Cons

  • Must run before anything else, synchronously.
  • Matching by URL misses unknown vendors and can break the site.
  • Adds work on the main thread for every inserted element.

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.

ts
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
  }
}
The gate: activate every inert tag whose category is now allowed.
html
<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>
In the HTML: two blocked tags and one placeholder for an embed.

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.

Region
Granted
A sample page with 10 items in the EU. session cookie: allowed. cart cookie: allowed. consent cookie: allowed. language memory: blocked. chat widget: blocked. Google Analytics: blocked. Hotjar heatmap: blocked. Meta pixel: blocked. YouTube embed: placeholder. Google Maps: placeholder. Third-party hosts contacted: 0.session cookieallowedcart cookieallowedconsent cookieallowedlanguage memoryblockedchat widgetblockedGoogle AnalyticsblockedHotjar heatmapblockedMeta pixelblockedYouTube embedplaceholderGoogle Mapsplaceholderhosts contacted: 0placeholders: 2necessary is always allowed
First visit from the EU: nothing non-essential runs until the visitor chooses.

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?

One switch

Accept or reject everything.

Pros

  • Simplest UI and code.

Cons

  • Not specific enough for GDPR consent.
  • Visitors who dislike ads lose nothing by rejecting analytics too.

A few categories

Necessary (always on), preferences, analytics, marketing.

Each vendor belongs to one category; the visitor can expand a category to see them.

Pros

  • Easy to understand in 10 seconds.
  • Specific enough for most sites.
  • Maps cleanly to Consent Mode signals.

Cons

  • A vendor that does two things must go in the stricter category.

Per vendor and per purpose (IAB TCF)

The visitor can allow or object to each of hundreds of ad tech vendors and each standard purpose.

The result is encoded in a TC string that ad partners read.

Pros

  • What programmatic ad partners expect.
  • Very precise.

Cons

  • A huge second layer that few visitors read.
  • Heavier script and a vendor list to download and keep updated.

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.

CategoryDefault (EU)ExamplesConsent Mode signal
NecessaryAlways on, no toggleSession, cart, CSRF, load balancer, the consent cookiesecurity_storage, always granted
PreferencesOffRemembered region, chat widget historyfunctionality_storage, personalization_storage
AnalyticsOffGoogle Analytics, Amplitude, heatmapsanalytics_storage
MarketingOffAd pixels, retargeting, YouTube and social embedsad_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?

localStorage only

A JSON record under one key.

Pros

  • Simple to read and write in JavaScript.

Cons

  • Invisible to the server and the edge.
  • Per origin, so shop.example.com and blog.example.com ask twice.

A first-party cookie

A small cookie such as cc holding the version, the timestamp, and the categories, with Domain=example.com, SameSite=Lax, Secure.

Pros

  • Sent with every request, so the server and the edge can render the right page.
  • Shared across subdomains.

Cons

  • Adds about 100 bytes to every request.
  • Safari caps cookies written by document.cookie at 7 days, so the server should set it.

Server only, by account

The choice is stored with the user's account.

Pros

  • Follows the user across devices.

Cons

  • Most visitors are not logged in.
  • Needs a request before the page knows what to block.

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.

ts
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"}
The stored record. Short keys keep the cookie small.

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.

Sequence diagram of Accept all between the Banner, the Store, the Gate, and the Receipt API. The banner sends Accept all to the store. The store calls onChange on the gate. The gate runs the tags and the banner hides. The store posts the receipt with keepalive to the Receipt API, which replies 200 with Set-Cookie cc. Failure path: if the receipt fails with 503 or the visitor is offline, the store keeps the choice with a short fallback cookie and retries on the next page.BannerStoreGateReceipt APIAccept allonChange(all)tags run, hide UIPOST, keepalive200, Set-Cookiefailure path503 or offlineretry on next page
The choice takes effect in the page before the network call, so a slow or failing receipt service never blocks the visitor. The long-lived cookie comes back from our own server in 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 stored v is older, the banner shows again. Until the visitor answers, the gate allows only vendors whose added-in version is at or below the stored v, 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.
State machine of a visitor's consent. Unknown, with no cookie, goes to Banner shown on page load, where tags stay blocked. From the banner, Accept or Reject leads to Answered, a cc cookie with version 7, and Customize leads to Preferences, with draft toggles. Save in the preferences leads to Answered, and the footer link goes from Answered back to Preferences. When the config version changes, Answered becomes Stale, where the old answer still holds for the vendors it listed, and Stale asks again by showing the banner. When the cookie expires after 6 months, Answered goes back to Unknown.Unknownno cookieBanner showntags blockedPreferencesdraft togglesStaleold v appliesAnsweredcc cookie, v7loadCustomizeAccept, RejectSavefooter linknewversionask againcookie expires (6 months)
Only three moves write a record: Accept, Reject, and Save. Everything else is reading or drafting. A stale record still covers the vendors it listed until the visitor answers again, so the gate never opens wider than the last answer; an expired cookie is simply gone.

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?

Ask a geo IP API from the browser

The CMP script calls a lookup service, waits for the country, then decides.

Pros

  • No server changes.

Cons

  • An extra round trip of 100 to 300 ms on every first visit before the banner can show.
  • The lookup service is itself a third party that sees the IP address.

Use the EU rules everywhere

One strict banner for all visitors.

Pros

  • Simplest, and never under-protects anyone.

Cons

  • US visitors see a banner they do not need, and the business loses opt-out analytics there.
  • Still needs a Do Not Sell or Share link for California.

Geolocate at the edge

The CDN already knows the country from the IP address, such as Cloudflare's CF-IPCountry or CloudFront's CloudFront-Viewer-Country header.

An edge function maps the country to a rule set (eu, uk, us-opt-out, other) and writes it into a tiny inline script in the HTML.

Pros

  • Zero extra requests.
  • The rule set is known before the first tag.

Cons

  • The HTML now varies by region, so the cache key must include the rule set: 4 variants, not one per country.
  • IP location is wrong for VPN users and travelers.

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.

Data flow from the origin to vendor servers. The origin sends one copy of the HTML with inert tags to the CDN edge. The edge writes rules equals eu inline, with 4 cached variants. The inline bootstrap reads the cc cookie and GPC and fills the consent store. When there is no answer it loads the Banner UI. The banner calls set with the granted categories on the store and posts a receipt to the Receipt API, which returns Set-Cookie cc to the page. The store calls onChange on the gate, and the gate lets only allowed requests reach vendor servers.Origin HTMLHTML, inert tags, 1 copyCDN edgerules=eu inline, 4 variantsInline bootstrapreads cc cookie, GPCConsent storeonChangeGateReceipt APISet-Cookie: ccload UIBanner UIPOST receiptset(granted)Vendor serversallowed only
The HTML never varies by consent, only by rule set, so the CDN keeps 4 copies instead of one per cookie value. The consent itself is read in the page, from the 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.

ts
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"
Applying GPC in the bootstrap, before any tag runs.

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.

Architecture of the consent manager. At the CDN edge, an edge function maps the country to rules and sends HTML with the rules. In the browser, the inline bootstrap feeds the Store and the Gate. The lazy Banner UI calls set on the Store, the Store calls the Gate, the Store notifies the App and SDKs with onChange, and the Gate activates Vendor tags such as gtag and pixels. The Banner UI posts receipts to the Receipts service on our server, and a versioned Config file feeds the bootstrap. A note says the cc cookie holds the version, timestamp, and granted categories.CDN edgeEdge fngeo to rulesHTML + rulescc cookie:v, ts,grantedBrowserInline bootstraprules, GPC, cookie, defaultsBannerlazyStoreget, setGateinert tagsApp, SDKswhenGrantedVendor tagsgtag, pixelsonChangeactivateServerConfigversionedReceiptsSet-CookiePOST receipt
The bootstrap, store, and gate are about 1 KB and run before any tag. The banner UI is dashed because most page views never load it: returning visitors already chose. Only the gate touches vendor code.
PartResponsibilityLoadsSize target
Edge functionMaps the country to a rule set, writes __CC_RULES__ and the config version into the HTMLOn the CDNNot shipped to the browser
Bootstrap (inline)Reads the cookie, applies GPC, sets Consent Mode defaults, decides if the banner is neededInline in head, before every tagAbout 1 KB
Consent storeHolds the current state in memory, exposes get, set, and onChangePart of the bootstrapIncluded above
GateActivates inert scripts and placeholders for allowed categories; watches for new onesPart of the bootstrapIncluded above
Banner and preferences UIFirst and second layer, translations, vendor listsLazy, only when needed8 to 12 KB plus text for one language
Receipt clientSends receipts with keepalive, retries on the next pageWith the UIUnder 1 KB
Receipt serviceStores receipts, sets the long-lived cookieOur serverNot shipped
Config fileCategories, vendors with their added-in version, texts, versionBuilt 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

EntityLives inNotes
Consent recordFirst-party cookie cc, mirrored in memoryThe source of truth in the browser, shown in the storing chapter
Rule setInline script from the edgeeu, uk, us-opt-out, other; never stored
Config (categories, vendors, texts)Static JSON on the CDN, versioned, cached for a long timeFile name contains the version, so a new version is a new URL
ReceiptServer databaseKept as long as the legal team decides; links to the exact text shown
UI state (open layer, toggles)Component stateThrown 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.

ts
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()));
The public API, created by the inline bootstrap.

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 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.

js
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,
  });
});
Defaults in the bootstrap, then an update from the store.

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.

Sequence diagram of withdrawing analytics between Preferences, the Store, the SDKs and gtag, and our Server. Preferences saves with Analytics off to the store. The store posts a receipt to the server. The store calls onChange with denied on the SDKs, then sends a gtag consent update with analytics_storage denied. The store deletes the _ga cookies on our domain and tells the preferences to offer a reload. The reload asks the server for the page, and the new HTML keeps the analytics tags inert.PreferencesStoreSDKs, gtagServerSave: no analyticsPOST receiptonChange: deniedgtag update_ga gone, reload?reload pagetags stay inert
Everything that can stop at once does: the receipt, the SDKs, and Consent Mode. A script that already ran in this page can only be fully stopped by a reload, and cookies on the vendor's own domain are out of our reach.

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: fixed at 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 head holds up first paint until it downloads. Fix: the inline 1 KB bootstrap decides, and the UI loads with async or a dynamic import() 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.
Two phone screens after the banner appears. Left, banner in the flow: the banner is inserted at the top and pushes the headline and the hero image down by 120 pixels, a layout shift. Right, fixed overlay: the headline and the hero image stay where they were, and the banner sits over the bottom of the screen with a layout shift of 0.banner in flowbannerheadlinehero imagefixed overlayheadlinehero imagebanner (fixed)content moves 120 pxlayout shift 0
A new element that appears is not a shift by itself; the shift is the content it moves. With 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 section with aria-labelledby pointing to its heading, or a non-modal dialog opened with show(). 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-bottom on the page to the banner's height, so the browser scrolls focused elements above it.
  • Toggles are switches. A button with role="switch" and aria-checked, or a checkbox, with a visible label and the description linked by aria-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.

ts
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([]);
  });
}
A Playwright check: no non-essential hosts before consent.

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.

MinutesWhat to doWhat to draw or say
0 to 4ScopeRegions, 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 12The gateInert tags with data-category, the new-element trick, tag manager checks, click-to-load embeds. Say why deleting cookies afterwards fails.
12 to 17The bannerLayered design, equal Accept and Reject, no pre-ticked boxes, corner box versus modal. Mention the CNIL fines.
17 to 22Storage and regionsCookie with version and timestamp, receipts, re-ask rules, edge geolocation, GPC.
22 to 27Architecture and APIDraw the bootstrap, store, gate, lazy UI. Show onChange and the Consent Mode update.
27 to 30QualityOverlay 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?

Think first

Should we build this or buy OneTrust or Cookiebot?

Think first

We have shop.example.com and blog.example.com. One banner or two?

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/plain tags 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next