The high-score block on Neon Snake was four lines: read the key, compare two numbers, write the larger one, return it. It was three lines too long. In a private window on one browser the write threw, and on another it succeeded and the value was gone by the time the tab closed. Both behaviours are correct for an API most people treat as a dictionary.
What follows is what the thing actually promises, measured against a game that writes about forty bytes a run and reads them once at startup. The distance between a dictionary and this API is the distance between storing a score and storing one without taking the page down with it.
Three properties decide the design. It is synchronous, it is about 5 MB, and every value in it is a string.
A synchronous call sitting inside the frame budget
Every method on the storage object returns its value directly. There is no promise and no callback, so the work happens on whichever thread made the call, and in a browser game that is the thread that draws the frame. Google's web.dev guidance on storage says it plainly: localStorage "should be avoided because it is synchronous and will block the main thread".
The cost scales with the size of the value, which makes the safe pattern obvious once stated. A best score is four or five bytes. Read it once at startup into a variable, compare runs against that variable, and write only when a run ends. One blocking call per session replaces one blocking call per frame.
It turns into a problem the moment the same object is used for something bigger. Serialising a level, or keeping a list of every run, puts the decode of a long string inside a window that the frame already needs. The 16.6 ms budget described in the frame-time note does not care that a read looks like a property access; a value a quarter of a megabyte long has to be turned back into a string before the method returns.
Five megabytes, counted in bytes and stored as UTF-16
The quota is per origin and it is small. MDN's page on storage quotas gives it twice: "browsers can store up to 5 MiB of local storage, and 5 MiB of session storage per origin", and, across both, "limited to 10 MiB of data maximum on all browsers". One mebibyte is 2^20 bytes, so five of them is 5,242,880 bytes.
The unit matters because of the second half of the same page. Keys and values are UTF-16 strings, and a UTF-16 code unit is two bytes, so dividing the quota by two gives 2,621,440 characters. Browsers do not agree on whether they count code units, encoded bytes, or something else, so treat that figure as an order of magnitude rather than a contract. What it does establish is that the ceiling is low enough to reach by accident.
The scope is the origin, not the page. Storage is keyed by scheme, host and port, so one game served over http and over https sees two separate stores, while two games on one host share a single 5 MB pool. A prefix on every key is cheap insurance: gamerig.snake.best cannot collide with a neighbour that also wanted best.
Whatever goes in, a string comes out
The setter takes a string and nothing else. MDN puts the rule as "storage only supports storing and retrieving strings", and the coercion is silent — no error on the way in, no marker on the way out. The round trip is worth memorising, because three of the four rows below are bugs waiting to be written.
| Value passed to setItem | Stored as | getItem returns | typeof |
|---|---|---|---|
42 | coerced to a string | "42" | "string" |
false | coerced to a string | "false" | "string" |
{ score: 42 } | ordinary object coercion | "[object Object]" | "string" |
null | coerced to a string | "null" | "string" |
The trap that survives code review is comparison. A stored best of 9 comes back as "9", and the string comparison "9" > "10" is true, because the test stops at the first character and "9" loses to "1" only when there is more of it. A score loaded as a string and compared without conversion therefore rejects a better run and accepts a worse one. Wrapping the read in Number(...) || 0 costs nothing and removes the class of bug entirely.
For anything structured, the answer is JSON.stringify on the way in and JSON.parse on the way out. MDN notes the retrieved object is a deep copy, so mutating it afterwards does not write anything back, which is a second silent failure for anyone expecting a live reference.
Three ways a write fails, and three different recoveries
The failures are not variations on one error. Each has its own signature and its own correct response, and lumping them together is how a game ends up with a permanently zero high score on one browser and an uncaught exception on another.
| Situation | Error name | Legacy code | What to do |
|---|---|---|---|
| The origin is denied storage by policy or by the user | SecurityError | 18 | play on, keep the score in memory only |
| The 5 MiB pool is full | QuotaExceededError | 22 | drop older keys, then retry once |
| A private window that reports a zero quota | QuotaExceededError | 22 | fall back to an in-memory object |
| Seven days without a visit, on Safari | none is raised | none | read with a default and carry on |
MDN's own availability check ends with e.name === "QuotaExceededError" && storage.length !== 0, and the second half of that is the part worth copying. A quota error on an empty store is not a full store; it is a store that refuses writes outright, which is exactly how a private window with a zero quota presents itself. The numeric codes in the table are historical and the field that carries them is deprecated, so a handler should branch on the name.
The wrapper that survived here is not clever. One try/catch around the read, one around the write, and a plain object standing in when either fails. Three lines buy a player in a private window a working game with a score that lasts until the tab closes, instead of an exception thrown somewhere nobody is looking. The same shape is what lets the fake-browser harness run a game with no storage at all.
Seven days, and the key is simply gone
Safari adds an eviction rule that has nothing to do with quota. MDN's quota page describes it in one line: Safari may proactively clear script-written storage for an origin that has had no user interaction in the last seven days, while cookies set by a server are exempt. A high score written by setItem is not exempt.
For a score this is not a crash, it is a missing key, and the only code that breaks is code which assumed the key would be there. The two common forms are JSON.parse(localStorage.getItem(k)) with no null check and a comparison against undefined that quietly yields NaN. Reading with a default covers this case and the private-window case with the same expression, which is the argument for writing the read defensively even where storage works.
Pros
- A four-byte read at startup costs nothing measurable and needs no initialisation, no callback and no permission prompt.
- The value survives a reload, a tab close and a browser restart, which is all a high score has ever needed.
- Storage is keyed by scheme, host and port, so a prefix isolates one game from everything else on the origin.
Cons
- It blocks the thread that draws the frame, so any value large enough to notice is a value too large to keep there.
- The 5 MiB pool is shared with every other page on the origin and is easy to fill with anything more than a handful of numbers.
- Private windows and Safari's seven-day rule both remove the data with no warning, so a read that assumes a value is always a bug.
- Every value is a string, which moves the type checking out of the API and into every call site.
Sources
- MDN: Storage quotas and eviction criteria — the 5 MiB local-storage limit per origin, the best-effort eviction model, and Safari's proactive seven-day clearing.
- MDN: Using the Web Storage API — the strings-only rule, the private window with a zero quota, and the availability check that branches on the error name.
- MDN: DOMException — the legacy error codes, including 22 for QuotaExceededError and 18 for SecurityError, and the note that the numeric field is deprecated.
- web.dev: Storage for the web — the synchronous main-thread warning and the comparison with the asynchronous storage APIs.