Consent state in a single page application has to survive client-side navigation, live in exactly one authoritative place, and notify every consumer the moment it changes. Where these apps fail an audit is almost always the same spot: a stale copy of the consent object still telling an analytics script it may fire, long after the visitor withdrew permission.
The legal rule did not change with the architecture. Article 5(3) of the ePrivacy Directive requires informed consent before non-necessary information is stored on or read from a visitor's device, and it says nothing about how many HTTP documents your application serves. A router that never triggers a page load does not create an exemption.
Key Takeaways
Consent state must be persisted outside component memory: an in-memory store alone loses the decision on reload and cannot be read by server-rendered routes.
Safari deletes cookies written with JavaScript, plus
localStorage,sessionStorageand IndexedDB, after seven days without user interaction with the site.The French data protection authority (CNIL) fined AMERICAN EXPRESS CARTE FRANCE EUR 1.5 million in November 2025, partly because previously placed cookies kept being read after users withdrew their consent.
Client-side route changes do not re-run boot-time blocking logic, so lazily imported chunks and widgets mounted on later routes need an explicit consent check.
Honouring a withdrawal means tearing down running scripts and deleting the cookies they set, and a full page reload is often the only reliable way to unload a third-party tag that is already executing.
Why Does Consent State Break in a Single Page Application?
Because the reset that multi-page sites get for free never happens. On a traditional site, every navigation reboots the whole consent pipeline: the browser requests a new document, the consent cookie travels with that request, the server can act on it, and the client-side blocking logic runs again from scratch. Client-side routing removes all of that. The consent record is read once during boot, copied into whatever store the framework uses, and then treated as a constant for the rest of the session. Anything mounted later inherits the value the application believed at start-up.
Consent state is the durable record of which cookie categories a visitor has permitted, together with the timestamp and the version of the purposes they were shown. Everything else in the app is a derived copy.
Count the copies in a typical build and the problem becomes obvious. There is the consent management platform's own internal state, the cookie or storage entry it writes, the copy your Redux, Pinia or signals store holds for rendering, the values pushed into window.dataLayer for tag management, and whatever each third-party software development kit cached when it initialised. Five copies, one of them authoritative, and no reload to resynchronise them.
Drift between those copies is what regulators actually find during an inspection.
Where Should Consent State Live in a Single Page Application?
In a cookie your own server can read, mirrored into memory for rendering. That combination is the only one that satisfies both audiences at once: the browser needs a fast synchronous read on every route change, and the server needs to know the decision before it streams a server-rendered route that might contain a third-party embed. Storing consent only in a client-side store means server-rendered HTML is generated blind. Storing it only in localStorage means the server cannot see it at all, because storage entries never travel with a request.
| Storage location | Survives a reload | Readable by the server | Safari ITP treatment | Sensible use |
|---|---|---|---|---|
document.cookie | Yes | Yes, on the next request | Deleted after seven days without interaction | Client-only apps with short consent lifetimes |
Set-Cookie from your own origin | Yes | Yes, on every request | Not covered by the script-writable cap | Server-rendered routes and long consent lifetimes |
localStorage | Yes | No | Deleted after seven days without interaction | A fast mirror of the cookie, never the only copy |
sessionStorage | No, cleared with the tab | No | Deleted with the tab | Not suitable for consent |
| In-memory store (Redux, Pinia, signals) | No | No | Not applicable | The working copy the interface renders from |
| Account record on your backend | Yes | Yes | Not applicable | Logged-in products and multi-device sync |
The Safari column is the one that surprises teams. According to WebKit's tracking prevention documentation, Intelligent Tracking Prevention deletes all cookies created in JavaScript and all other script-writable storage after seven days of no user interaction with the website, and that list explicitly covers IndexedDB, localStorage, sessionStorage, media keys and service worker registrations. Moving consent out of a cookie and into local storage does not escape the cap. It also does not escape the law: the European Data Protection Board confirmed in Guidelines 2/2023 on the technical scope of Article 5(3), adopted in October 2024, that the provision is technology-neutral and applies to storage and access operations regardless of the mechanism used.
Writing the consent record from your own server with a Set-Cookie header sidesteps the script-writable cap entirely, which is why HTTP consent cookies are worth the plumbing on any app with meaningful Safari traffic.
How Do You Keep One Source of Truth Across Router, Store and Tag Manager?
Expose consent through a single module that owns the read, owns the write, and lets everything else subscribe. No component should call document.cookie directly, and no component should hold its own long-lived boolean derived from consent. Subscribers receive the current value immediately on registration and every subsequent change, which removes the class of bug where a component mounted before the visitor decided keeps rendering with the pre-consent value forever.
const listeners = new Set();
let state = readStoredConsent();
export function getConsent() {
return state;
}
export function onConsentChange(handler) {
listeners.add(handler);
handler(state);
return () => listeners.delete(handler);
}
export function applyConsent(next) {
state = next;
persistConsent(next);
window.dataLayer.push({ event: 'consent_change', consent: next });
listeners.forEach((handler) => handler(next));
}
Where a consent management platform is already in place, that module should wrap the platform's own events rather than duplicating them. Most platforms expose a consent callback API that fires on initialisation and on every update, and wrapping it keeps a single subscription surface for the rest of the codebase.
Tag management deserves its own note. Google Consent Mode v2 holds consent signals inside the Google tag, separate from your application state, and it only learns about a change when you send one. Calling gtag('consent', 'update', {...}) from inside applyConsent keeps those two in step. Skipping that call leaves the tag operating on the default state you declared at boot.
What Should Happen on a Client-Side Route Change?
Re-evaluate before anything fires, and do not re-open the banner. A route change is not a new consent event, so a visitor who has already decided should never see the dialogue again simply because they clicked a link. What the route change should trigger is a fresh read of the consent module before any virtual pageview, any lazily imported analytics chunk, and any embed the new route mounts.
router.afterEach((to) => {
const consent = getConsent();
if (!consent.analytics) return;
window.dataLayer.push({
event: 'virtual_pageview',
page_path: to.fullPath
});
});
Three route-level cases account for most leaks. Lazily imported route chunks can pull in their own vendor scripts, so code splitting must not become a way of smuggling a tag past the boot-time check. Iframe embeds such as video players and maps set cookies the moment they mount, which is why they need a placeholder until the relevant category is granted. Third-party widgets initialised inside a component's mount hook run on every route that mounts them, not once per session.
The framework-specific mechanics differ, but the shape is identical whether you use React Router, Vue Router or the Angular router. The general patterns for gating vendor code are covered separately in the guide to conditionally loading third-party scripts.
How Do You Handle Consent Withdrawal Mid-Session?
By actually stopping the processing, not by flipping a flag. This is the single most expensive mistake in the category. In its decision of 27 November 2025, the CNIL fined AMERICAN EXPRESS CARTE FRANCE EUR 1.5 million for breaches of Article 82 of the French Data Protection Act, and one of the three findings was that cookies placed before a withdrawal continued to be read afterwards. Recording the withdrawal correctly in the consent management platform did not help, because the tracking carried on regardless.
A single page application makes this harder than a multi-page site, for a structural reason: once a vendor script has been evaluated, it owns timers, event listeners, closures and often a service worker. Removing the <script> element does not unload any of it.
A withdrawal handler that holds up needs to do five things in order:
Update the stored consent record and broadcast the change to every subscriber.
Call the vendor's own opt-out hook where one exists, such as the Google tag consent update or a software development kit's
shutdownmethod.Delete the cookies that category set on your own domain, matching the exact path and domain attributes used when they were written.
Unmount iframes and widgets belonging to the withdrawn category and restore their placeholders.
Reload the document when any vendor in that category cannot be shut down through an API.
That last step reads as a defeat and is not one. A reload is cheap, it is invisible if you restore the route afterwards, and it is the only guarantee that nothing from the previous consent state is still executing. Users who click reject all in a fresh session get the same outcome without the reload, since rejecting everything simply means the vendor code never runs.
How Do You Keep Consent Consistent Across Browser Tabs?
Listen for changes made elsewhere and re-read on resume. A visitor with your dashboard open in three tabs who withdraws consent in one of them has withdrawn it for the origin, not for that tab. The other two are still holding a stale in-memory copy and will keep firing until something tells them otherwise. Two browser primitives cover this: BroadcastChannel for direct messaging between same-origin contexts, and the storage event, which fires in every other tab when a storage key changes.
const channel = new BroadcastChannel('consent');
channel.onmessage = (event) => hydrateConsent(event.data);
window.addEventListener('storage', (event) => {
if (event.key === 'consent_state' && event.newValue) {
hydrateConsent(JSON.parse(event.newValue));
}
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) revalidateConsent();
});
The pageshow listener handles the back/forward cache. When a browser restores a page from that cache, the JavaScript heap comes back exactly as it was, so a consent object from twenty minutes ago returns intact with no boot sequence to correct it. Checking event.persisted and revalidating catches it.
Syncing the same decision between a phone and a laptop is a different problem with different legal conditions, covered in the guide to cross-device consent sync.
How Long Should the Application Trust a Stored Decision?
Long enough to avoid nagging, short enough to stay valid, and never longer than the stored record itself. The CNIL treats six months as good practice for remembering a refusal so the visitor is not asked again, and applies a thirteen-month ceiling to the validity of tracker consent. Safari's seven-day cap on script-writable storage can undercut both, which is how apps end up re-prompting the same Safari user every week while believing they hold a thirteen-month consent.
Long-lived tabs add their own wrinkle. A dashboard left open for four days is running on an in-memory consent object that may have outlived the cookie backing it, so expiry has to be checked on resume rather than only at boot. Tie the check to visibilitychange and treat an expired record as no consent.
As of July 2026, the rules themselves are moving. The European Commission's Digital Omnibus proposal, published on 19 November 2025, would relocate cookie consent into the General Data Protection Regulation through new Articles 88a and 88b, the second of which would make browser-level consent signals binding on websites. The Council's position paper of 18 June 2026 removed Article 88b, the European Parliament has not yet taken a position, and negotiations continue. Any consent module built now should be able to accept a signal it did not render a banner for.
What Should You Test Before Shipping?
Navigate the app the way a real visitor would, with the network panel filtered to third-party domains and the application panel open on cookies and storage. The checks that catch real defects are the ones that cross a boundary the boot sequence never crosses.
Load the app, decline everything, then visit five routes including at least one lazily loaded chunk. No new third-party cookies should appear.
Accept, navigate, then withdraw. Confirm the network requests stop and the category's cookies disappear.
Open two tabs, change consent in one, and confirm the second stops firing without a manual refresh.
Navigate away and press the back button to confirm the restored page revalidates instead of resuming its old state.
Repeat the first check in Safari, since its storage behaviour differs from Chromium's.
Automating the first two in an end-to-end suite is worth the effort, because consent regressions arrive through dependency updates rather than through deliberate changes. A repeatable banner verification process catches what a manual pass on release day will not.
Frequently Asked Questions
Do I need to show the cookie banner again on every route change in my SPA?
No. A route change is not a new consent event, and re-opening the dialogue after a visitor has already chosen is closer to a dark pattern than to compliance. Re-read the stored consent state on each route change, but only display the banner when no valid record exists.
Can I store cookie consent in localStorage instead of a cookie?
You can, but it is rarely the better choice. Storage entries are not sent with HTTP requests, so a server-rendered route cannot see the decision, and Safari deletes script-writable storage after seven days of inactivity just as it deletes JavaScript cookies.
Why do analytics scripts keep firing after a user withdraws consent?
Because the script is already running. Updating the consent record does not stop timers, listeners or pending requests created by code that has already been evaluated, so a withdrawal handler has to call the vendor's opt-out method, delete the cookies it set, and reload the page when no opt-out method exists.
Is reloading the page after consent withdrawal bad practice?
No, and it is often the only reliable option. Restore the current route after the reload so the visitor keeps their place, and treat the reload as a guarantee that no previously permitted vendor code survives the change.
How do I stop my app re-prompting Safari users every week?
Write the consent record from your own server using a Set-Cookie response header rather than from JavaScript. Intelligent Tracking Prevention's seven-day deletion applies to script-writable storage, so a server-set first-party cookie is not subject to the same cap.
Does Google Consent Mode handle SPA route changes automatically?
No. The Google tag keeps its own consent signals and only updates when your code sends an update call, and virtual pageviews on client-side routes have to be pushed explicitly. Wire both into the same module that owns your consent state.
Check What Your App Actually Stores
Consent state bugs are invisible from the outside, which is why they survive so long in production. Kukie.io scans a site for the cookies and storage it sets, blocks non-necessary scripts until consent is recorded, and exposes an event API your consent management platform integration can subscribe to instead of polling.
