Notepad Neo

What happens when localStorage runs out

An autosave that quietly stops working is worse than one that never worked, because the user has no reason to doubt it. The interesting part is not the fallback — it is deciding when to trigger it.

DH

— builds and maintains Notepad Neo

· updated · 11 min read

Notepad Neo keeps every note on the device. There is no account, no sync and no server, which makes storage the only place a note exists — and makes a failed write a data-loss event rather than an inconvenience.

localStorage is the natural first choice. It is synchronous, universally supported, survives a reload, and stores strings, which is what a serialised app state is. It also has a hard per-origin cap that a long note reaches faster than you would guess, and its failure mode is the worst possible one.

The failure mode

localStorage.setItem() throws QuotaExceededError when the write does not fit. If nothing catches it, the exception unwinds out of a debounced autosave callback that nobody is awaiting, lands in the console, and disappears.

From the user's side there is no signal at all. The editor keeps accepting keystrokes. The status bar keeps saying Saved, because the status bar was updated by the code that scheduled the save rather than by the save itself. The note is intact in the DOM for as long as the tab is open. Then the tab closes, and everything since the last successful write is gone.

The cap is smaller than the number you have read

The commonly quoted figure is 5 MB per origin, and per MDN it is 5 MiB for localStorage plus another 5 MiB for sessionStorage. But Web Storage holds UTF-16 strings, and browsers account for the quota in bytes — so an ASCII note consumes two bytes per character, and the practical ceiling is around 2.5 million characters, not five million.

That is still a lot of plain text. It is not a lot of rich text: a note with formatting spans, inline styles and a base64 image reaches it far sooner, and the whole app state — every tab — shares the one budget.

Web Storage quota compared with the IndexedDB pool localStorage is capped at 5 MiB per origin in every browser. IndexedDB draws from a much larger pool: Chrome allows an origin up to 60 percent of total disk, Firefox allows the smaller of 10 percent of disk or 10 GiB in best-effort mode, and Safari allows around 60 percent of disk for browser apps. The difference is orders of magnitude, not a factor of two. PER-ORIGIN LIMITS · MDN, 2026 localStorage 5 MiB — every browser, fixed IndexedDB · Chrome 60% of total disk IndexedDB · Firefox min(10% of disk, 10 GiB) — best effort IndexedDB · Safari ~60% of disk in a browser app, ~15% when embedded The bars are not to scale — they cannot be. On a 512 GB disk the Chrome figure is around 300 GB, which is roughly sixty thousand times the localStorage cap.
Quota is an upper bound, not a reservation. Nothing guarantees an origin can actually store that much, which is why error handling is required even on the large-pool side.

Upgrade on failure, not on prediction

The obvious design is to measure before writing. Ask how much room is left, and if the state is getting close to the cap, move to IndexedDB pre-emptively. There is even an API that sounds like it answers exactly this question.

It does not. navigator.storage.estimate() reports the IndexedDB and Cache pool. Web Storage usage is not included in it. Asking it how full localStorage is returns a number about something else entirely.

Predicting the quota versus reacting to a failed write The predictive path asks navigator.storage.estimate for remaining space, but that figure excludes Web Storage entirely and is padded for cross-origin data, so the decision is made on the wrong number. The reactive path attempts the write, catches the failure, and promotes to IndexedDB — using the only signal that is always accurate. PREDICT await navigator.storage.estimate() remaining < threshold ? switch to IndexedDB ✕ excludes Web Storage entirely ✕ padded for cross-origin data, by design REACT localStorage.setItem(...) did it throw ? promote, then write to IndexedDB ✓ the write itself is the only reliable oracle ✓ one wasted attempt, once per session
The reactive version costs one failed write. That write was going to fail either way — the predictive version just fails it silently and later.

There is a deeper reason to prefer reacting. Quota is not the only thing that makes a localStorage write fail. Safari in private browsing has historically thrown on any write. Enterprise policy can disable site data. A user can block storage for the origin. None of those are visible to an estimate, and all of them mean the same thing: this storage backend is not usable, use the other one.

Catching the quota error is more awkward than it should be

