localStorage Survives a Refresh (Not a Second Browser)

Build one small page twice, run three tests on your own list, and find out which of the four promises “my app saves things” is actually making.

8 min read

Argued into existence in the Writing Room7 messages · 1 technical catch · 1 mind changed
localStorage Survives a Refresh (Not a Second Browser)

Let's open the hood on the most boring question in your project, which is also the one quietly deciding its shape: when you type something into your app and it appears on the screen, where did it actually go?

Not metaphorically. Physically, on a disk somewhere, in a file with a path.

There are three answers. A value can live in the page's own memory, or in a small box the browser keeps on your machine, or in a database belonging to somebody else. Each one survives something different, and each one costs you something different.

You do not learn that from a comparison table. You learn it by building one tiny page twice and running three tests on it, in order.

First, give yourself a real address

Make an empty folder, open a terminal in it, and run this — 'python3' is 'py' on Windows — and save everything below inside it:

python3 -m http.server 3000

Leave that running, and open everything below at 'http://localhost:3000/look-up-later.html'. Do not double-click the file. A page opened off the disk has what the spec calls an opaque origin, and storage is not promised to it — the simulator I used refuses outright, with 'localStorage is not available for opaque origins'.

You also need a real port number before the last section can mean anything.

Apparatus, plainly. Version one, version two and the origin fence were reproduced on 25 July 2026 in jsdom 29.1.1 on Node 22.22.2 — a browser simulator, not a browser.

Tests two and three are reasoned from the storage spec and from where browsers keep profiles on disk. jsdom has no profiles and no private windows, so I could not run those two, and I would rather say so than dress an argument up as a measurement.

Version one: the list lives in the page

Save this as 'look-up-later.html'. It parks things you mean to look up later, which is the only app I have ever used every day.

<!doctype html>
<html>
  <body>
    <h1>Look Up Later</h1>
    <input id="thing" placeholder="Something to look up" />
    <button id="add">Add</button>
    <ul id="list"></ul>

    <script>
      let items = [];  // this list exists in the page and nowhere else

      function render() {
        const ul = document.getElementById("list");
        ul.innerHTML = "";
        items.forEach(function (text) {
          const li = document.createElement("li");
          li.textContent = text;
          ul.appendChild(li);
        });
      }

      document.getElementById("add").addEventListener("click", function () {
        const box = document.getElementById("thing");
        const text = box.value.trim();
        if (!text) return;
        items.push(text);
        box.value = "";
        render();
      });

      render();
    </script>
  </body>
</html>

Add three things — mine were 'what a closure is', 'why sort needs a comparator', 'what CORS means'. All three appear.

Now press refresh.

Empty.

Nothing broke. 'let items = []' is a label on a box the page itself owns, and a refresh does not reload your list — it throws the entire page away and runs the script again from the top, at which point 'items' is a brand-new empty array.

No error, because nothing went wrong. You asked for a list that lives in the page, and the page stopped living.

That is tier one: free, instant, and gone the moment anything reloads. Fine for a value you need for the next four seconds. A disaster for anything a person typed.

Version two: give it somewhere to land

Two new functions and one changed habit. Delete the whole '<script>' block from version one — the old 'render' included — and put this in its place.

<script>
  const KEY = "look-up-later";

  function load() {
    const raw = localStorage.getItem(KEY);   // a string, or null if nothing is saved yet
    return raw ? JSON.parse(raw) : [];
  }

  function save(items) {
    localStorage.setItem(KEY, JSON.stringify(items));
  }

  function render() {
    const items = load();                    // read from the box, not from memory
    const ul = document.getElementById("list");
    ul.innerHTML = "";
    items.forEach(function (text) {
      const li = document.createElement("li");
      li.textContent = text;
      ul.appendChild(li);
    });
  }

  document.getElementById("add").addEventListener("click", function () {
    const box = document.getElementById("thing");
    const text = box.value.trim();
    if (!text) return;
    const items = load();                    // read, push, write
    items.push(text);
    save(items);
    box.value = "";
    render();
  });

  render();
</script>

I am labouring delete because I tried it the other way — old 'render' kept, new one pasted above it — and the page fails in silence.

JavaScript moves function declarations to the top of the file before it runs anything — hoisting is what you'll see it called — and the last one wins, so the 'render' that runs is the old one, reaching for an 'items' that no longer exists.

Your value lands in storage, your screen stays blank, and the only complaint is a 'ReferenceError' in a console you have no reason to open.

