Audit storage, not cookies

A third-party script can hold a stable identifier for every visitor without setting a cookie. Where it lives, why the write is synchronous, what to ask.

Three glass storage shelves hold a single orange identifier cube in the middle

Open the cookie list your consent banner publishes and count the third-party scripts on it. A script that stores nothing there can still hold a stable identifier for every visitor. It sits in web storage instead, the shelf most scripts reach for blocks the main thread, and they reach for it at the worst moments a page has.

A browser gives a script several places to keep an identifier across a page load. The four a script is likely to use are cookies, localStorage, sessionStorage and IndexedDB, and behind them sit the Cache API, the origin private file system, a service worker registration and window.name, each of which has been used for the same purpose at one time or another. A consent banner enumerates the first of those, and so does most of the vendor paperwork built from it. An inventory assembled that way covers a small fraction of the surface, and the shelves it skips are the ones a reviewer cannot see without opening the storage panel and knowing which database to look in.

Our own tag sits inside that blind spot. It sets no cookie: there is no document.cookie anywhere in the widget’s source, and every fetch it makes goes out with credentials omitted. The visitor identifier is a single IndexedDB entry, one per project. A reviewer auditing us by the cookie list would record that we store nothing on the visitor’s machine, and that conclusion would be wrong.

The shelf is what matters, not the presence of a cookie. IndexedDB is same-origin, so the entry lives in the customer’s own origin and cannot be read from anybody else’s site, while a third-party cookie is the opposite arrangement by design. What a script keeps on the device and what it sends off it are separate questions, and our answer to the second is set out under security.

Synchronous means the page cannot yield while it waits

localStorage and sessionStorage are synchronous by specification. The call returns the value, so the main thread stops until the browser has finished, and there is no point at which the page can hand the CPU back to itself. IndexedDB returns a request object instead, and the page keeps running while the answer is prepared.

That single difference decides what a slow disk does to a page. A slow synchronous read makes the page late. A slow asynchronous read makes the answer late, and an answer that is late can be abandoned on a deadline, which is a choice the synchronous version never offers.

There is a second trap a performance audit usually walks past. Reaching for localStorage is itself code that can throw: Safari with cookies blocked raises on the property access, before any method is called on it. An audit wrapper that patches only Storage.prototype.getItem never sees that access, and a script that reads the property outside a try block takes the host page down with it on browsers where the feature is switched off entirely.

The four moments a script reaches for storage

The cost of an access is the cost of the operation multiplied by how badly the moment was chosen, and there are four moments a third-party script is tempted. The last column is ours, and it is last on purpose: the first three are the ones a reviewer can apply to any vendor.

The momentWhy it costsWhat to require of any vendorWhat our contract does
Before the page has paintedCompetes with the host’s own first render, when the main thread is the scarcest thing on the pageNo storage access on the load path at allThe file served as widget.js performs no storage access of any kind
Inside the handler for an interaction somebody is waiting onThis is the window responsiveness metrics measure, so a blocking read lands directly in the scoreStorage access deferred out of input handlersThe session record is read once per document, and written on this path only to clear it, when an interaction arrives 30 minutes or more after the last one
On page hide or visibility changeThe browser is already trying to freeze the page, so the write extends the freeze and may be discarded anywayA beacon on the exit path, never a writeThe exit path hands the browser a beacon, and the same visibility check that gates every refresh closes the write path
Once per captured eventTurns an in-memory buffer into steady disk traffic that scales with how busy the page isA declared write cadence, not a write per eventThe refresh writes at most once per visible minute

Not one of those answers mentions us, which is the test each row had to pass. The tag those rows describe is the feedback widget, and the rows are the questions we had to answer before it could go on anybody else’s checkout page. The last row carries the rule the other three lean on: the busy path calls a method whose only job is to decide whether this minute has already been written, so a page that produces a thousand events cannot turn them into a thousand writes. Ours calls that method on every piece of evidence it records, and almost every call returns without touching the disk.

What to ask a vendor, and what we answer

Five questions are enough to tell a contract from a default. Which APIs does the script touch, what ceiling does it hold itself to per record, what time budget does a single operation get, how often does it write, and what does it do when storage throws.