There is no portable way to identify a quota failure. The modern answer is a DOMException with name === 'QuotaExceededError'. Firefox historically threw NS_ERROR_DOM_QUOTA_REACHED. Older Safari threw QUOTA_EXCEEDED_ERR. And the private-browsing and policy cases throw things that are not quota errors at all but mean exactly the same thing operationally.

So the code does not branch on the error type. It treats every failure identically, and the try is the whole logic:

function saveToLS(state: AppState): boolean {
  try {
    localStorage.setItem(LS_KEY, JSON.stringify(state));
    return true;
  } catch {
    // Any failure means this backend is unusable — quota, private mode,
    // blocked site data. The distinction does not change what we do next.
    return false;
  }
}

A boolean rather than a rethrown error, because the caller has a recovery path and does not need the diagnosis. Distinguishing the cases would let us write a better console message and would change nothing about the behaviour.

The promotion, and the one line that makes it correct

The adapter carries a single flag. While it is false, saves go to localStorage. The first failed write flips it, and everything after that goes to IndexedDB:

async save(tabs: TabState[], activeTabId: string): Promise<void> {
  const state: AppState = { version: CURRENT_VERSION, tabs, activeTabId };

  if (this.useIDB) {
    await saveToIDB(state);
    return;
  }

  const ok = saveToLS(state);
  if (!ok) {
    this.useIDB = true;
    // Drop the localStorage copy at the moment of promotion. load() checks
    // localStorage first, so leaving the last-successful (older) state behind
    // would make it win over the fresh IndexedDB one on the next visit.
    try { localStorage.removeItem(LS_KEY); } catch {}
    await saveToIDB(state);
  }
}

The removeItem is the line that matters, and it is the one that is easiest to leave out. load() reads localStorage first and only falls through to IndexedDB if nothing is there. Leave the stale copy behind and the next visit restores the last state that fit — silently discarding everything written after the quota was hit. That is a worse bug than the one the fallback exists to fix, because it looks like a successful load.

The promotion cycle, including the demotion nobody designed Saves go to localStorage until one fails. On failure the adapter sets an in-memory flag, removes the stale localStorage copy, and writes to IndexedDB for the rest of the session. Because the flag is not persisted, a reload starts back in localStorage mode; the load falls through to IndexedDB and succeeds, and the next save attempts localStorage again. If the note has shrunk in the meantime, that attempt succeeds and the app silently demotes. save → localStorage useIDB = false throws promote useIDB = true removeItem(LS_KEY) save → IndexedDB for the rest of the session tab closes · page reloads load() LS empty → falls to IDB useIDB starts false again next save tries localStorage first — again if the note has shrunk, it fits, and the app quietly demotes Nobody designed this. It falls out of the flag being per-session, and it is the behaviour you want.
The self-healing demotion is a genuine accident of the design. Because the flag lives in memory rather than in storage, every reload re-tests the cheaper backend and returns to it when it can.

What actually gets written

The whole app state is one JSON object, and every tab in it has the same shape:

export interface TabState {
  id: string;
  title: string;
  content: string;      // HTML for rich tabs, raw text for plain and markdown
  format: TabFormat;    // 'rich' | 'plain' | 'markdown'
  color: string | null;
  zoom: number;
  createdAt: number;
  updatedAt: number;
}

content is the interesting field, and it is the reason the cap arrives sooner than a character count suggests. For a rich tab it is serialised HTML, so a paragraph the user sees as forty characters is stored as forty characters plus whatever markup carries its formatting — a <span style="font-size: 18px; line-height: 1.4"> is fifty bytes of overhead before a single letter of content.

That is the tax for using the DOM as the document model rather than keeping a separate structure. It is the same trade discussed in the font-size article, seen from the storage side: the export pipelines get to read formatting straight off the live DOM, and persistence pays for it in bytes.

The read path mirrors the write path exactly, and the ordering in it is load-bearing:

async load(): Promise<AppState> {
  let state = loadFromLS();               // synchronous — no round trip on the common path
  if (!state) state = await loadFromIDB(); // only if Web Storage had nothing
  if (!state) return defaultState();
  return migrate(state);
}

