Your consent platform emits a TC string and your Google tags read Consent Mode signals, and the two now disagree: a visitor who declined personalisation still shows ad_personalization: granted, or a purpose grant fails to release the tags that depend on it.

Triage: Compare the Two Outputs for One Decision

The check takes one console session and settles whether the mapping is wrong or the timing is.

  1. Clear consent, reload, and accept a partial selection — statistics only is the most informative.
  2. Run __tcfapi('getTCData', 2, console.log) and record purpose.consents for purposes 1 through 11.
  3. Read the data layer for the most recent consent update and record all four Consent Mode signals.
  4. Compare: purposes 1 and 8 granted with analytics_storage: denied is a mapping fault; all four signals absent is a v1 platform, a different problem entirely.
One decision, two vocabularies A single partial acceptance is expressed twice. On the TCF side it becomes a set of purpose consents, where purpose one covers storing information on a device and purpose eight covers measuring content performance. On the Consent Mode side it becomes four named signals. The mapping between them is not one to one, which is why deriving each from the other rather than from a shared state produces drift. TCF purposes 1 store on device ✓ 8 measure content ✓ 3 personalise ads ✗ 4 select ads ✗ Consent Mode analytics_storage granted ad_storage denied ad_user_data denied ad_personalization denied Not a one-to-one mapping: several purposes feed one signal, and one purpose feeds several. Which is why a mapping written in one direction cannot simply be reversed.

Root Cause: Two Vocabularies, Mapped Twice

TCF describes processing in terms of eleven purposes and a per-vendor list; Consent Mode describes it in terms of four storage and usage signals. The relationship is many-to-many: analytics_storage depends on purposes 1 and 8 together, ad_storage on purposes 1 and 3, and ad_personalization on purposes 3 and 4 — with legitimate interest complicating each of them further.

Drift appears when the two are derived independently. A platform emits the TC string; a separate piece of integration code emits the Consent Mode calls; and the two encode slightly different opinions about which purposes imply which signals. Because nothing validates them against each other, the disagreement is silent until someone compares a specific visitor’s two outputs — which is what the triage above does.

The durable fix is structural rather than a corrected mapping table. Both outputs should be derived from one normalised internal state, in one module, so there is a single place where the relationship is expressed. That is the same adapter boundary that keeps a platform swap from touching every call site, doing a second job.

Derived twice versus derived once On the left, the platform emits a TC string and separate integration code emits Consent Mode calls, each encoding its own opinion of the mapping, so the two drift apart with nothing to detect it. On the right, one normalised state is derived from the platform and both outputs are generated from it, so the mapping exists in exactly one place and cannot disagree with itself. derived independently platform → TC string integration code → gtag calls two mappings, no comparison drift is silent derived from one state platform → normalised state state → TC string, state → gtag one mapping, one place they cannot disagree The fix is not a better table. It is having only one table.

Resolution: One Normalised State, Two Renderers

// consent-map.js — the single place the relationship is expressed.
// Purposes are read from the TC data; both outputs are rendered from `state`.
export function normalise(tcData) {
  const p = (n) => Boolean(tcData.purpose?.consents?.[n]);
  return {
    // Storage on the device underpins everything; without purpose 1 nothing is granted.
    storage:        p(1),
    measurement:    p(1) && p(8),
    ads:            p(1) && p(3),
    adUserData:     p(1) && p(3),
    adPersonalise:  p(1) && p(3) && p(4),
  };
}

export function toConsentMode(state) {
  const v = (b) => (b ? 'granted' : 'denied');
  return {
    analytics_storage:  v(state.measurement),
    ad_storage:         v(state.ads),
    ad_user_data:       v(state.adUserData),
    ad_personalization: v(state.adPersonalise),
  };
}

// One call site. Never emit gtag consent updates from anywhere else.
__tcfapi('addEventListener', 2, (tcData, ok) => {
  if (!ok || !['tcloaded', 'useractioncomplete'].includes(tcData.eventStatus)) return;
  if (!tcData.tcString) return;              // some platforms fire early with no string
  gtag('consent', 'update', toConsentMode(normalise(tcData)));
});

Two guards in that listener matter. Filtering on eventStatus prevents acting on the stub and loading states, and checking tcString is non-empty catches platforms that emit useractioncomplete before the string is assembled — a known deviation that produces intermittent, unreproducible wrong states.

Note also that every derived value depends on purpose 1. Storage consent underpins the rest, so a visitor who declines it has declined everything downstream regardless of what else they selected; encoding that once in normalise is considerably safer than remembering it at four separate call sites.

Verification: A Partial Selection Round-Trips

Accept statistics only, then re-run both reads from the triage step. analytics_storage should be granted and the other three denied, and the TC data should show purposes 1 and 8 consented with 3 and 4 refused. Repeat with marketing only, which exercises the opposite half of the mapping.

