Third-party scripts that were granted consent at page load continue executing after a user withdraws that consent — because deleting a cookie does not terminate a running timer, close an open WebSocket, or cancel an in-flight fetch. A full-page reload solves the symptom but fractures SPA routing state, resets hydration caches, and guarantees a jarring user experience. The correct fix is a deterministic teardown protocol that stops execution in-place, within milliseconds of the revocation signal.
Triage: Confirm That Scripts Are Still Running After Revocation
Before writing any code, verify the problem is real and measure its scope.
- Open Chrome DevTools → Network tab. Filter by
Fetch/XHRandWS. - In a second DevTools tab open Performance → click Record.
- Trigger the consent-revocation flow through your CMP UI (not by clearing cookies manually).
- Stop the recording. Note the timestamp of the revocation event in the timeline.
- Run this in the Console to surface any requests that fired after revocation:
// Set window.consentRevokedAt to performance.now() inside your CMP callback
// before running this check
const revokedAt = window.consentRevokedAt ?? 0;
const leaking = performance.getEntriesByType('resource')
.filter(r => r.startTime > revokedAt);
console.table(leaking.map(r => ({
url: r.name,
startMs: r.startTime.toFixed(0),
type: r.initiatorType
})));
- Switch to the Memory tab → take a heap snapshot → search
Detached DOM tree. Any detached nodes that reference vendor script state confirm execution continued after revocation.
Reproduction checklist:
Root Cause: Async Initialization Races the Synchronous Revocation Signal
The failure is structural. When syncing consent states across multiple vendors most CMPs emit a single callback — a TCF useractioncomplete event, a custom onConsentChange handler, or a window.__tcfapi response — but vendor SDKs initialize through entirely separate async pipelines. A script loaded with async or via window.dataLayer.push() may still be executing its initialization chunk when revocation fires. That chunk registers setInterval timers, opens WebSocket connections, and queues fetch calls before any teardown logic can intercept them.
The core breakdown has three layers:
- No registration contract. Vendor SDKs do not declare which handles they create, so there is nothing to unwind.
- No de-registration hook. The CMP’s revocation API (
gtag('consent', 'update', ...)) updates a flag but does not call any vendordestroy()method. - No network-layer enforcement. Patching a flag in memory does not block the
fetchAPI, which the SDK already holds a reference to.
This is the same async-lifecycle hazard that affects delaying third-party scripts until user consent — the difference is that here the SDK has already started.
The diagram below shows the state machine from grant to revocation:
Resolution Path: ConsentTeardownManager + window.fetch Interception
The fix has two parts that must both be in place: a registration layer that tracks every handle each vendor creates, and a network-layer patch that blocks outbound calls whose consent has lapsed.
Part 1 — Registration and teardown
Wrap SDK initialization so every handle gets registered before the SDK runs. The ConsentTeardownManager holds a Map of vendor IDs to their active handles, and revoke() unwinds them atomically.
class ConsentTeardownManager {
constructor() {
// vendorId -> { intervals, timeouts, sockets, abortControllers }
this.handles = new Map();
}
// Call this immediately after each SDK registers a handle
register(vendorId, handles) {
const existing = this.handles.get(vendorId) ?? {
intervals: [], timeouts: [], sockets: [], abortControllers: []
};
this.handles.set(vendorId, {
intervals: existing.intervals.concat(handles.intervals ?? []),
timeouts: existing.timeouts.concat(handles.timeouts ?? []),
sockets: existing.sockets.concat(handles.sockets ?? []),
abortControllers: existing.abortControllers.concat(handles.abortControllers ?? [])
});
}
revoke(vendorId) {
const h = this.handles.get(vendorId);
if (!h) return;
h.intervals.forEach(id => clearInterval(id));
h.timeouts.forEach(id => clearTimeout(id));
h.sockets.forEach(ws => {
// Only close if not already closing/closed
if (ws.readyState < WebSocket.CLOSING) ws.close(1000, 'consent_revoked');
});
h.abortControllers.forEach(ctrl => ctrl.abort());
this.handles.delete(vendorId);
this._purgeDOM(vendorId);
}
_purgeDOM(vendorId) {
// Remove any elements the vendor injected, identified by data-vendor attribute
document.querySelectorAll(`[data-vendor="${vendorId}"]`)
.forEach(el => el.remove());
}
}
// Single global instance — initialize before any vendor script loads
window.__ctm = new ConsentTeardownManager();
Part 2 — Network-layer enforcement
Removing DOM nodes does not cancel fetch calls already in flight or block new ones that a still-running timer queues. Patch window.fetch before vendor hydration to enforce consent state on every outbound call:
(function patchFetch() {
const _fetch = window.fetch; // store original before any vendor touches it
window.fetch = async function (input, init) {
const url = typeof input === 'string' ? input : input.url;
// window.__consentState is your CMP's live consent map: vendorDomain -> boolean
if (window.__consentState && !window.__consentState.isPermitted(url)) {
// Beacon the violation so compliance audits have a complete log
navigator.sendBeacon('/compliance/audit', JSON.stringify({
blocked: url,
ts: Date.now(),
reason: 'consent_revoked'
}));
// DOMException with AbortError is the closest semantic match
return Promise.reject(
new DOMException(`Fetch blocked post-revocation: ${url}`, 'AbortError')
);
}
return _fetch.apply(this, arguments);
};
})();
Part 3 — Cross-tab propagation via BroadcastChannel
A user who revokes consent in one tab expects the same effect in every open tab. Syncing consent state across tabs is handled with BroadcastChannel, which delivers within a single event-loop tick to all same-origin contexts:
const _ch = new BroadcastChannel('consent_revoke_v1');
// Call this from your CMP's revocation callback
function revokeConsent(vendorId) {
window.__ctm.revoke(vendorId); // local teardown
_ch.postMessage({ type: 'REVOKE', vendorId, ts: Date.now() }); // other tabs
navigator.sendBeacon('/compliance/audit', JSON.stringify({
event: 'consent_revoked', vendorId, ts: Date.now()
}));
}
// Other tabs receive and mirror the teardown
_ch.onmessage = ({ data }) => {
if (data.type === 'REVOKE') window.__ctm.revoke(data.vendorId);
};
Verification: Zero Post-Revocation Requests
Run this in the Console immediately after triggering revocation. A passing teardown returns an empty array; any entries indicate leaked execution:
// window.__consentRevokedAt must be set inside revokeConsent() above:
// window.__consentRevokedAt = performance.now();
const leaked = performance.getEntriesByType('resource')
.filter(r => r.startTime > (window.__consentRevokedAt ?? Infinity));
if (leaked.length === 0) {
console.log('PASS — no post-revocation network requests');
} else {
console.error('FAIL —', leaked.length, 'requests after revocation:');
console.table(leaked.map(r => ({ url: r.name, ms: r.startTime.toFixed(0) })));
}
Complement this with a Playwright assertion in CI:
// playwright test — confirm no vendor requests fire after CMP revocation click
test('no requests after consent revocation', async ({ page }) => {
const postRevocationRequests = [];
let revokedAt = 0;
page.on('request', req => {
if (revokedAt && req.url().includes('analytics-vendor.com')) {
postRevocationRequests.push(req.url());
}
});
await page.goto('https://staging.example.com/');
await page.click('[data-testid="revoke-consent"]');
revokedAt = Date.now();
await page.waitForTimeout(2000); // allow any in-flight requests to complete
expect(postRevocationRequests).toHaveLength(0);
});
Revocation is three actions, not one
Revoking is not the inverse of granting. Granting starts a vendor; revoking has to stop it, remove what it stored, and prevent it restarting — and only the first of those is something the vendor’s own API reliably offers.
Common Pitfalls
- Assuming
gtag('consent', 'update', { analytics_storage: 'denied' })is sufficient. This updates Google’s consent mode flag but does not callclearInterval, close sockets, or abort pending fetch calls queued by the GA4 SDK. You still need the teardown manager. - Patching
fetchwithout preserving the original reference. If you assignwindow.fetch = ...without capturingconst _fetch = window.fetchfirst, you create an infinite call loop. Always capture the original in the same IIFE scope before overwriting. - Ignoring
navigator.sendBeaconqueues. Beacon calls queue duringvisibilitychangeand fire on the next visibility event or page unload. If your teardown fires during a visibility-hidden period, beacons from that window may still flush. Treat them as accepted-but-logged rather than blocked —sendBeaconcannot be aborted after queuing.
Frequently Asked Questions
How do we prove a revocation actually took effect?
Verify it from outside your own code, because your code’s belief that it revoked is the thing under test. Open the Network panel, filter to the vendor’s origins, revoke, and then exercise the page — no further requests to those origins should appear. Then open the Application panel and confirm the vendor’s storage entries under your own domain are gone.
Add both as an automated check if the vendor matters. A headless run that grants consent, exercises a page, revokes, exercises again and asserts zero post-revocation requests is a few dozen lines, and it is the only form of evidence that survives a vendor changing their SDK without telling you. Manual verification catches the regression once; the automated version catches it every release.
Should revocation reload the page automatically?
Only for vendors in the third group, and preferably with the visitor’s knowledge. An automatic reload discards form state, scroll position and anything in progress, which turns a privacy control into a destructive action — and the visitor has no way to know that pressing “reject” will lose their half-completed checkout.
The workable pattern is to tear down everything you can in place, then tell the visitor plainly that finishing the change requires a refresh, with a button that does it. That is honest about what your code can and cannot do, and it puts the timing under the control of the person whose data it is.
Related
- Syncing Consent States Across Multiple Vendors — the BroadcastChannel architecture that powers cross-tab propagation
- Designing Graceful Fallback Chains for Blocked Scripts — what to serve in place of a script whose consent has lapsed
- Architecting GDPR-Compliant Consent Gating — the upstream consent-state machine that triggers revocation events
- Debugging CMP Integration Failures with Analytics Tags — diagnosing CMP callbacks that never fire