DEV Community

Cover image for localStorage Isn't Free — It's Blocking Your Main Thread
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

localStorage Isn't Free — It's Blocking Your Main Thread

You add autosave to a text editor. Every few seconds, whatever's in the textarea gets written to localStorage so a refresh — or a crashed tab — doesn't cost the user their draft. You test it with a paragraph. Smooth. Ship it.

Then someone pastes in a 6,000-word draft, and every autosave tick makes the whole page hitch for a beat — the cursor stalls, the animation in the sidebar stutters, keystrokes queue up and land late. Nothing crashed. No error. The console is clean. It just... pauses. Repeatedly. Forever, as long as the draft stays that big.

Guess before you scroll: it's not a bug in your debounce logic. The debounce is fine. The freeze is coming from a function you'd swear is instant, because it always has been — right up until the argument got large.

The obvious fix that doesn't fix it

Your first move is reasonable: you're calling localStorage.setItem too often, so throttle it.

const saveDraft = debounce((text) => {
  localStorage.setItem("draft", JSON.stringify({ text, savedAt: Date.now() }));
}, 2000);

textarea.addEventListener("input", (e) => saveDraft(e.target.value));
Enter fullscreen mode Exit fullscreen mode

Autosaves now fire every two seconds instead of every keystroke. On a small draft, the stutter is gone — but that's because small drafts were never the problem. Paste the same 6,000-word draft back in and the page still hitches, just less often. Every two seconds instead of every keystroke, but each hitch is exactly as long as before.

That's the tell. Debouncing controls frequency. It does nothing to the duration of a single call. If one call to setItem blocks for 40ms, calling it less often gives you fewer 40ms freezes — not shorter ones.

What's actually blocking

localStorage is a synchronous API. Not "usually fast" — synchronously specified. MDN is direct about it: every getItem and setItem call runs to completion on the thread that called it before anything else on that thread can run. No other event handler fires, no frame paints, no requestAnimationFrame callback executes, until that one line returns.

For a short string, "runs to completion" is sub-millisecond — you'll never see it. But your draft isn't a short string. Before it ever reaches setItem, it goes through JSON.stringify on a growing object, and then the storage write itself has to serialize and persist that string. Both steps happen on the same call, on the same thread, with the same guarantee: nothing else runs until it's done. A 5MB draft doesn't make setItem async — it just makes the synchronous part take longer, and the page is unresponsive for exactly that long, every single time you call it.

There's a second cost stacked on top: localStorage only stores strings. Every save round-trips your data through JSON.stringify, and every load round-trips it back through JSON.parse — both synchronous, both scaling with payload size, both adding to the freeze. And you're working inside a ceiling most browsers put at roughly 5MB per origin for all of localStorage combined, draft included. Get there and setItem throws QuotaExceededError instead of saving — which, for an autosave feature, is worse than a stutter.

The fix: an API that doesn't block

IndexedDB solves the actual problem, not a symptom of it. It's asynchronous from the ground up: you open a transaction, call .put(), and get a request object back immediately. The real work — serializing and persisting the value — happens off the synchronous call stack. Your code (and the browser's renderer) keeps running while it does.

function openDraftsDb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open("editor", 1);
    req.onupgradeneeded = () => req.result.createObjectStore("drafts");
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function saveDraft(text) {
  const db = await openDraftsDb();
  const tx = db.transaction("drafts", "readwrite");
  tx.objectStore("drafts").put({ text, savedAt: Date.now() }, "current");
  // tx.oncomplete fires later — the call above already returned.
}
Enter fullscreen mode Exit fullscreen mode

Notice what's missing: no JSON.stringify. IndexedDB uses the structured clone algorithm instead of JSON serialization, so you can hand it the plain object — even Dates, Maps, Blobs — and it stores the value directly. And the storage ceiling isn't a fixed 5MB; it's a share of whatever disk space the browser is willing to grant the origin, which you can check with navigator.storage.estimate() and which is typically a large fraction of free disk space, not a hardcoded number.

The trade you're making is real, not free: IndexedDB's API is callback- and event-based and noticeably more ceremony than setItem(key, value). For a handful of small, infrequent values — a theme preference, a feature flag — localStorage's synchronous simplicity is still the right call; the blocking never gets large enough to notice. The line to watch is payload size and write frequency, not "is this data important." A dark-mode toggle can live in localStorage forever. A growing document, a cart with attachments, or anything you write on every keystroke should not.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Numbers in a paragraph don't land the way a stuttering animation does. The playground runs a requestAnimationFrame loop — a dot sliding back and forth, smooth as long as the main thread is free — next to two buttons that write the same payload two different ways. One call blocks the loop. The other doesn't. Watch the dot, not the numbers.

The lesson

"It's just a localStorage.setItem call" is true and also exactly why it's easy to miss — the API looks identical whether the payload is 12 bytes or 12 megabytes, and only one of those is a problem. Debouncing a synchronous call gives you the same freeze, less often. Only an actually asynchronous API — IndexedDB, here — gives you a shorter one.

Go check what you're writing to localStorage on a hot path — an editor, a form draft, anything with attachments or growing text. If the payload can grow past a few dozen KB, that's worth five minutes today. What's the biggest thing you've ever accidentally shoved into localStorage?

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)