localStorage is the box — a small key-and-value notebook your browser keeps on your own computer, one page of it per website.

It holds text and only text, no arrays and no objects, which is why the list goes out through 'JSON.stringify' (flatten my array into one line) and comes back through 'JSON.parse' (turn that line back into an array).

If that shape looks familiar, it is the one The Bug That Doesn’t Crash Anything pulled apart — it is what nearly every assistant hands you.

Here is precisely what sits in the notebook after my three lines, read straight back out:

key    look-up-later
value  ["what a closure is","why sort needs a comparator","what CORS means"]

Sixty-nine characters, one string. Now the three tests, in order, because the order is the lesson.

Test one: refresh. It survives.

Add your three things. Press refresh. They come back.

That is the promise, delivered, for about nine lines. Your list is on your hard disk in a folder your browser owns, and it will be there tomorrow.

Test two: open the identical page in a second browser. It does not.

Same laptop, same URL, nothing changed. If you added your items in Chrome, load that same URL in Firefox — or the reverse.

Empty list. Same page, same heading, same button, no items and no error — two windows side by side, one full and one blank, and nothing on either screen tells you why.

Test three: a private window. Also does not.

Back in your original browser, open a private window and load the same URL.

Empty again. Type something — it appears, it survives a refresh inside that window, and it is gone the moment you close the window.

That is the half people get wrong, and it runs both ways: the private window cannot see your list, and your list never sees what the private window did.

The two fences, named

Those two failures look like one fact. They are two, and mixing them up will cost you an afternoon.

Fence one is the origin. An origin is scheme plus host plus port — 'http', 'localhost', '3000' — and every distinct origin gets its own page in the notebook.

This is the fence that bites during an ordinary week: run your project on 'http://localhost:3000', save a list, start it again on port 8000, and the list is gone.

Same file, same computer, same browser, different box. Same story when a site moves from 'http' to 'https', or from one domain to another.

This one I did reproduce, and it needed care. The obvious check — two separate simulator instances, one per port — proves nothing: a fresh instance holds no data at all and reads null even at the same origin.

What works is one instance holding two frames against real servers on 3000 and 8000. Only 3000 wrote. 3000 read the value back; 8000 read null.

Fence two is the browser's own profile. Chrome keeps its storage in a Chrome folder, Firefox keeps its in a Firefox folder, and neither has ever heard of the other.

A private window is a variant of that same fence: a scratch area, gone when you close it.

The plumbing version: the origin decides which tap, the profile decides which house.

Browsers do not all publish the same ceiling, but a few megabytes per origin is the usual figure — and the meter counts characters, not bytes.

I checked that against the simulator's own quota setting: on a hundred-character budget, a hundred-character value is refused whether the character is 'a', 'é' or a Chinese one, all three costing exactly one.

Emoji cost two, because JavaScript holds each as a pair. So five megabytes divided by a forty-character line is about a hundred and twenty thousand lines.

You will not run out with notes. You will run out immediately with images.

Version three: somebody else's database

Tier three is a database on a machine that is not yours — a hosted one you sign into, or a small server you write yourself.

What it buys is the one thing neither test two nor test three could give you: the list stops belonging to a browser on a desk and starts belonging to an account. That is the whole feature.

What it costs, honestly. An account. A network. A wait between asking and knowing. And a class of failures that did not exist an hour ago: offline, slow, half-saved, saved twice.

It also costs one hard rule: the database password cannot live in the file, because anything the browser downloads can be read by whoever downloaded it.

Tier three is the day secrets start needing somewhere else to be.

Sure, but — none of that is an argument against it. It is an argument for knowing which day you need it.

Why this matters

The reason to run those tests rather than take my word is that "my app saves things" is four claims wearing one coat, and you now know which you are making.

Saves across a refresh. Saves across a browser. Saves across a machine. Saves across a person.

Each is a different amount of work, and reaching for the biggest on day one is how a two-hour project becomes a fortnight of logins for a list nobody but you will read.

The failure mode this prevents is the quiet one. You send the page to a friend, watch them open it, and discover in front of them that the data you looked at all week was never in the app. It was in your browser.

Final thought

Serve your own project and run test two on it. Add three items, then open the identical URL in the other browser you already have installed.

Whatever you see, write one sentence at the top of that file naming where its data lives and what would erase it.

Then the verdict, flat, because hedging is not an answer. For what you are building this week, localStorage is very likely right. And you have outgrown it the day a second person needs the same list.

localStorage Survives a Refresh (Not a Second Browser) | Vibecodes