Back to Hub
ENGINEERING •
By Yash Panchal · Last updated:

localStorage Is My Database: Patterns, Limits, Graduation

NitroIDE has no server and no database. Your projects still survive a browser restart, a closed tab, and a Monday morning. The entire persistence layer is localStorage — one API, string keys, string values, about 5 MB per origin.

That sounds like a toy. It has carried a real product for months. It has also taught me exactly where it breaks, because I have hit every wall myself. Here is the honest version: the patterns that work, the limits that bite, and the line where you graduate to something bigger.

Pattern 1: one key, one JSON document

The first mistake is treating localStorage like a table and scattering dozens of keys. I do the opposite: one key holds one whole document.

NitroIDE's projects live under a single key, nitro_projects — a JSON array where each project carries its virtual file system (its HTML, CSS, and JS files). A second key, nitro_current_project_id, tracks which project is active. Loading the app is two reads and two parses. Saving is one JSON.stringify and one write.

The tradeoff is real and you should name it: every save rewrites the whole document. For a handful of projects with small files, that is milliseconds and nobody notices. For a megabyte of code, it is a full re-serialize on every save — which is why Pattern 4 exists.

Pattern 2: namespace every key, and never trust what's inside

Preferences live under their own namespaced keys — theme, nitro_font, nitroide_wordwrap — so a key never collides with another feature's data or with a library's. The nitro_ prefix is the whole scheme, and it is enough.

More important: every read is wrapped in try/catch with a sane default. localStorage is user-editable, extensions can touch it, and a half-written value from a crashed tab is a JSON.parse error waiting to happen. Corrupt data must degrade to “fresh default project”, never to a white screen. If your app cannot boot with an empty store, your store is a single point of failure.

Pattern 3: cap your history before it caps you

Unbounded growth is how a 5 MB store dies. NitroIDE's time-machine snapshots are sliced to a fixed length before every save — old history is dropped as new history arrives, by construction, not by a cleanup job I might forget to run.

The rule generalizes: any list you append to in localStorage needs a cap at write time. Undo stacks, recent files, activity logs — if it grows without a ceiling, it will eventually throw QuotaExceededError at the worst possible moment, which is always mid-save.

Pattern 4: don't save on every keystroke

Saves are batched, not fired per keystroke. Typing is fast; serializing a whole project document is not free, and localStorage is synchronous — every write blocks the main thread until it finishes. A big document saved on every keypress is a typing lag bug wearing a persistence costume.

Batching plus the whole-document rewrite is the core tension of this design: simple reads, expensive writes. Keep documents small, save on pauses, and measure before you blame the editor.

The limits, stated plainly

About 5 MB per origin. Browsers vary, and the count is roughly in UTF-16 code units, so non-ASCII text costs more than you think. There is no “buy more” button.

Strings only. Everything is serialized. Dates become strings, undefined vanishes from JSON, and a Map silently becomes {}. If your data model needs types, your serialization layer is now part of your database.

Synchronous and blocking. There is no async localStorage. A large read on startup delays first paint; a large write freezes typing. This is the limit that ends the honeymoon.

No queries, no indexes, no transactions. Finding one project means parsing all of them. Two tabs writing at once can clobber each other — the storage event tells other tabs something changed, but it does not merge anything.

Per-origin, and wiped with site data. http and https are different stores, subdomains are different stores, and “clear site data” deletes everything. It is a cache with delusions of permanence unless you also offer export — which is why NitroIDE exports to ZIP.

Readable by any script on your origin. This is the security line. localStorage has no per-key permissions: any XSS on your page reads the whole store. Never put tokens or secrets in it. And this is exactly why NitroIDE's preview iframe runs on an opaque origin — user code in the preview cannot reach the parent's localStorage at all. The sandbox fix from the v25 audit and the storage design are the same decision: the store is only as safe as the origin around it.

When to graduate

Move on when any one of these turns true: you need queries or indexes, you store more than a few megabytes, you store binary data, or two tabs must transact without clobbering each other. IndexedDB is the usual next step — async, much larger, indexed, transactional — and it is a real database with real API surface to show for it.

My rule of thumb: localStorage is a database for documents you can hold in your head — settings, small project lists, drafts. The moment you catch yourself building an index, a migration, or a merge strategy on top of it, you have already outgrown it. Build the index in a store that has one.

Until that day, though, one key per document, namespaced keys, try/catch on every read, a cap on every list, and batched saves will carry you further than most people expect. It carried a whole IDE.

Try it

I build NitroIDE — a browser-based IDE where your code never leaves your machine: nitroide.com

⭐ If you enjoyed this, star NitroIDE on GitHub: github.com/nitroideofficial/nitroide

Frequently asked questions

How much can localStorage actually hold?

About 5 MB per origin, counted roughly in UTF-16 code units, so non-ASCII text costs more than you think. There is no way to buy more — when the store is full, writes throw QuotaExceededError.

Why does NitroIDE save whole documents instead of individual files?

One key per JSON document keeps reads to two parses and writes to one stringify. The cost is that every save rewrites the whole document, which is why saves are batched instead of fired per keystroke.

What should never go in localStorage?

Tokens and secrets: any script on your origin can read the whole store, so an XSS anywhere reads everything. NitroIDE's preview iframe runs on an opaque origin precisely so user code cannot reach the parent's store.

When should I switch from localStorage to IndexedDB?

When you need queries or indexes, more than a few megabytes, binary data, or safe multi-tab writes. If you catch yourself building an index, a migration, or a merge strategy on top of localStorage, you have already outgrown it.