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 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.
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.
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.
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:
-
First paint.
localStorageis synchronous, so the first render can read state directly. An IndexedDB-only app has at least one asynchronous round trip before it knows what to draw — which is either a flash of empty editor or a spinner, on every single load, to solve a problem most users never have. - Private browsing. IndexedDB is available but ephemeral in some private modes, and blocked outright in others. Neither backend is reliable there, so having two is worth more than picking the better one.
-
Failure surface. The IndexedDB store here is one record — the whole app state,
keyed by the literal integer
1, in an object store with no key path and no indexes. That islocalStoragewith a bigger cap, deliberately. Modelling tabs as rows would buy partial writes and cost transaction management, migration complexity and a class of consistency bugs the app does not currently have.
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.
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:
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.
- Request persistence. As above — the single highest-value change available, and it is a one-liner.
- Surface the backend. The app knows it has promoted to IndexedDB and never says so. A user whose notes have outgrown Web Storage is a user whose notes are large enough to be worth exporting a copy of.
- Drive the status bar from the write, not from the scheduling. Today the status is set optimistically around the save. A failure that both backends could not recover from would still leave Saved on screen, which is the exact class of lie this whole article is about.
- Catch the IndexedDB rejection. The second failure in a row is the one worth interrupting the user for.
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.
-
localStorageis 5 MiB per origin, and UTF-16 accounting halves that in characters. -
navigator.storage.estimate()does not report Web Storage usage. Do not gate a Web Storage decision on it. - Promote on the failed write, not on a prediction — the write is the only reliable oracle, and it also catches private mode and blocked storage.
- Delete the old copy at the moment of promotion, or a stale successful write outranks the fresh one on the next load.
- Keep the backend flag in memory. The demotion you get for free is the behaviour you want.
-
Put a
versionon persisted state on day one. -
Call
navigator.storage.persist(). Quota is catchable; eviction is not.