A blocked tag left on the page is one line of code away from running
The usual way to hold a third-party script until the visitor consents is to leave it in the markup with type="text/plain" and swap the type back on accept. Nothing has executed, no request has gone out, and the DOM reads as compliant. It is also one assignment away from running, from any code on the page, at any time, with no decision involved. We build consent-gated tags as a registry instead: held until there is an answer, released only for the purposes that answer granted, and on withdrawal taken off the page and cleaned up after.
What parking actually guarantees#
A script element with a type the browser does not recognise is inert. That part is true, and it is the whole appeal: paste the vendor's snippet exactly where the vendor said to paste it, add one attribute, ship.
What it does not give you is the property the notice claims. "Inert right now" and "cannot run unless the visitor allows it" are different guarantees, and the gap between them is a single line:
document.querySelector('script[type="text/plain"]').type = "text/javascript";That line does not have to be malicious or even deliberate. It can be a consent library's own revive routine firing against a stale state, a tag manager container reactivating scripts it did not block, a "re-run third-party scripts after a route change" helper in a single-page app, or the next snippet someone pastes into the head. The refused vendor's code is sitting in the document of a visitor who refused it, and every one of those paths reaches it.
The visitor was told the vendor would not run. What was built is that the vendor is loaded but not started. Keeping the artefact one unrelated line away from execution is the loophole, and that nobody has flipped it yet is not a defence.
A tag is not queued work#
In consentcore we separate the two, because they fail in different directions.
Queued work is a one-shot side effect: it runs once and it is over.
const gate = new Gate(state);
gate.when("necessary", "session", () => startSession());
gate.when("statistics", "analytics", () => loadAnalytics());
gate.when("marketing", "pixel", () => loadPixel());
// later, when the visitor decides
gate.update(accept(["statistics"], options));Necessary work runs immediately and synchronously, so callers can rely on ordering. Nothing runs twice however many decisions arrive. Work whose category is withdrawn gets dropped rather than kept waiting, because holding a withdrawn callback in case the visitor changes their mind is the same loophole as the parked script, hidden in a closure instead of in the DOM.
A tag is not that shape. It keeps running after it starts, and consent can move under it: someone who accepted statistics can withdraw while the vendor's script is live on the page. A queue has nothing useful to say about that moment, because by then its job finished long ago. So tags are a registry. A tag stays registered whatever the current answer is, and what changes is whether it is on the page.
Hold, release, remove#
export type Tag = {
readonly id: string;
readonly purpose: Category;
readonly src?: string;
/** For vendors that hand over a block of code rather than a URL. */
readonly inline?: string;
readonly attributes?: Readonly<Record<string, string>>;
readonly cleanup?: () => void;
};const tags = new Tags(state);
tags.register({ id: "analytics", purpose: "statistics", src: "https://cdn.example/a.js" });
tags.register({
id: "pixel",
purpose: "marketing",
src: "https://cdn.example/p.js",
cleanup: () => { document.cookie = "_pxl=; Max-Age=0"; },
});
tags.update(accept(["statistics"], options)); // analytics goes on the page, the pixel does not
tags.update(withdraw(options, state)); // the analytics element is taken off itupdate takes a decision rather than a list of categories, so acceptance, a narrower custom answer and a withdrawal are all the same call. A later grant releases a removed tag again, which is a new answer to the same question, not a replay of the old one.
Ids are unique and a second registration under the same id throws DuplicateTag. The id is not decoration: it is how the element is found again when the answer changes.
Mounting behind a seam#
Releasing and revoking are expressed against a two-method interface, and the DOM implementation is one of its implementations rather than an assumption baked into the registry.
export type TagHost = {
mount(tag: Tag): void;
unmount(tag: Tag): void;
};
export function domHost(where: { document?: TagDocument; root?: TagRoot } = {}): TagHost {
// Looked up per call, not once: a module imported during server rendering
// has no document, and the same host has to work after hydration.
const owner = () =>
where.document ?? (globalThis.document as unknown as TagDocument | undefined);
return {
mount(tag) {
const document = owner();
const root = where.root ?? document?.head;
if (!document || !root) return;
const element = document.createElement("script");
element.setAttribute(TAG_ATTRIBUTE, tag.id);
element.setAttribute(PURPOSE_ATTRIBUTE, tag.purpose);
for (const [name, value] of Object.entries(tag.attributes ?? {})) {
element.setAttribute(name, value);
}
if (tag.src) element.setAttribute("src", tag.src);
if (tag.inline !== undefined) element.textContent = tag.inline;
root.append(element);
},
unmount(tag) {
const root = where.root ?? owner()?.head;
if (!root) return;
// Matched by reading the attribute rather than by building a selector:
// the id comes from a caller, and a selector would have to escape it.
for (const element of Array.from(root.querySelectorAll(`[${TAG_ATTRIBUTE}]`))) {
if (element.getAttribute(TAG_ATTRIBUTE) === tag.id) element.remove();
}
},
};
}Three details earn their place here. The document is resolved per call rather than captured at import, because a module loaded during server rendering has none and the same host must keep working after hydration, so with no document, mounting and unmounting are no-ops rather than a crash and server rendering stops being a special case the caller has to remember. Removal matches by reading data-consent-tag off each element instead of composing a selector, because the id is caller-supplied and a selector would have to escape it. And TagElement, TagRoot and TagDocument are named narrowly enough that a test, or a renderer that is not a browser, can stand in without a fake window.
Every mounted element carries data-consent-tag and data-consent-purpose. That makes the page inspectable: open devtools and you can see what is running and under which answer. It is a different kind of evidence from the consent log, where the log says what the visitor chose and the attributes say what the page did about it.
Removing the element is not undoing the tag#
Taking a script off the page stops nothing that already started. The cookie it wrote, the interval it set and whatever it hung on window all outlive the element. cleanup is where those are dealt with, and a registry without it is a registry that produces a tidy DOM and an untouched tracking cookie.
Cleanup is only as complete as what you know the vendor does, which is exactly the kind of knowledge that usually lives in prose. Declaring it as data is the cheaper option:
{ id: "acme-analytics", name: "Acme Analytics", purposes: ["audience"],
policy: "https://acme.example/privacy", cookies: ["_acme"], transfers: "US" },The cookie names in a vendor declaration and the cookie names in a cleanup function are the same fact written twice. Keeping both in one repository, diffed in the same review, is how you find out they have stopped agreeing.
The record has to run both ways#
export type TagEvent = {
readonly id: string;
readonly purpose: Category;
readonly action: "released" | "revoked";
readonly at: string;
};tags.history records releases and revocations alike. After a withdrawal the question is rarely "is it off the page now". It is "when did it come off, and was it ever on". A history of releases only cannot answer the second half, and the second half is the one that gets asked.
What it costs to run#
Every third-party tag has to be named, filed under a purpose and given a cleanup. Prose in the cookie policy becomes code, which is uncomfortable for the tag nobody can explain, and that discomfort is probably doing useful work.
Re-release re-executes. Removing a script element and appending a fresh one on a later grant means the vendor's code runs from the top again. That is correct under this model, but a vendor that assumes it initialises once per page load needs checking before you lean on it.
Answers expire. The storage layer treats a decision as good for thirteen months (CONSENT_MONTHS), after which the notice is due again and tags are held until it is answered. A page that quietly assumed a one-time accept behaves differently in the visitor's second year.
Inline tags stay awkward. The inline field exists for vendors who hand over a block of code rather than a URL, which means that code sits in your bundle instead of behind a src you can read in the network tab.
Close#
Parking a refused vendor as text/plain is a reasonable instinct, since keeping it nearby is what makes the accept path cheap. The cost is that the refusal path is not a refusal, only a pause that any line of code can end. A registry costs an id, a purpose and a cleanup per tag. What it buys is that "refused" means the vendor's code is not in the document, and "withdrawn" means it stopped being there at a time you can name.