Storage
Local storage personalization
These are the keys this origin actually has, what they change, and the stores this site leaves empty. Consent off means the visitor and session records are not written and are not used to change the page.
Live result
This block uses the same rules as the home page. Edit a key below and it updates here without a reload. Consent is off, so those two keys are not read. The hero stays on the default.
Checking this browser…
localStorage
No localStorage keys on this origin.
site.consent.v1
Current value: absent (treated as denied). Size 0 B. The consent banner and the header switch write granted or denied. This page does not add another writer.
site.consent.choice.v1
Current value: absent (no banner choice saved yet). Size 0 B. JSON fields are choice, chosenAt, and version. A missing key with no site.consent.v1 value means the banner is still waiting. Once this version is saved, the banner stays closed until Privacy settings opens it.
sessionStorage
No sessionStorage keys in this tab.
Cookies
HttpOnly cookies are missing from document.cookie. The list below is what the server received with this page request. Reload after a consented beacon if bbp.visitor was just created. JavaScript can see: no cookie names.
No cookies were sent with this request. bbp.visitor appears after a consented Collection Lab beacon. Auth.js cookies appear after you start sign-in.
IndexedDB
Asking the browser for database names.
Quota
Waiting for navigator.storage.estimate().
Best practices
How this site chooses a store, and the limits that still apply. The snippets are the implementation, not a sample pasted in from somewhere else.
Which store
localStorage holds the returning-visitor record and the consent choice. Both should outlive the tab, and both are strings the page can read on the next visit. sessionStorage holds scroll, clicks, and pages for this tab only. MDN defines that difference: localStorage has no expiry, and sessionStorage lasts for the page session.
Cookies are for values the server must see on the next request. bbp.visitor is set with Set-Cookie so the collect route can filter rows. The Auth.js session cookie is the signed-in session. Neither is a personalization rule input.
IndexedDB is the right place for large structured data, files, and queries. This site has none of that, so it does not open a database. web.dev’s storage overview makes the same split: cookies for small values sent to the server, Web Storage for small client-only strings, IndexedDB when the data is bigger or structured.
export const VISITOR_KEY = "site.visitor.v1" export const SESSION_KEY = "site.session.v1" export const VISITOR_COOKIE = "bbp.visitor"
Nothing sensitive in Web Storage
localStorage and sessionStorage are readable by any script on the origin. A session token, OAuth secret, or email address does not belong there. The Auth.js session and CSRF cookies are HttpOnly, so the inventory can see that they exist and still hide the values. The visitor record stores visit counts, paths, and topic slugs. It does not store a name or an email.
httpOnly: true, sameSite: "lax", path: "/", secure, maxAge: 60 * 60 * 24 * 400
Names, versions, and a small migration
Keys are prefixed with site. or bbp. so they do not collide with another script on the same origin. The .v1 suffix is the schema. loadVisitor rejects a value that is not JSON or has no numeric visits field, and the caller treats that as “no record” instead of throwing. A future site.visitor.v2 would read v1, write v2, and delete v1. This site does not have a second version yet, so that migration is not implemented.
const parsed = JSON.parse(raw) as VisitorRecord if (!parsed || typeof parsed.visits !== "number") return null
TTL
Web Storage has no expiry argument. A TTL is a field you write yourself, then ignore the record when Date.now() passes it. site.visitor.v1 and site.consent.v1 do not have that field. site.consent.choice.v1 stores chosenAt, which is the time of the choice, not an expiry. They remain until they are deleted. site.session.v1 ends when the tab’s browsing context ends, which is the TTL sessionStorage already has.
Cookies do have an expiry. bbp.visitor is set with a 400-day Max-Age, inside the 400-day cap MDN documents for cookie lifetimes. The browser does not return Max-Age on later requests, so the table above quotes the setter. WebKit’s ITP 2.1 post describes a seven-day cap on cookies created with document.cookie. This site does not create its cookies that way.
Size and quota errors
MDN documents QuotaExceededError from setItem when the origin is over its quota. The usual localStorage ceiling is about 5 MB, and it is not a standard. saveVisitor and saveSession catch that failure and return false. The editors on this page say so instead of breaking the render. navigator.storage.estimate(), when the browser provides it, is the quota line above. web.dev describes that estimate as covering the origin’s storage, not one key.
try {
localStorage.setItem(VISITOR_KEY, JSON.stringify(record))
return true
} catch {
return false
}Safari ITP, partitioning, and eviction
Intelligent Tracking Prevention classifies domains that can track across sites and limits the storage those domains get. The original WebKit post describes deleting website data for classified domains after a stretch with no user interaction. A first-party site the visitor is using is not that case.
Partitioning is separate. MDN’s state-partitioning guide and WebKit’s third-party cookie post describe storage that is keyed by the top-level site as well as the frame’s origin. This page is top-level, so its localStorage is the origin’s own bucket. The same origin inside another site’s iframe can get a different bucket, and it can be evicted sooner. Do not assume an embedded frame sees the keys listed here.
Browsers may also clear storage under pressure. Quotas and eviction criteria on MDN are the reference. A missing key has to look the same as a first visit.
Consent
The ICO’s cookies guide treats storage used for a purpose the visitor did not ask for as something that needs consent. This site uses one choice for measurement and personalization. site.consent.v1 remembers granted or denied. site.consent.choice.v1 stores the same choice with chosenAt and version, which is what keeps the banner from returning. While consent is not granted, persistActivity does not write the visitor or session records, the collect route returns 403, and the rules do not read stored visit history. The editors above follow the same rule: save stays disabled, clear still deletes.
if (consentState === "granted") {
persistActivity({ path, topics, maxScroll, clicks, lastClick })
}Turning consent off does not wipe keys that were written earlier. It stops reading them for personalization. The inventory still lists them, because this page’s job is to show what is stored.
Signed-in sync
The localStorage record is not uploaded as a blob. A consented beacon already carries the client profile, and the server stores that profile under bbp.visitor. On sign-in, and again on the account page, claimVisitor attaches rows that have this visitor id and no account yet. It does not take rows that already belong to someone else. The account page is where that person reads and deletes the server copy. Clearing localStorage does not delete those rows.
.where(and(eq(collectEvents.visitorKey, visitorKey), isNull(collectEvents.userId)))
Hydration
The server cannot see localStorage. Reading it during the first client render would disagree with the HTML and React would warn. useSyncExternalStore takes a server snapshot: consent is denied, and the personalization snapshot is null. The home page renders the default copy and “Checking this browser…”. After hydration the client snapshot runs, storage is read, and the hero updates. That is a deliberate update after paint, not a mismatched hydrate. React’s useSyncExternalStore reference describes the server snapshot for this reason.
function getServerSnapshot(): ConsentState {
return "denied"
}When storage is missing
Private windows, blocked storage, and a thrown SecurityError are all the same outcome for personalization: loadVisitor and loadSession return null, and consent reads as denied if the read throws. The page still renders. Rules that needed a stored visit fall through to time of day, the URL, and the viewport, which do not need storage.
try {
const raw = localStorage.getItem(VISITOR_KEY)
if (!raw) return null
// ...
} catch {
return null
}Sources
- MDN: Window.localStorage
- MDN: Window.sessionStorage
- MDN: Document.cookie
- MDN: Using HTTP cookies
- MDN: IndexedDB API
- MDN: Storage quotas and eviction
- MDN: Storage.setItem
- MDN: State partitioning
- web.dev: Storage for the web
- web.dev: SameSite cookie recipes
- WebKit: Intelligent Tracking Prevention
- WebKit: Intelligent Tracking Prevention 2.1
- WebKit: Full third-party cookie blocking and more
- WebKit: Tracking prevention in WebKit
- React: useSyncExternalStore
- ICO: Cookies and similar technologies