Two selections that exercise both halves Two test cases. Statistics only should produce purposes one and eight granted and analytics storage granted, with the three advertising signals denied. Marketing only should produce purposes one, three and four granted and the three advertising signals granted, with analytics storage denied. Testing only accept-all passes with almost any mapping and proves nothing. statistics only purposes 1, 8 ✓ analytics_storage granted three ad signals denied marketing only purposes 1, 3, 4 ✓ three ad signals granted analytics_storage denied Accept-all is the one case that passes under almost any mapping. Never test with it alone.

Legitimate Interest Is a Third State

The code above reads purpose.consents and ignores purpose.legitimateInterests, which is a deliberate simplification worth making explicit because it is a policy decision rather than a technical one.

Under TCF, a vendor may rely on legitimate interest for some purposes instead of consent, and the string carries both sets separately. A mapping that considers only consent will treat a legitimate-interest basis as absent and gate a vendor that was entitled to run; a mapping that treats the two as interchangeable will run a vendor on a basis your organisation may not have agreed to rely on.

Neither is universally correct, which is why the choice belongs in one place with a comment recording who made it. Most publishers take the conservative route for measurement and advertising — requiring consent regardless of the available legitimate-interest basis — because the resulting under-collection is recoverable and the alternative is not. Whichever you choose, express it inside normalise so it is visible, versioned and reviewable, rather than implied by which field a piece of integration code happened to read.

Where the Mapping Should Live in the Codebase

Put the mapping module as close to the platform adapter as possible and as far from the tags as possible. The tags should never see a purpose number, and the platform should never see a Consent Mode signal; both vocabularies belong inside one boundary that translates between them and exposes neither.

The practical test is a grep. Searching the codebase for purpose or for ad_user_data should return matches in exactly one file. Every additional file that matches is a place where the mapping has been duplicated — usually by someone who needed one value and inlined the derivation rather than importing it — and each duplicate is a copy that will not be updated when the framework changes.

That discipline also makes the module the natural home for the legitimate-interest decision, the version guard, and the logging you will want during an incident. A single function that takes TC data and returns a normalised state is small enough to read in one sitting and is the only artefact anyone needs to understand to answer “why did this vendor run”.

Common Pitfalls

  • Emitting Consent Mode updates from more than one place. A tag manager template and your own code both calling gtag('consent', 'update') will race, and the loser’s values win at unpredictable times.

  • Ignoring purpose 1. Every other purpose presupposes storage. A mapping that grants measurement without it produces a state no framework considers valid.

  • Testing only with accept-all. It grants everything and therefore validates nothing about the mapping.

  • Reading the TC string directly at call sites. Every place that parses the string is a place the mapping is repeated. Parse once, in the adapter, and pass the normalised state onward.

  • Forgetting that vendor consent is separate from purpose consent. A purpose may be granted while a specific vendor is not, so a vendor check needs vendor.consents[id] as well — accepting all purposes does not authorise a vendor added to the list last week.


Frequently Asked Questions

Do we need TCF at all if we only run Google tags?

No. TCF exists to carry per-purpose, per-vendor signals into a programmatic advertising auction, and if you are not participating in one the string has no consumer. Google’s own tags read Consent Mode signals, which you can emit directly from your normalised state without any TCF involvement.

Adopting it anyway brings a certification obligation, a vendor list to keep current, and a materially more complex banner. Take it on when an advertising partner requires it, and note that requirement in writing when they do — it is a constraint imposed on you rather than a technical choice you made.

What happens if the TC string arrives after the tags have loaded?

The tags run under the defaults, which is why the default call must set every signal to denied before any container loads. Consent Mode is designed for exactly this ordering: tags boot in a restricted state, hold anything requiring storage, and release it when the update arrives.

What must not happen is defaults that are permissive, or a default call emitted after the container. Both mean the tags spend the window between load and update in a state the visitor never authorised — and on a slow connection, or where the platform’s bundle is blocked, that window is the entire session.

How do we keep the mapping correct as frameworks change?

Version it and test it. Because the whole relationship lives in one function, a change to either framework is a change to one file with a diff someone can review, and a small table-driven test — a set of purpose combinations and the signals each should produce — catches a regression immediately.

Add the two partial selections from the verification step to that test suite rather than checking them by hand. They are the cases that fail when a framework adds a signal or a purpose, and they are cheap enough to run on every commit.

Should the normalised state be exposed to product code?

Yes — it is the vocabulary the rest of the application should think in. A feature deciding whether to render an embed wants to ask “is marketing granted”, not to reason about purpose four or about ad_personalization. Exposing the normalised state and hiding both frameworks behind it is what makes that possible.

Keep the exposed shape small and stable, because it becomes an internal API. Four or five named booleans covering the categories your consent banner actually presents is usually right; mirroring the eleven TCF purposes into product code recreates the coupling the module exists to prevent.


Up: Syncing Consent States Across Multiple Vendors