Script Blocking
How Zest blocks tracking scripts until user consent
How It Works
Zest uses a sophisticated script blocking system that:
- Monitors the DOM using
MutationObserverto detect new scripts - Checks each script against blocking rules based on the configured mode
- Blocks matching scripts by changing their type to
text/plain - Queues blocked scripts for later execution
- Replays scripts when the user grants consent
Blocking Modes
Manual Mode
Only blocks scripts explicitly tagged with data-consent-category:
<!-- This will be blocked -->
<script data-consent-category="analytics" src="https://example.com/tracker.js"></script>
<!-- This will NOT be blocked -->
<script src="https://www.googletagmanager.com/gtag/js"></script>Safe Mode (Default)
Blocks manually tagged scripts plus known major trackers:
Blocked Analytics:
google-analytics.comanalytics.google.comgoogletagmanager.complausible.iocloudflareinsights.com
Blocked Marketing:
connect.facebook.netads.google.comgoogleads.g.doubleclick.netpagead2.googlesyndication.com
Strict Mode
Everything in Safe mode plus extended tracking services:
- Hotjar, Microsoft Clarity, Heap
- Mixpanel, Segment, FullStory, Amplitude
- LinkedIn, Twitter/X, TikTok, Snapchat, Pinterest
- Bing, Yahoo, Amazon, Criteo, Taboola, Outbrain
Doomsday Mode
Blocks all third-party scripts. Use with caution—this may break legitimate functionality.
Tagging Scripts
Basic Tagging
Use data-consent-category to specify which consent category a script requires:
<!-- Requires analytics consent -->
<script data-consent-category="analytics" src="https://example.com/analytics.js"></script>
<!-- Requires marketing consent -->
<script data-consent-category="marketing" src="https://example.com/pixel.js"></script>
<!-- Inline scripts work too -->
<script data-consent-category="analytics">
console.log('This runs after analytics consent');
</script>Categories
| Category | Purpose |
|---|---|
essential | Always allowed (cannot be blocked) |
functional | Site personalization features |
analytics | Usage tracking and statistics |
marketing | Advertising and remarketing |
Security note. Since v2.0.0, a script self-labeling as
data-consent-category="essential"is ignored — the only valid self-labels arefunctional,analytics, andmarketing. This prevents third-party scripts from escaping the blocker by declaring themselves essential. Categories assigned by the blocker's mode (e.g. known-tracker hostname) always take precedence over self-labels.
Allow-listing Scripts
Prevent a script from being blocked (even in strict/doomsday mode):
<script data-zest-allow src="https://trusted-cdn.com/library.js"></script>Custom Blocked Domains
Add your own domains to the blocklist:
window.ZestConfig = {
mode: 'safe',
blockedDomains: [
// Simple domain (defaults to marketing category)
'custom-tracker.com',
// With explicit category
{ domain: 'my-analytics.com', category: 'analytics' },
{ domain: 'my-cdn.com', category: 'functional' }
]
};Element Interception (v2.3.0+)
MutationObserver-based script blocking is asynchronous — it fires a microtask after the DOM mutation, so by the time we react, the browser has already kicked off the network request. The script may not execute (we flip type to text/plain), but the fetch already happened, and to a privacy auditor that fetch IS a pre-consent leak.
Zest now also installs synchronous prototype-setter patches that catch tracker elements BEFORE the browser fetches them:
| Patched | Catches |
|---|---|
HTMLScriptElement.src setter | script.src = "..." |
HTMLLinkElement.href setter | link.href = "..." (stylesheets, preload, prefetch) |
HTMLImageElement.src setter | img.src = "..." (tracking pixels) |
HTMLIFrameElement.src setter | iframe.src = "..." (tracking iframes) |
Element.prototype.setAttribute | el.setAttribute("src", "...") for the same four element types |
window.Image constructor | new Image() followed by .src = "..." |
When code does:
const s = document.createElement('script');
s.src = 'https://tracker.example/track.js'; // ← intercepted HERE,
// URL never lands on the element
document.head.appendChild(s); // ← appendChild fires nothingThe URL is queued with its category. When consent arrives, replayElements() re-applies the URL via the ORIGINAL setter for every element that's still in the DOM, so previously-blocked scripts / stylesheets / images execute without a page reload.
What this does NOT catch
Inline HTML <script src="..."> and <link rel="stylesheet" href="..."> tags parsed from the original document response. The browser starts fetching those during HTML parsing, BEFORE any JavaScript runs. The only complete fix for that class is server-side CSP or template-time removal (e.g. self-host Google Fonts instead of using fonts.googleapis.com).
Element interception is gated by the same intercept.scripts toggle as the MutationObserver-based blocker — they're both element-level defences and ship together.
Network Interception (v2.3.0+)
Modern CMSes (HubSpot, Cloudflare Zaraz, server-side GTM, Shopify, Webflow) increasingly proxy tracker scripts through the site's own origin to defeat ad-blockers. The <script> tag itself is first-party (e.g. /hs/scriptloader/{id}.js), so a hostname-based script blocker cannot match it. But at runtime that script still phones home to the vendor's analytics endpoint via fetch, XMLHttpRequest, or navigator.sendBeacon — and THAT URL is third-party.
Zest's network interceptor closes that gap. It patches the three network APIs and matches every outbound URL against the same blockedDomains + mode-based tracker list that the script blocker uses.
What gets blocked
Blocked requests are dropped rather than queued for replay (network calls are one-shot and time-sensitive; replaying a stale beacon after consent would create duplicate data). Each API fails cleanly in a way trackers handle gracefully:
| API | Blocked behavior |
|---|---|
fetch(url) | Resolves to Response with status 204 and empty body |
XMLHttpRequest | Fires error event with readyState=4, status=0 |
navigator.sendBeacon(url, data) | Returns false (spec's "not queued" signal) |
Example: HubSpot CMS
Zest.init({
blockedDomains: [
{ domain: 'track-eu1.hubspot.com', category: 'analytics' },
{ domain: 'app-eu1.hubspot.com', category: 'analytics' }
]
});HubSpot's scriptloader at /hs/scriptloader/{portalId}.js still loads (first-party path, not blockable by hostname) — but its runtime beacons to track-eu1.hubspot.com are now caught. No tracking data leaves the page before consent.
Opting out
Headless integrations that gate their own network calls can disable the interceptor:
Zest.init({
intercept: { network: false }
});Cookie & Storage Interception
Zest also intercepts cookie and storage operations:
How It Works
- Intercepts
document.cookiesetter operations - Proxies
localStorage.setItem()andsessionStorage.setItem() - Categorizes operations based on key name patterns
- Queues blocked operations
- Replays when consent is granted
Automatic Categorization
Keys are categorized by matching patterns:
Analytics patterns:
_ga, _gid, _gat, _utm, __utm, plausible, _pk_, matomo, _hj, ajs_Marketing patterns:
_fbp, _fbc, _gcl, _ttp, ads, doubleclick, __gads, __gpi, _pin_, li_Functional patterns:
lang, locale, theme, preferences, ui_Essential patterns (always allowed):
zest_, csrf, xsrf, session, __host-, __secure-Unknown keys default to the marketing category (strictest).
Custom Patterns
Add your own categorization patterns:
window.ZestConfig = {
patterns: {
analytics: [/^my_analytics_/, /^custom_ga/],
marketing: [/^my_ad_/, /^campaign_/],
functional: [/^user_pref_/]
}
};Script Execution Order
When consent is granted:
- External scripts are loaded in their original order
- Inline scripts execute after their dependencies load
- The
zest:consentevent fires after all scripts execute
Debugging
Inspect the current consent state and which categories have been granted:
// In browser console
console.log(Zest.getConsent());
// { essential: true, functional: false, analytics: false, marketing: false }
console.log(Zest.getConsentProof());
// { version, timestamp, categories }Subscribe to real-time changes as you test:
Zest.on('consent', (consent) => console.log('consented:', consent));
Zest.on('change', (consent) => console.log('changed:', consent));Changed in v2.0.0. Zest no longer writes the
data-blocked-srcattribute to blocked<script>tags (it was a DOM-tampering vector). If your previous debug snippet inspected that attribute, useZest.getConsentProof()orZest.on('consent', …)instead.
Best Practices
- Load Zest first - Before any tracking scripts in your HTML
- Use safe mode - Good balance of protection and compatibility
- Tag unknown scripts - Use
data-consent-categoryfor scripts Zest doesn't recognize - Test thoroughly - Check that your site works after consent is granted
- Allow essential scripts - Use
data-zest-allowfor critical third-party libraries