The questionWhat our contract answers for the automatic session
Which APIIndexedDB for the visitor identifier, sessionStorage for the one per-tab session record, no cookie
Size ceiling per record128 characters, checked against the serialized string before the write is attempted
Time budget per operation2 ms, measured around the call itself
Write cadenceThe refresh writes at most once per visible minute, and never while the tab is hidden
On failure or overrunThat record is abandoned for the rest of the runtime epoch

Scoping the table to one path is the point of asking the question precisely, because the honest answer for most scripts is a list rather than a single API. Ours keeps two reporter preferences in localStorage: where a launcher was dragged to, behind a project setting that is off by default, and a reviewer’s name in review mode. A preference that has to survive the tab is the legitimate case for synchronous storage. The test to apply to any such exception is whether its read can land near an interaction somebody is waiting on. What a page carrying our tag gets is one write per deliberate gesture rather than one per event, on a path an admin has to switch on before anybody can reach it: the drag write happens in a pointer handler, which the table above names as a bad moment, and the setting that allows a launcher to be dragged ships off. A reviewer is entitled to weigh that trade themselves.

The 2 ms row is the one worth stealing. A budget nobody measures is a comment, so the widget times its own storage call and compares the elapsed value against the budget; an operation that runs long sets a flag that stops every later access to that record for the rest of the epoch. A test that hands the object a clock which advances 3 ms on read asserts that zero writes follow, which is what makes the number a contract rather than an intention.

When storage fails, decide once and stop asking

Storage failure is a normal condition rather than an error: private mode, a full quota, a hardened browser and a blocked property access are ordinary states of ordinary machines. A script that treats them as exceptions retries, and every retry lands in the same conditions that caused the first failure, on a page that has since become busier. Deciding once per page load and never asking again is the cheaper design and the more honest one.

Our automatic path resolves identity once per document, behind a 100 ms wall deadline. A timeout, a blocked database, private mode and a full quota are deliberately not distinguished: all four select an ephemeral identifier for the whole runtime epoch, with no retry and no switch of identity part way through a session. That epoch is the same unit the runtime uses to withdraw its other capabilities under load, which the host isolation contract sets out in full.

That choice has a price, and the visitor pays it in continuity. A visitor whose storage never answered is a new visitor every time, so some visits by the same person never join up and returning-visitor continuity is best-effort by design. The session record refreshes at most once per visible minute, which makes the 30 minute idle boundary precise to roughly a minute rather than to the second. Both are the same trade read from two directions: a script that writes less knows less, and the reviewer deciding what to allow onto a checkout page is entitled to know which way each vendor made that call.

Common questions

Does a cookie audit find every third-party identifier on a page?

No. Cookies are one of several places a script can keep an identifier across a page load, alongside localStorage, sessionStorage, IndexedDB, the Cache API and the origin private file system. A script using any of the others holds a stable identifier that never appears in a consent banner's cookie list.

Why is localStorage worse for performance than IndexedDB?

The difference is what the calling code can do while the browser answers. localStorage is synchronous by specification, so the main thread stops until the call returns. IndexedDB hands back a request object instead, which means a slow answer can be abandoned on a deadline rather than waited out.

When should a third-party script write to web storage?

After the page has painted, outside the handler for any interaction somebody is waiting on, and at a fixed cadence rather than once per captured event. The page-hide path is the worst choice of all, because the browser is already trying to freeze the page and a synchronous write extends that.

What should I ask a vendor about the storage their script uses?

Five things: which storage APIs it touches, what size ceiling it holds itself to per record, what time budget a single operation gets, how often it writes, and what the script does when storage throws. A vendor who cannot answer those inherited the behaviour rather than choosing it.

What happens to a third-party script when web storage is blocked?

That depends on whether the script treats the failure as an exception or as an operating mode. A script that retries will retry in private mode, on a full quota and on a busy page, which is the worst moment repeated. The better design decides once per page load and stops asking.

Does a script that avoids cookies still need consent?

Often, yes. European ePrivacy rules govern the storing of information on a visitor's device rather than the mechanism used, so choosing IndexedDB over a cookie rarely changes that analysis. Other regimes are drafted differently, and none of this is legal advice, but the storage API is a poor place to look for an exemption.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">