Privacy and Storage Partitioning for PWAs¶
Privacy in a Progressive Web App is about what the device keeps and who can correlate it. A PWA stores more on the device than a classic site (app shell, API responses, drafts, push subscriptions), keeps it for longer, and runs code in the background, while browsers are steadily partitioning, capping and deleting that state to stop cross-site tracking. This page explains how storage partitioning works, including for service workers in third-party iframes, where third-party cookies stand in September 2026, how Safari's Intelligent Tracking Prevention treats PWA data, which PWA APIs add fingerprinting surface, and how to design analytics, retention, GDPR compliance and data deletion for an app that works offline.
Key takeaways
- Every major engine partitions third-party storage by top-level site: Chrome since 115, Firefox by default since 103, Safari for years. A service worker registered from an iframe on
a.exampleis a different registration from the same iframe onb.example, and it never controls your top-level app. - Chrome still allows third-party cookies by default. Google dropped the deprecation plan (July 2024), ruled out a user-choice prompt (April 2025) and retired most Privacy Sandbox APIs (October 2025), but Incognito, Safari and Firefox block or partition them. Build PWAs as first-party apps.
- Safari deletes all script-writable storage after 7 days of Safari use without interaction. Home Screen web apps are exempt for their own origin, and they have separate storage from Safari.
- Install state, display mode, storage estimates, permission states, window-controls geometry and push endpoints are all identifying signals. Don't collect them at a granularity that singles out a device.
- Under EU law, writing to or reading from the device (Cache Storage, IndexedDB and
localStorageincluded) needs consent unless it's strictly necessary for the service the user asked for. An offline app shell usually is. Analytics identifiers usually aren't. - Push subscriptions are personal data processed by a third-party push service. Document it, keep only what you need, and delete endpoints on
404/410, on sign-out and on account deletion. Clear-Site-Data: "storage"wipes storage and unregisters service workers (Safari 17+, Chrome 61+, Firefox 63+). Pair it with in-app deletion for data the user asks you to forget.
Why privacy is different for PWAs¶
| PWA feature | Privacy consequence |
|---|---|
| Cache Storage, IndexedDB, OPFS | Personal data (messages, documents, order history) lives on the device for weeks, readable by anyone with device access and by any script running on your origin. |
| Offline-first sync | Writes made offline sit in queues until the network returns, so deleted-on-server data may still exist on devices. |
| Push subscriptions | A long-lived identifier is sent to your server and held by a push service run by the browser vendor. |
| Installation | Standalone windows hide the address bar and the browser's "clear data" UI. Install state itself is a signal. |
| Background execution | Push, Background Sync and Periodic Sync run code, and send requests, when the user isn't looking at the app. |
| Separate installed contexts | On iOS, a Home Screen app and Safari are separate data stores, so "sign out" and "delete data" must happen in each. |
None of this is a reason to avoid PWAs. It's a reason to decide deliberately what you store, for how long, and how the user gets rid of it.
Storage keys and partitioning¶
Historically, web storage was keyed only by origin: a script from https://widget.example saw the same localStorage, IndexedDB and cookies whether it ran at top level or inside an iframe on any other site. That made every embedded third party a cross-site tracker. Browsers now key storage in third-party contexts by the top-level site as well.
The Storage Standard calls the key a storage key. Engines compose it slightly differently:
| Engine | Key for third-party contexts | Notes |
|---|---|---|
| Chromium (Chrome 115+) | Top-level site + frame origin + a "cross-site ancestor" bit | The bit "gets set if any document between the current iframe and the top-level site is from a different (cross-site) origin", so a.example > b.example > a.example doesn't get first-party storage |
| Firefox (State Partitioning, default from 103) | Frame origin + top-level site (scheme and registrable domain) | Mozilla calls this double-keying |
| WebKit | Frame origin + top-level site | Partitioned third-party storage is also ephemeral for some APIs |
For a first-party document (your PWA's own pages, its service worker, its installed window) the key is effectively just the origin. Partitioning only changes what happens when your origin is embedded somewhere else, or when you embed someone else.
flowchart LR
subgraph A["Top-level site: news.example"]
IA["iframe widget.example"] --> PA[("Partition: news.example + widget.example")]
end
subgraph B["Top-level site: shop.example"]
IB["iframe widget.example"] --> PB[("Partition: shop.example + widget.example")]
end
subgraph C["Top-level: widget.example"]
TC["widget.example itself"] --> PC[("First-party: widget.example")]
end The three boxes share nothing: IndexedDB, Cache Storage, service worker registrations and (in browsers that partition them) cookies are separate. That is the point: the widget can no longer recognize the same user on news.example and shop.example.
What is partitioned¶
| State | Chrome | Firefox | Safari |
|---|---|---|---|
localStorage | ✅ 115 | ✅ | ✅ |
sessionStorage | ✅ 115 | ✅ | ⚠️ |
| IndexedDB | ✅ 115 | ✅ | ✅ |
| Cache Storage | ✅ 115 | ✅ | ✅ |
| Origin Private File System | ✅ 115 | ⚠️ | ⚠️ |
| Service worker registrations | ✅ 115 | ✅ | ✅ |
BroadcastChannel, SharedWorker | ✅ 115 | ✅ | ⚠️ |
| Web Locks | ✅ 115 | ⚠️ | ⚠️ |
| Blob URLs | ✅ 137 (except top-level navigations) | ⚠️ | ⚠️ |
| Storage buckets | ✅ | n/a (no API) | n/a (no API) |
Quota for navigator.storage.estimate() | ✅ | ⚠️ | ⚠️ |
Clear-Site-Data from a third-party response | ✅ Clears only that partition | ⚠️ | ⚠️ |
| HTTP cache | ✅ 86 (network partitioning) | ✅ 85 | ✅ |
| Third-party cookies | ⚠️ Allowed by default; partitioned only with Partitioned (CHIPS) | ✅ Partitioned (Total Cookie Protection) | ❌ Blocked |
⚠️ Not listed in the vendor's partitioning documentation. Test rather than assume in either direction.
Support data as of September 2026. Chrome's list comes from the Privacy Sandbox storage partitioning page, Firefox's from MDN: State Partitioning, and Safari's from WebKit's tracking prevention documentation.
postMessage() between frames is not partitioned. It's the sanctioned way for an embedded frame to talk to its embedder, because both sides consent explicitly.
Firefox also partitions network state that can't be relaxed: the HTTP, image and favicon caches, DNS, connection pools, HSTS, TLS session identifiers, the back/forward cache and WebRTC device IDs. MDN notes that "Network Partitioning is permanent. Websites can't control or relax these restrictions."
Service workers in third-party iframes¶
A service worker registered from inside a cross-site iframe is partitioned like the rest of its storage. Chrome gives the reason: service workers registered from third-party contexts are partitioned to prevent timing-based information leaks. The consequences surprise teams that embed a PWA, or part of one, in other sites:
- Registration still works, and the worker controls the iframe's documents and subresources while embedded on that top-level site. It has its own Cache Storage and IndexedDB in the same partition.
- It is a different registration on every embedding site. A user who visits three publishers that embed your widget has three installations of your worker, each with its own caches and its own update cycle.
- It never controls your first-party pages. When the user visits
widget.exampledirectly, the first-party registration applies. The partitioned one is invisible from there, and vice versa. Precaching inside an embed doesn't warm the app cache. - Partitioned storage is not persistent across embedders, and in Safari it's ephemeral for some types, so treat caches as disposable.
- Push is effectively unavailable. Cross-origin iframes can't request notification permission in Chrome, Firefox or Safari, and a partitioned registration can't share the first-party subscription.
- The Storage Access API does not un-partition service workers. Chrome's
requestStorageAccess({ all: true })handle covers cookies, Web Storage, IndexedDB, Cache Storage, OPFS, locks, Blob URLs,BroadcastChannelandSharedWorker, but there's no service worker member. clients.matchAll()is partitioned too. The worker only sees clients in its own partition, so it can't message your top-level app windows.
The practical rule: an embed is not an app. Keep embeds light and network-first, and send users to your top-level origin (a link or a popup) for anything that needs your real service worker, push or durable offline data. Registration & Scope and Service Worker Security cover the first-party side.
// Runs inside widget.example when it may be embedded on other sites.
// Registers a *small* worker only at top level; embeds stay network-first.
const isTopLevel = window.top === window.self;
async function hasFirstPartyState() {
// hasStorageAccess() is true at top level and when an embed has been granted access.
if (!document.hasStorageAccess) return isTopLevel;
try {
return await document.hasStorageAccess();
} catch {
return false;
}
}
(async () => {
if (!("serviceWorker" in navigator)) return;
if (isTopLevel) {
// The real PWA: full offline support.
await navigator.serviceWorker.register("/sw.js", { type: "module" });
return;
}
// Embedded: a partitioned registration would be duplicated per embedding site,
// cost the user storage on each, and never help the real app. Skip it.
const firstParty = await hasFirstPartyState();
console.debug("Embedded; first-party storage access:", firstParty);
})();
Talking across partitions with postMessage¶
Because BroadcastChannel and SharedWorker are partitioned, an embed can't reach your app's other windows through them. Use postMessage() between the embed and its embedder, and validate both directions:
// Inside the iframe (widget.example). Only talk to embedders you know.
const ALLOWED_PARENTS = new Set(["https://news.example", "https://shop.example"]);
window.addEventListener("message", (event) => {
// Always check origin AND source: any window can postMessage to you.
if (!ALLOWED_PARENTS.has(event.origin) || event.source !== window.parent) return;
if (typeof event.data !== "object" || event.data?.type !== "theme") return;
document.documentElement.dataset.theme = event.data.value === "dark" ? "dark" : "light";
});
// Never use "*" as the target origin when the message contains anything private.
function notifyParent(message) {
const referrerOrigin = document.referrer ? new URL(document.referrer).origin : null;
if (referrerOrigin && ALLOWED_PARENTS.has(referrerOrigin)) {
window.parent.postMessage(message, referrerOrigin);
}
}
notifyParent({ type: "ready", height: document.documentElement.scrollHeight });
The Storage Access API¶
When an embed genuinely needs its first-party state (a signed-in comments widget, an embedded document editor), the Storage Access API lets it ask. It's available in Chrome 119, Firefox 65 and Safari 11.1, with the permission name storage-access.
document.hasStorageAccess()resolvestrueif the frame already has unpartitioned cookie access.document.requestStorageAccess()requests it. Requests are "automatically denied unless the embedded content is currently processing a user gesture such as a tap or click (transient activation), or unless permission was already granted previously." It rejects withNotAllowedErrorin an insecure context, when thestorage-accessPermissions Policy blocks it, with anullorigin, or in a sandboxed iframe withoutallow-storage-access-by-user-activation.- Chrome 125 added a
typesargument:requestStorageAccess({ indexedDB: true, caches: true })or{ all: true }resolves with aStorageAccessHandlewhose members (localStorage,sessionStorage,indexedDB,caches,getDirectory(),locks,estimate(),createObjectURL(),BroadcastChannel(),SharedWorker()) reach the unpartitioned state. Firefox and Safari grant cookies only. - Chrome 133 and Firefox 147 support the Storage Access Headers, which let a server use an existing grant without loading a document and running script first (see below).
// Inside a signed-in embed. Call from a click handler: the request needs transient activation.
export async function unlockFirstPartyStorage() {
if (!document.requestStorageAccess) return { cookies: true, handle: null }; // old engines: not partitioned
// hasStorageAccess() only reports *cookie* access. Even when it's true, call
// requestStorageAccess() again: with an existing grant it resolves without a prompt
// or user activation, and in Chrome 125+ that is the only way to get the handle.
const alreadyHasCookies = await document.hasStorageAccess().catch(() => false);
try {
// Chrome 125+: ask for exactly what's needed and get a handle to unpartitioned IndexedDB.
// Firefox and Safari ignore the argument and resolve with undefined.
const handle = await document.requestStorageAccess({ cookies: true, indexedDB: true });
return { cookies: true, handle: handle ?? null };
} catch (error) {
if (alreadyHasCookies) return { cookies: true, handle: null };
if (error instanceof TypeError || error?.name === "InvalidStateError") {
// Engines without the types argument: fall back to cookies only.
try {
await document.requestStorageAccess();
return { cookies: true, handle: null };
} catch {
return { cookies: false, handle: null };
}
}
return { cookies: false, handle: null }; // NotAllowedError: denied or policy-blocked
}
}
// Usage: openDB from the handle when present, otherwise from the partitioned window.indexedDB.
button.addEventListener("click", async () => {
const { cookies, handle } = await unlockFirstPartyStorage();
const idb = handle?.indexedDB ?? indexedDB;
// ...
});
Storage Access Headers: using a grant without script¶
Without headers, an embed that already holds a storage-access grant still loads twice: once without cookies, then again after its script calls requestStorageAccess(). The headers remove that round trip. Every credentialed cross-site request carries Sec-Fetch-Storage-Access with one of three values:
Sec-Fetch-Storage-Access | Meaning |
|---|---|
none | No storage-access grant for this embed on this top-level site |
inactive | A grant exists but isn't active in this context, so unpartitioned cookies were not sent |
active | The grant is active and unpartitioned cookies are included |
On inactive, the server can answer with Activate-Storage-Access: retry; allowed-origin="https://news.example" (or allowed-origin=*). The browser activates the grant and repeats the request with cookies. For an HTML document, Activate-Storage-Access: load tells the browser to load it with the grant already active, so the page's own script doesn't need to call requestStorageAccess() again. Add Vary: Sec-Fetch-Storage-Access to responses that differ by the header, or a shared cache (including a CDN) may serve a signed-in response to a request without cookies.
GET /embed/comments HTTP/1.1
Host: widget.example
Sec-Fetch-Storage-Access: inactive
HTTP/1.1 401 Unauthorized
Activate-Storage-Access: retry; allowed-origin="https://news.example"
Vary: Sec-Fetch-Storage-Access
Treat the header as a hint about cookies, not as authentication: check the session cookie itself before returning private data.
Chrome's requestStorageAccessFor() (a top-level variant) was part of Related Website Sets, which Google retired in October 2025. Don't build on it.
For testing and migration, Chrome offers a deprecation trial (its third iteration is named DisableThirdPartyStoragePartitioning3, and Google has extended it more than once), which lets a top-level site temporarily restore unpartitioned storage, service workers and communication APIs for the third parties it embeds, and the --disable-features=ThirdPartyStoragePartitioning command-line flag for local testing.
Third-party cookies in 2026¶
Third-party cookies matter to PWAs mainly for authentication across domains (an app on app.example calling an API on api.other.example with cookies), embedded features and analytics. The situation in September 2026:
Chrome¶
- January 4, 2024: Chrome began restricting third-party cookies for 1% of users as a test of "Tracking Protection", as a step toward removal.
- July 2024: Google announced it would not proceed with removal on a fixed deadline, proposing user choice instead.
- April 22, 2025: Google announced it would "maintain our current approach to offering users third-party cookie choice in Chrome" and "will not be rolling out a new standalone prompt for third-party cookies". Incognito mode "already blocks third-party cookies by default".
- October 17, 2025: Google retired most Privacy Sandbox technologies: Attribution Reporting, IP Protection, On-Device Personalization, Private Aggregation, Shared Storage, Protected Audience, Protected App Signals, Related Website Sets (including
requestStorageAccessForand Related Website Partition), SelectURL, SDK Runtime and Topics. It kept CHIPS and FedCM, which it said "have seen broad adoption", and Private State Tokens, and repeated that Chrome will "maintain our current approach to offering users third-party cookie choice in Chrome".
So in regular Chrome windows, third-party cookies work by default, users can block them in Settings > Privacy and security > Third-party cookies, and Incognito blocks them. Storage partitioning (above) is unaffected: it shipped independently in Chrome 115 and remains in force.
Safari and Firefox¶
- Safari blocks all third-party cookies by default. WebKit's March 2020 post announced that "Cookies for cross-site resources are now blocked by default across the board", and WebKit's documentation says the policy has no exceptions. The Storage Access API is the only way for an embed to get its cookies.
- Firefox partitions third-party cookies for all users through Total Cookie Protection (dynamic state partitioning, default since Firefox 103), and blocks known trackers' cookies with Enhanced Tracking Protection. Its storage access heuristics (automatic grants after popups and redirects with user interaction) are, in MDN's words, "a transitional feature meant to prevent website breakage".
CHIPS: partitioned cookies on purpose¶
A cookie with the Partitioned attribute (Cookies Having Independent Partitioned State) is stored per top-level site, like partitioned storage. It lets a legitimate embed keep session state without enabling cross-site tracking. Support: Chrome 114, Firefox 141, Safari 26.2.
Set-Cookie: __Host-widget_session=8f2c…; Secure; Path=/; SameSite=None; Partitioned; HttpOnly; Max-Age=86400
Partitioned requires Secure, and the __Host- prefix pins the cookie to the exact host with Path=/.
What this means for PWA architecture¶
| Pattern | Works in all engines? | Recommendation |
|---|---|---|
App and API on the same site (app.example, api.app.example) | ✅ Same-site cookies are first-party | Preferred: SameSite=Lax or Strict, HttpOnly, Secure |
App on app.example, API on api-vendor.example with cookies | ❌ Blocked in Safari, partitioned in Firefox, blocked in Chrome Incognito | Proxy the API through your origin, or use bearer tokens held in memory |
| Identity provider on another domain | ⚠️ Redirect flows work; iframe-based silent refresh breaks | Use top-level redirects, FedCM where supported, or passkeys. See Authentication & Passkeys |
| Embeddable widget | ⚠️ Partitioned storage, no push, no shared worker | CHIPS for session, Storage Access API for signed-in state |
| Cross-site analytics cookie | ❌ Blocked or partitioned in most browsers | First-party, cookieless measurement (below) |
An installed PWA is always the top-level document, so none of this limits your own pages. It limits everything you embed and every cross-site request you make.
Safari's Intelligent Tracking Prevention¶
Intelligent Tracking Prevention (ITP) goes further than cookie blocking. The rules that affect PWAs:
- 7-day cap on script-writable storage. ITP "deletes all cookies created in JavaScript and all other script-writeable storage after 7 days of no user interaction with the website": IndexedDB,
localStorage, media keys,sessionStorage, and service worker registrations and their caches. "Days" means days of Safari use, so a phone left in a drawer doesn't advance the clock. - Home Screen web apps are exempt for their own origin. "The first-party domain of home screen web applications is exempt from ITP's 7-day cap on all script-writeable storage." They keep their own counter of days of use, which the user resets by using the app.
- Link decoration. When a user arrives from a site ITP classifies as a tracker with query parameters or fragments in the URL, "ITP detects such link decoration and caps the expiry of cookies created in JavaScript on the landing webpage to 24 hours."
- CNAME and IP cloaking. If your first-party subdomain is a CNAME (or IP-level alias) for a third-party service, ITP caps cookies set in its HTTP responses to 7 days. Don't route analytics through a cloaked subdomain expecting durable cookies.
- Partitioned, sometimes ephemeral, third-party storage as described above.
For PWA design this means: keep sessions in server-set HttpOnly cookies on your own domain (not capped by the JavaScript-cookie rule), treat Safari-tab storage as a cache that can disappear after a week away, and treat the Home Screen app as the durable place for offline data on iOS. Never keep the only copy of user data on the device. Storage Quotas & Persistence covers quotas, eviction and persist() in depth.
Safari 26 fingerprinting protections¶
Safari 26 extended Advanced Fingerprinting Protection. WebKit's Safari 26.0 release notes state that it "prevents known fingerprinting scripts from reliably accessing web APIs that may reveal device characteristics, such as screen dimensions, hardware concurrency, the list of voices available through the SpeechSynthesis API", and additionally prevents those scripts from setting long-lived storage such as cookies or localStorage and from reading navigational tracking state such as query parameters and document.referrer. The protection targets scripts WebKit classifies as known fingerprinters. If an analytics or A/B-testing vendor lands on that list, it silently loses data in Safari.
iOS: separate storage for Home Screen web apps¶
On iOS and iPadOS, a Home Screen web app is a separate data store from Safari. WebKit: "the website data of home screen web applications is kept isolated from Safari". Cookies, Web Storage, IndexedDB, Cache Storage, OPFS, service worker registrations and permissions are all separate, and each additional copy of the app on the device is isolated from the others. Since iOS 17.2, cookies are copied from Safari when the user adds the app, so a cookie-based session carries over once; nothing else is copied, and the two stores diverge from then on. The same model applies to web apps added to the Dock on macOS. On iOS 26, every site added to the Home Screen opens as a web app by default, so this is now the common case.
The privacy consequences:
- Signing out in Safari doesn't sign out the web app, and vice versa. Revoke sessions server-side (short-lived tokens, a server session list the user can manage) rather than relying on the client to clear itself.
- Clearing Safari's website data doesn't clear the web app. Removing the app from the Home Screen does remove its data, permissions and push subscription.
- Two copies mean two push subscriptions, which your server must be able to list and delete individually.
iOS & iPadOS documents the storage model, quotas and eviction in detail.
The fingerprinting surface of PWA APIs¶
Fingerprinting combines many low-entropy signals into an identifier that survives cookie clearing. PWA capabilities add signals that ordinary pages don't have. Browsers mitigate some of them, but your own collection matters too: sending these values to your server at full precision, joined with an account, builds exactly the profile that privacy law and browser vendors object to.
| Signal | What it reveals | Browser mitigations | Guidance |
|---|---|---|---|
display-mode media query, navigator.standalone (Safari on iOS, iPadOS and macOS) | Whether the site is installed and how it's launched | None; it's a documented feature | Fine to record as a coarse dimension, not per user |
navigator.getInstalledRelatedApps() (Chromium) | Whether specific native or web apps are installed | Only apps that list your site: Android (Play apps from Chrome 80, web apps from 84), Windows apps from 85, and installed web apps on desktop since Chrome 140 | Use it to hide redundant install prompts, never to profile |
navigator.storage.estimate() | Quota, historically proportional to free disk | Current Chromium reports a predictable value (usage plus 10 GiB on most devices) instead of a disk-derived one | Bucket usage coarsely if you report it |
navigator.permissions.query() | The user's grants across features | States are per origin, so they don't cross sites | Don't upload a permission vector with identifiers |
Window Controls Overlay titlebarAreaRect | Title-bar geometry, which varies by OS, theme and scaling | None specific | Layout only |
getScreenDetails() | Monitor count, sizes, positions, labels | Behind the window-management permission | Only request when the feature needs it |
queryLocalFonts() | Installed fonts | Behind the local-fonts permission | As above |
hardwareConcurrency, deviceMemory, screen size | Device class | Firefox 145 protects the processor core count and screen-related values in its protected modes; Safari 26 limits known fingerprinting scripts | Don't collect raw values |
| Push subscription endpoint | A unique, long-lived URL for this browser install | Scoped to your origin and your VAPID key | Treat as a secret identifier, see below |
| Service worker and cache timing | Whether a resource was cached before | HTTP cache and storage partitioning block cross-site probing | Nothing to do beyond not caching cross-site secrets |
Firefox 145 (November 2025) added protections against these techniques (fonts, processor core count, simultaneous touch points, dock and taskbar dimensions, graphics details and math timing among them) that, according to Mozilla, "cut the percentage of users seen as unique by almost half". They started in Private Browsing and Enhanced Tracking Protection's Strict mode, with default-mode rollout planned later. Protections like these are why feature detection must never assume a value is exact.
The European Data Protection Board's Guidelines 2/2023 on the technical scope of Article 5(3) of the ePrivacy Directive, adopted in final form in October 2024, extend the consent rules beyond cookies to fingerprinting and other ways of reading information from the device. Reading hardwareConcurrency to build an identifier falls under the same rules as setting a tracking cookie.
A diagnostics payload that stays useful for support without identifying the device:
// Coarse, non-identifying environment info for support tickets and aggregate stats.
// Every value is bucketed or boolean; nothing is stable enough to fingerprint a device.
function bucketBytes(bytes) {
if (bytes == null) return "unknown";
const mib = bytes / 2 ** 20;
if (mib < 10) return "<10MiB";
if (mib < 100) return "10-100MiB";
if (mib < 1024) return "100MiB-1GiB";
return ">1GiB";
}
function displayMode() {
// Safari web apps first: on iOS a "standalone" manifest matches display-mode:
// fullscreen, and a web app without a manifest reports "browser".
if (navigator.standalone === true) return "standalone";
for (const mode of ["window-controls-overlay", "standalone", "minimal-ui", "fullscreen"]) {
if (matchMedia(`(display-mode: ${mode})`).matches) return mode;
}
return "browser";
}
export async function diagnostics() {
let usage = null;
try {
({ usage } = (await navigator.storage?.estimate?.()) ?? {});
} catch {
/* estimate() can reject in some private modes */
}
return {
appVersion: globalThis.APP_VERSION ?? "dev", // your build id, not the browser's
displayMode: displayMode(), // installed or not, coarse
swControlled: !!navigator.serviceWorker?.controller,
online: navigator.onLine,
storageUsage: bucketBytes(usage), // bucketed, never raw
// Deliberately omitted: user agent string, screen size, core count, memory,
// permission states, push endpoint, time zone.
};
}
Privacy-preserving analytics for PWAs¶
Analytics in a PWA has two problems a classic site doesn't: much of the usage happens offline, and the pages are served by a service worker, so server logs miss most of it. The Analytics for PWAs guide covers measurement design. From a privacy point of view, the goal is to answer product questions (are people installing, is offline mode used, which version is live) without creating a per-user profile.
Principles that hold up with regulators and browsers alike:
- Count events, not people. Most PWA questions are ratios: installs per visit, offline sessions per day, update adoption. They need no identifier at all.
- No client-side identifier without consent. A random ID in
localStorageis "storage of information on the terminal equipment" under Article 5(3). If you need unique counts, derive them server-side from a daily-rotating salted hash that you never store with the raw inputs, and check the approach with your privacy counsel. - First-party endpoint, no third-party scripts. Send beacons to your own origin. Third-party analytics scripts are what ITP, Enhanced Tracking Protection and Safari's fingerprinting protections target, and they widen your XSS and supply-chain surface (Service Worker Security).
- Coarse dimensions. Version, display mode, online/offline, country at most. No full user agent, no precise timestamps tied to other data, no URLs containing IDs or search terms.
- Offline events with a shelf life. Queue events while offline, but drop them after a few days instead of uploading a detailed history later.
- Honor opt-out signals. Firefox 120+ sends
Sec-GPC: 1and exposesnavigator.globalPrivacyControlwhen the user enables Tell websites not to sell or share my data. California's regulations treat Global Privacy Control as a valid opt-out of sale or sharing.
// Minimal first-party, cookieless analytics with an offline queue.
// - No identifiers, no cookies, no third-party script.
// - Events are aggregated per day on the client and flushed as counts.
// - Offline counts expire after MAX_AGE_DAYS instead of accumulating history.
const DB_NAME = "analytics";
const STORE = "counts";
const MAX_AGE_DAYS = 3;
const ENDPOINT = "/api/metrics"; // same origin
const optedOut = () =>
navigator.globalPrivacyControl === true || localStorage.getItem("analytics-opt-out") === "1";
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "key" });
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function dayStamp(date = new Date()) {
return date.toISOString().slice(0, 10); // UTC day: coarse on purpose
}
export async function count(event, dims = {}) {
if (optedOut()) return;
// Whitelist dimensions so nothing identifying sneaks in later.
const safe = {
v: String(dims.version ?? ""),
m: String(dims.displayMode ?? ""),
o: dims.online === false ? "0" : "1",
};
const key = `${dayStamp()}|${event}|${safe.v}|${safe.m}|${safe.o}`;
const db = await openDB();
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
const get = store.get(key);
get.onsuccess = () => {
const row = get.result ?? { key, day: dayStamp(), event, ...safe, n: 0 };
row.n += 1;
store.put(row);
};
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
db.close();
}
export async function flush() {
if (!navigator.onLine) return;
const db = await openDB();
const rows = await new Promise((resolve, reject) => {
const req = db.transaction(STORE).objectStore(STORE).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const cutoff = dayStamp(new Date(Date.now() - MAX_AGE_DAYS * 86_400_000));
const fresh = rows.filter((r) => r.day >= cutoff);
if (fresh.length) {
const body = JSON.stringify(fresh.map(({ key, ...r }) => r));
const bytes = new TextEncoder().encode(body).byteLength;
// keepalive lets the request outlive the page (like sendBeacon, but with a status code).
// In-flight keepalive bodies share a 64 KiB budget per page, so only use it for small batches.
const res = await fetch(ENDPOINT, {
method: "POST",
body,
keepalive: bytes < 60_000,
headers: { "Content-Type": "application/json" },
credentials: "omit", // no cookies: the endpoint must not link counts to sessions
}).catch(() => null);
if (!res?.ok) {
db.close();
return; // keep for the next attempt, the cutoff still applies
}
}
// Delete what was sent *and* what expired. A row counted again during the upload
// keeps only the increments that arrived after the snapshot.
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
for (const r of rows) {
const get = store.get(r.key);
get.onsuccess = () => {
const current = get.result;
if (!current) return;
if (current.n > r.n && r.day >= cutoff) {
store.put({ ...current, n: current.n - r.n });
} else {
store.delete(r.key);
}
};
}
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
db.close();
}
addEventListener("online", () => flush());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
Because the payload has no identifier, the server can't join it to accounts even by accident, and the server logs for /api/metrics should drop IP addresses or truncate them at ingestion. Background Sync can call the same flush() from the service worker in Chromium if you want uploads without an open window.
Data minimization for offline data¶
Article 5(1)© of the GDPR requires personal data to be "adequate, relevant and limited to what is necessary". Offline-first architectures push in the opposite direction, because caching more makes the app work better offline. Resolve the tension per data type:
| Data | Cache it? | Retention on device |
|---|---|---|
| App shell, static assets | Yes | Until the next deploy |
| Public content (articles, catalog) | Yes | Days to weeks, by freshness |
| User's own recent data (inbox, orders) | Only what the offline experience needs, for example the last 30 days | Rolling window, purged on launch |
| Drafts and pending writes | Yes, in IndexedDB | Until synced, then deleted |
| Sensitive categories (health, finance, messages from others) | Only if the user expects offline access; consider opt-in | Short, with an explicit "keep offline" control |
| Authentication tokens | No in Cache Storage; prefer HttpOnly cookies | Session length |
| Responses containing other people's personal data | Avoid unless essential | Short |
Techniques that make minimization automatic:
- Expire by design, not by eviction. Browser eviction is unpredictable, and Home Screen apps on iOS may never be evicted. Store an
expiresAtwith each record and purge on launch. - Namespace by user. Put user data in databases and caches named per account (
inbox-u123), so sign-out and account switching can delete exactly that user's data. The complete sign-out cleanup, includingClear-Site-Dataand broadcasting to other windows, is on the Service Worker Security page. - Don't cache what you can't delete. Opaque responses and third-party data you can't attribute to a user are hard to purge selectively.
- Mind backups. Some platforms include browser data in device backups. Anything on the device may exist in more places than the device.
// Rolling retention for offline user data in IndexedDB.
// Each record carries expiresAt; an index makes the purge a cheap range scan.
const DB = "app-data-v3";
export function openAppDB(userId) {
return new Promise((resolve, reject) => {
// One database per user makes account deletion a single deleteDatabase() call.
const req = indexedDB.open(`${DB}-${userId}`, 1);
req.onupgradeneeded = () => {
const store = req.result.createObjectStore("messages", { keyPath: "id" });
store.createIndex("expiresAt", "expiresAt");
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export function putWithTTL(db, record, ttlDays) {
return new Promise((resolve, reject) => {
const tx = db.transaction("messages", "readwrite");
tx.objectStore("messages").put({ ...record, expiresAt: Date.now() + ttlDays * 86_400_000 });
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
export function purgeExpired(db, now = Date.now()) {
return new Promise((resolve, reject) => {
const tx = db.transaction("messages", "readwrite");
const range = IDBKeyRange.upperBound(now);
let removed = 0;
const req = tx.objectStore("messages").index("expiresAt").openCursor(range);
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return;
cursor.delete();
removed += 1;
cursor.continue();
};
tx.oncomplete = () => resolve(removed);
tx.onerror = () => reject(tx.error);
});
}
// Run on every launch, before rendering cached data.
export async function onLaunch(userId) {
const db = await openAppDB(userId);
const removed = await purgeExpired(db);
if (removed) console.debug(`Purged ${removed} expired records`);
return db;
}
Apply the same idea to Cache Storage by storing the fetch time in a header of the cached response or in a small IndexedDB index, as Workbox's expiration plugin does (Advanced Workbox).
GDPR and ePrivacy considerations¶
This section is engineering guidance, not legal advice. It lists the questions a PWA raises so you can take them to your privacy counsel with the technical facts right.
Local storage and Article 5(3) of the ePrivacy Directive¶
Article 5(3) of the ePrivacy Directive requires consent for "the storing of information, or the gaining of access to information already stored, in the terminal equipment" of a user, unless it is "strictly necessary in order for the provider of an information society service explicitly requested by the subscriber or user to provide the service". It applies whether or not the data is personal, and it's not limited to cookies: the EDPB's Guidelines 2/2023 explicitly cover other storage and access mechanisms, including local processing and unique identifiers.
Mapping PWA storage to that test:
| Storage | Typical purpose | Strictly necessary? |
|---|---|---|
| Service worker + precache of the app shell | Loading the app the user opened, offline | Generally yes, for an app that promises offline use |
| Cache of content the user viewed, for offline reading | Feature the user relies on | Generally yes, when offline reading is part of the service |
| Drafts and outbox in IndexedDB | Not losing the user's work | Yes |
| Session cookie / token | Keeping the user signed in | Yes |
| UI preferences (theme, language) | Remembering a user choice | Usually yes, when the user set it |
| Consent record | Remembering the consent decision itself | Yes |
| Analytics client ID | Measuring audience | Generally no |
| A/B test bucket, personalization profile | Optimization | Generally no |
| Push subscription | Sending notifications the user opted into | The subscription follows the user's explicit opt-in through the browser prompt; document it |
Consent must come before the non-essential write, so defer analytics initialization until consent exists, including in the service worker. Store the consent decision locally so it's available offline, and sync it to the server when you can:
// Offline-capable consent state. Essential storage never waits for this;
// optional features check it before writing anything.
const KEY = "consent-v2"; // bump the version when the purposes change
export function getConsent() {
try {
const raw = localStorage.getItem(KEY);
return raw ? JSON.parse(raw) : null; // null = not asked yet
} catch {
return null;
}
}
export async function setConsent({ analytics, personalization }) {
const record = {
analytics: !!analytics,
personalization: !!personalization,
at: new Date().toISOString(),
version: KEY,
};
localStorage.setItem(KEY, JSON.stringify(record));
// Tell the service worker, which can't read localStorage.
navigator.serviceWorker?.controller?.postMessage({ type: "consent", record });
// Withdrawal must be as easy as giving consent, and must delete what was stored.
if (!record.analytics) {
await new Promise((resolve) => {
const req = indexedDB.deleteDatabase("analytics");
req.onsuccess = req.onerror = req.onblocked = () => resolve();
});
}
// Best effort; the local record is authoritative on this device.
fetch("/api/consent", {
method: "PUT",
body: JSON.stringify(record),
headers: { "Content-Type": "application/json" },
}).catch(() => {});
}
The European Commission's Digital Omnibus proposal (November 2025) would move the rules for personal data on terminal equipment into the GDPR. Until a final text is adopted and applies, Article 5(3) as implemented in each member state's law remains the rule, so check the proposal's status with your counsel before relying on it.
Push subscriptions are personal data¶
A PushSubscription contains an endpoint URL that uniquely identifies one browser installation's subscription, plus p256dh and auth keys. Once you store it against an account, it's personal data. What makes push different from other PWA data is the third party in the middle: the push service operated by the browser vendor (Google's FCM for Chrome, Mozilla's autopush for Firefox, Apple's service for Safari, Microsoft's WNS for Edge on Windows). The Web Push Protocol page lists the endpoint hosts.
- Payloads are end-to-end encrypted (RFC 8291), so the push service can't read message content. It does see metadata: the endpoint, your VAPID public key and contact claim, message size, timing, and the
TTL,UrgencyandTopicheaders. Never put personal data inTopicor in the VAPIDsubclaim. - The push service is a recipient of that metadata. Mention it in your privacy notice and records of processing, including any international transfer it implies.
- Minimize payloads anyway. Encryption protects transit, but the notification text appears on the lock screen. Offer a "hide content" option for sensitive apps: push a generic "New message" and fetch details after unlock.
- Delete endpoints promptly. Remove a subscription when the push service answers
404or410, when the user turns notifications off in your app, on sign-out if notifications are per-user, and on account deletion. Stale endpoints are personal data you no longer need. - One user, many endpoints. Multiple devices, multiple browsers and multiple iOS Home Screen copies each have their own subscription. Show users where they're subscribed and let them remove devices.
Data subject rights with data on the device¶
- Access and portability. Data that exists only on the device (drafts never synced, offline notes) isn't on your servers, but users still expect an export. An in-app "Export my data" that serializes IndexedDB to a file (see File System Access or a download link) covers it.
- Erasure. Deleting an account server-side doesn't touch devices. On the next launch after deletion, the app should receive
401/410, wipe local data and unsubscribe from push. For devices that never come back online, your server-side deletion is what counts, and the local data expires through the retention rules above. - Data protection by design (Article 25) and security of processing (Article 32) are where retention, per-user namespacing, no tokens in Cache Storage and CSP come in. Document them.
// Called after the server confirms account deletion (or answers 410 Gone for the account).
// Removes everything this app stored for the user on this device.
export async function forgetUserOnThisDevice(userId) {
// 1. Stop push first, so no notification arrives for a deleted account.
try {
const reg = await navigator.serviceWorker?.getRegistration();
const sub = await reg?.pushManager.getSubscription();
if (sub) await sub.unsubscribe();
} catch (error) {
console.warn("Push unsubscribe failed", error);
}
// 2. User-namespaced caches and databases.
const cacheNames = await caches.keys();
await Promise.all(cacheNames.filter((n) => n.includes(`-u${userId}`)).map((n) => caches.delete(n)));
const dbs = indexedDB.databases ? await indexedDB.databases() : [];
await Promise.all(
dbs
.filter((d) => d.name?.endsWith(`-${userId}`))
.map(
(d) =>
new Promise((resolve) => {
const req = indexedDB.deleteDatabase(d.name);
req.onsuccess = req.onerror = req.onblocked = () => resolve();
}),
),
);
// 3. OPFS files for this user.
try {
const root = await navigator.storage.getDirectory();
await root.removeEntry(`user-${userId}`, { recursive: true });
} catch {
/* not present or OPFS unsupported */
}
// 4. Small per-user keys in Web Storage.
for (const key of Object.keys(localStorage)) {
if (key.startsWith(`u${userId}:`)) localStorage.removeItem(key);
}
sessionStorage.clear();
// 5. Tell other windows and the service worker to drop in-memory state.
const channel = new BroadcastChannel("auth");
channel.postMessage({ type: "forgotten", userId });
channel.close(); // an open channel keeps the page out of the back/forward cache
}
Clearing data¶
Clear-Site-Data¶
The Clear-Site-Data response header lets your server tell the browser to delete state for the response's origin. It's only honored over HTTPS, and directives must be quoted strings.
| Directive | Clears | Chrome | Firefox | Safari |
|---|---|---|---|---|
"cookies" | All cookies for the registrable domain (subdomains included) and HTTP auth credentials | ✅ 61 | ✅ 63 | ✅ 17 |
"storage" | The origin's DOM-accessible storage (localStorage, sessionStorage, IndexedDB and similar) and service worker registrations | ✅ 61 | ✅ 63 | ✅ 17 |
"cache" | The HTTP cache for the origin, and depending on the browser the back/forward cache, prerendered pages and script caches | ⚠️ | ✅ 138 | ✅ 17 |
"clientHints" | Stored Accept-CH client hints | ✅ 117 | ❌ | ❌ |
"executionContexts" | Reloads the origin's browsing contexts | ❌ | ❌ (removed in 68) | ❌ (removed in 18.3) |
"prefetchCache", "prerenderCache" | Speculation-rules prefetches and prerenders | ✅ 138 | ❌ | ❌ |
"*" | Everything, including future types | ⚠️ | ✅ 63 | ✅ 17 |
⚠️ MDN's compatibility data notes that in Chromium, "cache" (and therefore "*") "may cause seconds-long hangs", and some requests may still be served from the cache until the tab is reloaded.
Support data as of September 2026. See MDN: Clear-Site-Data for live data.
HTTP/1.1 200 OK
Clear-Site-Data: "cookies", "storage"
Cache-Control: no-store
Content-Type: application/json
Two details specific to PWAs:
- The header is processed only on network responses. The specification is explicit: "It is imperative that the Clear-Site-Data header is only respected on responses fetched over network, and not those served by a service worker." A response the worker builds or serves from Cache Storage is ignored, so make sure the sign-out endpoint always goes to the network (and service worker script updates, being network responses, can carry the header as a kill switch).
"storage"unregisters your service worker. The app loses offline support until the next visit reinstalls it. If you need cached personal data gone with certainty, also delete the relevant caches from script before the request, as shown in the account-deletion code above. That's right for account deletion and sign-out on shared devices, and too blunt for routine sign-out on a personal device. For that case, use targeted client-side deletion by user namespace and keep the app shell.
Programmatic clearing¶
Script can delete everything it can enumerate:
| Store | Enumerate | Delete |
|---|---|---|
| Cache Storage | caches.keys() | caches.delete(name) |
| IndexedDB | indexedDB.databases() (Chrome 72, Firefox 126, Safari 14) | indexedDB.deleteDatabase(name) |
| OPFS | navigator.storage.getDirectory() then iterate | removeEntry(name, { recursive: true }) |
| Web Storage | Object.keys(localStorage) | localStorage.clear() |
| Service worker | navigator.serviceWorker.getRegistrations() | registration.unregister() |
| Push | registration.pushManager.getSubscription() | subscription.unsubscribe() |
| Storage buckets (Chromium) | navigator.storageBuckets.keys() | navigator.storageBuckets.delete(name) |
HttpOnly cookies | Not visible to script | Server Set-Cookie with Max-Age=0, or Clear-Site-Data: "cookies" |
indexedDB.deleteDatabase() fires blocked while another tab holds a connection open. Handle versionchange in every tab by closing the connection, or deletions stall until the other tabs close. IndexedDB shows the pattern.
What users can clear, and what survives¶
| Action | Effect |
|---|---|
| Chrome/Edge: clear site data from site settings or Delete browsing data | Removes storage, caches, service worker and cookies for the site. Permissions are managed separately |
| Chrome desktop: uninstall an app with "Also clear data from Chrome" | Removes the site's data. A Chromium issue reports that permissions haven't always been reset |
| Android: uninstall a WebAPK | The data lives in the browser's profile for the origin, not in the WebAPK package. Clear it from the browser's site settings |
| Firefox: Settings > Privacy & Security > Cookies and Site Data > Manage Data | Per-site removal of cookies and storage |
| Safari macOS: Settings > Privacy > Manage Website Data | Removes Safari's data for the site, not a Dock web app's |
| iOS: remove a Home Screen web app | Removes that app's storage, permissions and push subscription |
| iOS: Settings > Apps > Safari > Advanced > Website Data | Removes Safari's data, not Home Screen web apps' |
| Private or Incognito window closed | Everything from that session |
| ITP 7-day cap (Safari tabs) | Script-writable storage and service worker after 7 days of Safari use without interaction |
Your app must survive all of these. On launch, detect "no local data but a valid session" and resync; detect "local data but no session" and wipe it.
Browser support¶
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| Third-party storage partitioning (incl. service workers) | ✅ 115 | ✅ 103 (default) | ✅ |
| Third-party cookies by default | ✅ Allowed (blocked in Incognito) | ⚠️ Partitioned | ❌ Blocked |
Partitioned cookies (CHIPS) | ✅ 114 | ✅ 141 | ✅ 26.2 |
requestStorageAccess() | ✅ 119 | ✅ 65 | ✅ 11.1 |
requestStorageAccess(types) / StorageAccessHandle | ✅ 125 | ❌ | ❌ |
| Storage Access Headers | ✅ 133 | ✅ 147 | ❌ |
Clear-Site-Data | ✅ 61 | ✅ 63 | ✅ 17 |
indexedDB.databases() | ✅ 72 | ✅ 126 | ✅ 14 |
Global Privacy Control (Sec-GPC) | ❌ | ✅ 120 (opt-in) | ❌ |
| 7-day cap on script-writable storage | ❌ | ❌ | ✅ (Home Screen apps exempt) |
Support data as of September 2026. For live data, see MDN: Storage Access API, MDN: Clear-Site-Data and caniuse.
Common pitfalls¶
- Expecting an embedded PWA to share the app's service worker. Partitioning gives every embedding site its own registration and caches, and none of them helps your top-level app.
- Relying on third-party cookies because "Chrome still has them". Safari blocks them, Firefox partitions them, Chrome Incognito blocks them, and Chrome users can block them in settings.
- Keeping sessions in
localStoragein Safari tabs. The 7-day cap can delete them, and on iOS the Home Screen app won't see them. UseHttpOnlycookies on your own domain. - Routing analytics through a CNAME-cloaked subdomain. ITP caps its cookies at 7 days, and consent rules still apply.
- Uploading raw device characteristics. Screen size, core count, permissions and install state joined to an account form a fingerprint and fall under Article 5(3).
- Forgetting push subscriptions on account deletion. The endpoint is personal data and the device keeps receiving pushes until you unsubscribe.
- Putting personal data in the push
Topicheader or VAPIDsubclaim. The push service sees both in clear. - Serving the sign-out response from the service worker cache.
Clear-Site-Dataonly works on network responses. - Using
Clear-Site-Data: "*"for routine sign-out. It unregisters the worker and can hang Chromium. Use"cookies", "storage"for shared-device sign-out and targeted deletion otherwise. - Assuming clearing Safari data resets the iOS web app. It doesn't; they're separate stores.
Debugging¶
- Chrome DevTools > Application. The Storage section shows usage for the inspected origin, and Clear site data clears storage, caches and the service worker at once. The Cookies view has a partition key column for
Partitionedcookies. The Issues panel reports blocked third-party cookies. - Simulating partitioning changes. Run Chrome with
--disable-features=ThirdPartyStoragePartitioningto compare behavior, or block third-party cookies inchrome://settings/cookies. - Firefox. about:preferences#privacy switches Enhanced Tracking Protection to Strict, and the Storage Inspector (Shift+F9) lists cookies, Cache Storage, IndexedDB and Web Storage for the page.
- Safari. Develop > Show Web Inspector > Storage. To test the 7-day cap, use a test device and the Web Inspector's storage view; you can't fast-forward ITP's clock, so test the "data vanished" code path by clearing storage manually.
- Verifying
Clear-Site-Data. In the Network panel, confirm the response came from the network (not "(ServiceWorker)"), then check Application > Storage and Service workers.
Further reading¶
On this site
- Storage Quotas & Persistence: quotas, eviction,
persist()and the storage model - iOS & iPadOS: Home Screen app storage isolation in depth
- Service Worker Security: sign-out cleanup, cache poisoning and kill switches
- Permissions: the Permissions API, prompts and Permissions Policy
- Web Push Protocol: what push services see and how payloads are encrypted
- Analytics for PWAs: measuring installs, offline use and updates
- Authentication & Passkeys: first-party sign-in without third-party cookies
- Security & Privacy: threat model and baseline headers
External references
- Storage Standard (WHATWG)
- Privacy Sandbox: Storage partitioning
- Privacy Sandbox: Update on plans for Privacy Sandbox technologies (October 2025)
- MDN: State Partitioning
- MDN: Storage Access API
- WebKit: Tracking Prevention in WebKit
- WebKit: Full Third-Party Cookie Blocking and More
- WebKit: Updates to Storage Policy
- MDN: Clear-Site-Data
- EDPB Guidelines 2/2023 on the technical scope of Art. 5(3) ePrivacy Directive
- RFC 8291: Message Encryption for Web Push