localStorage first, because on the overwhelming majority of loads it has the state and the app can render without waiting. IndexedDB second, because it only holds anything at all once a promotion has happened. And a default state last, which is both the first-visit path and the "storage is unavailable in this context" path — deliberately indistinguishable, because there is nothing useful to say about the difference at that point.

// One record: the whole app state, at the literal integer key 1.
const req = tx.objectStore(IDB_STORE).get(1);

Why not use IndexedDB for everything?

It is the larger, more capable store. It is also asynchronous, transactional, versioned, and event-based rather than promise-based, which means every call site either grows an await or a wrapper.

The costs that decided it:

So: localStorage for the common case, IndexedDB when it stops working. Two backends, one shape of data, and the same serialised state either way.

Version the state before you need to

Persisted state outlives the code that wrote it. Someone will open this app having last used it eighteen months ago, and the state in their browser will have a shape from an older release.

export interface AppState {
  version: number;
  tabs: TabState[];
  activeTabId: string;
}

export const CURRENT_VERSION = 1;

The migration function today is a stub — it stamps the current version and returns. That is the point. The field costs one integer per save and it is the difference between adding a migration later and having to guess, from the shape of an object, which release wrote it.

Two rough edges, stated rather than hidden

loadFromIDB wraps everything in a try that returns null, so a blocked or unavailable IndexedDB degrades to a fresh default state rather than throwing. That is the right call on load — but it also means a transient failure is indistinguishable from a first visit.

saveToIDB is awaited but not wrapped at its call site, so a rejected write surfaces as an unhandled rejection. In the promotion path that is the second failure in a row and there is genuinely nothing left to fall back to — but it should still be caught and surfaced to the user rather than to the console.

Debounce the save, and pick the intervals deliberately

Serialising every tab and writing the whole state on every keystroke is wasteful, and with a synchronous backend it is wasteful on the main thread. But a long debounce widens the window in which a crash loses work.

There are two debounces in the chain, at different levels, doing different jobs:

The autosave debounce chain A keystroke updates the DOM immediately. The editor coalesces input events over 100 milliseconds before emitting a change. The change marks the tab unsaved and starts a second 500 millisecond debounce before the actual write, which sets the status to saving and then saved. A beforeunload handler flushes the pending write when the tab closes. keystroke DOM updates 100 ms editor debounce change status: unsaved 500 ms save debounce write saved beforeunload — flush immediately, skipping both debounces Worst case exposure is 600 ms of typing. The beforeunload flush is fire-and-forget by necessity — the handler cannot await a promise, so an IndexedDB write started there may not complete. That is the strongest argument for keeping the debounce short rather than relying on the flush.
The 100 ms editor debounce coalesces raw input events. The 500 ms save debounce coalesces logical changes. Collapsing them into one number would mean either serialising far too often or leaving the status bar lying about what has been written.

Note what is deliberately not debounced: the per-block direction sync runs synchronously on every input event, outside both timers, so the saved HTML always carries its dir attributes. That is covered separately.

Eviction is the risk nobody plans for

Quota is the failure you can catch. Eviction is the one you cannot.

By default all this data is best-effort: the browser may delete it when the device is short of space, and it deletes an origin's data in full rather than in part. Chromium uses a least-recently-used policy. Safari is more aggressive still — an origin with no user interaction for seven days of browser use has its script-created storage deleted, which for an offline notepad someone opens once a fortnight is a real scenario rather than a theoretical one.

The mitigation is navigator.storage.persist(), which requests that the origin's data only be cleared by the user. It returns a boolean, and browsers decide whether to grant it based on engagement signals — installed as a PWA, bookmarked, frequently visited. It is a request, not a setting.

Notepad Neo does not currently call it, and that is the clearest gap in this design. It costs one line and one await at startup, it degrades to today's behaviour when refused, and for a local-first app it is the difference between "your notes are on this device" and "your notes are on this device unless the browser needs the space".

What we would do differently

The promotion logic itself has held up. The design decisions worth revisiting are all about what the user can see.

The general lesson is narrower than "handle your errors". It is that in a local-first app, the storage layer is the only layer that can tell the truth about whether the user's work exists — so every branch in it that fails quietly is a branch that will eventually lose somebody's document and let them find out at the worst possible moment.

← All engineering write-ups Try the editor