Command Palette

Search for a command to run...

Hectal
PHASE 4Intermediate ~32 min· topic 4 of 8

Topic 4.4

Saving and Restoring Conversations

In one line

Chat state lives in memory and dies on reload. localStorage can keep it, but it is small, synchronous and string-only, it can fail or hold old, corrupt or foreign data, and two tabs can overwrite each other. Version what you save, validate what you load, and never restore a half-finished answer as if it were running.

Think of it like this

Your memory versus a notebook. What you hold in your head is fast but is gone after a good night's sleep (a page reload). A notebook keeps it, but it has a limited number of pages (the storage quota), someone else may have scribbled in it (another tab, an older version of the app), and the ink can smudge (corrupt data). Saving a chat means writing it in the notebook, and reading it back means checking every page before you trust it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

localStorage
A small key-value store in the browser for one website. Synchronous, strings only, survives reloads.
Origin
Scheme + host + port. Each origin has its own separate storage.
Quota
The storage limit. When it is exceeded setItem throws QuotaExceededError.
Migration
Code that upgrades data saved by an older version of the app to the current shape.
Schema version
A number saved with the data that says which shape it has.
Last write wins
When two writers overwrite one value, the later one silently replaces the earlier one.
storage event
An event the browser fires in other tabs of the same origin when a key changes.
Throttle
Doing something at most once per interval, however often it is asked for.
Interrupted answer
A saved message whose stream never finished because the page closed.

Step by step

01Save: one versioned object

Everything goes under one key, with a version number. To your code one setItem call is all-or-nothing: a later getItem returns either the old text or the new text, never half of each.

save.jswhole filejs
const KEY = "mitra:chats";
function save(chats) {
  localStorage.setItem(KEY, JSON.stringify({ version: 2, chats }));
}

02Load: distrust everything

The load function in the first live example handles nine situations: nothing saved, storage blocked, bad JSON, an old version, the wrong shape, a newer version, the text null (valid JSON that is not a store), one bad message among good ones, and an answer that was still streaming. Read the nine output lines and match each to a branch of the code.

Two of the lines matter most. After a "reload", a message that was streaming comes back as error, with the text it had. And a half-written JSON string gives an empty list and a note, not a crash.

Load: distrust everythingdiagram
Rendering diagram…

03Save when it settles, not on every token

A throttled save plus a save when the page is hidden is the usual shape. The snippet below is what runs in a real browser (it is not live here, because the preview page doesn't close).

when-to-save.jswhole filejs
let timer = null;
function saveSoon(chats) {            // call this on every change
  if (timer) return;                  // already scheduled: the later call will save the newest state
  timer = setTimeout(() => { timer = null; save(latestChats()); }, 400);
}
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") save(latestChats()); // last reliable moment on mobile
});

04When storage is full or blocked

The second live example uses a stand-in storage with a tiny limit that throws the same error name a browser does. The save function drops the oldest chat and retries until it fits, and reports what it did so the screen can say so. A single chat too big for any space, and blocked storage, both return a clear failure instead of throwing.

05Two tabs, one storage

The third live example lets two tab stand-ins each add a message after opening the same chat. With overwrite the first tab's message is lost: storage ends with 2 messages instead of 3. With merge both survive. The storage event is how a real tab would learn that it should reload and merge.

other-tabs.jswhole filejs
window.addEventListener("storage", (e) => {
  if (e.key !== "mitra:chats") return;   // some other key changed
  reloadAndMerge();                      // another tab saved: refresh this tab's copy
});
// the event is NOT fired in the tab that made the change

06What the server keeps

When chats must outlive the browser, the same record becomes two tables. The assistant row is written by the server when the stream ends, so a user who closes the tab mid-answer still gets the finished answer next time.

tables.sqlwhole filesql
CREATE TABLE chats (
  id          uuid PRIMARY KEY,
  user_id     uuid NOT NULL,                 -- from the session, never from the request body
  title       text NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  updated_at  timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE messages (
  id          uuid PRIMARY KEY,
  chat_id     uuid NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
  role        text NOT NULL,                 -- user | assistant | system
  content     text NOT NULL,                 -- or jsonb for parts (Topic 5.4)
  status      text NOT NULL,                 -- done | error | stopped
  created_at  timestamptz NOT NULL DEFAULT now()
);

Try it yourself

  1. 1

    Stop interrupted answers from being fixed

    In the first example, change the .map((m) => (m.status === "streaming" || m.status === "pending" ? … : m)) part so it returns m unchanged, and run it.

    The "after reload" line now ends with streaming:"It left Pune this mor". A real screen would show "Mitra is typing…" on that message forever, and a busy flag derived from it would keep the form locked.

  2. 2

    Pretend the version is current

    In the first example, change the last test's version: 3 to version: 2 and run it.

    The last line becomes newer version: restored 0 chat(s): the data is now accepted as current. With a real newer version the code must refuse to touch it, and also refuse to save over it, or an old tab left open will overwrite the newer data.

  3. 3

    Make the storage smaller

    In the second example set the limit 480 to 250.

    Only one chat now survives (saved: 1, dropped: 3). A user with many chats would lose most of their history without a word, which is why the result tells the screen what was dropped.

Code & diagrams

Load: versions, corrupt data and an interrupted answer

Each line is one situation the load function must survive. `JSON.parse("null")` succeeds, so the shape check must come before any `data.version`. The unfinished answer "It left Pune this mor" comes back as an error with its text, not as a running stream.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

first visit: nothing saved yet
after reload: restored 1 chat(s) | done:"Where is ORD-1042?" error:"It left Pune this mor"
version 1 data: restored 1 chat(s) | done:"Hi" done:"Hello!"
half-written JSON: corrupt JSON, ignored
wrong shape: unexpected shape, ignored
newer version: saved by a newer version, left alone
the text null: unexpected shape, ignored
one good, two bad messages: restored 1 chat(s) | done:"Hi"
blocked storage: storage unavailable
When storage is full or blocked

The storage here is a stand-in with a tiny limit, so the failure shows up at once. The error names (QuotaExceededError, SecurityError) are the ones a real browser uses. The result object is what the screen would use to say "older chats were removed".

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

plenty of room: { ok: true, saved: 4, dropped: 0 }
room for about two chats: { ok: true, saved: 2, dropped: 2 }
one chat too big for any room: { ok: false, reason: 'QuotaExceededError', dropped: 0 }
blocked storage: { ok: false, reason: 'SecurityError', dropped: 0 }
Two tabs overwrite each other

Two plain objects stand in for two tabs, because a live preview has only one page. The mistake and the fix are the same as in a real browser.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

overwrite: storage has 2 messages: m1,m3 | tab A sees 2 | tab B sees 2
merge: storage has 3 messages: m1,m2,m3 | tab A sees 3 | tab B sees 3

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Trust the stored text

In the first example, remove the try { line and the } catch { return { chats: [], note: "corrupt JSON, ignored" }; } lines around data = JSON.parse(raw);, leaving just the JSON.parse call.

terminal
$ Console
── what you'll see ──
version 1 data: restored 1 chat(s) | done:"Hi" done:"Hello!"
Uncaught SyntaxError: Unterminated string in JSON at position 38 (line 1 column 39)

Myth vs fact

Myth

localStorage is a database.

Fact

It is a small, synchronous string store with no queries, no transactions and no size guarantees. It is fine for a few chats; use IndexedDB or a server for more.

Myth

Saved data always matches the current code.

Fact

It was written by an older version, a different tab, a crash, or an extension. Version it and validate it every time.

Myth

A streaming message can be resumed after a reload.

Fact

The connection is gone. Mark it as interrupted and let the user retry. Resuming needs special support on the server.

Myth

It is safe to store anything in localStorage.

Fact

Any script running on your page can read it. Never store keys, tokens or other secrets there.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Write the whole state under one key so a save is atomic. Several keys (chat-1, chat-2, an index) can be left out of step if the page closes between writes.

  • ▸

    Keep message content out of the always-loaded index. For many chats, store a small list of { id, title, updatedAt } under one key and each chat's messages under its own key, so opening the app doesn't parse megabytes.

  • ▸

    IndexedDB is the next step up: asynchronous, much larger, structured data. It is more code, and worth it when chats include images or hundreds of long conversations.

  • ▸

    On the server, accept only the *new* user message plus a chat id, and load the history yourself. Then the client cannot rewrite history, and you can enforce your own limits (Topic 4.3).

Remember this

  1. 1

    The problem this solves: React state lives in memory. Reload the page, close the tab or let the phone discard it, and the whole conversation is gone. Users lose long chats, and developers lose their place every time they save a file. The fix is short to write, and short to write wrong: saved data outlives your code, so the next version of your app must cope with whatever an older version, a crash or another tab left behind.

  2. 2

    **localStorage in four sentences.** It stores text under a key for one website (one *origin*: scheme, host and port). It is synchronous: reading and writing block the page, so big writes make it stutter. It holds only strings, so you JSON.stringify on the way in and JSON.parse on the way out. And it is small (a few megabytes per site; the exact limit depends on the browser), and setItem throws when it is full. In this course's live preview, localStorage is an in-memory copy that starts empty on every Run, so the examples save and "reload" within one run; in a real browser the data survives the reload.

  3. 3

    What to save. *(Recall from Topic 4.1: a message is id, role, content, status, createdAt.)* The chats, their titles, and each message's id, role, content, status and createdAt. Do not save things that only describe the current moment (busy, the live error, the AbortController), because they will be wrong at the next start. Never save secrets or API keys: anything in localStorage can be read by any script that runs on your page, so an injected script (Phase 9) would read it. Be careful with personal data too (Topic 9.7).

  4. 4

    Version it. Save { version: 2, chats: [...] }, not a bare array. When you change the shape next month, the new code reads version, sees an older number, and upgrades the data (a *migration*) instead of crashing. The first live example reads version-1 data (a bare list of messages) and turns it into version 2. If the stored version is *newer* than the code understands (an old tab opened after an update), leave the data alone and don't overwrite it.

  5. 5

    Validate everything you load. JSON.parse throws on half-written text. The result may be the wrong shape (chats is a string), an old version, or data a browser extension or a person edited. Wrap the read in try … catch, check the shape, drop individual bad messages rather than the whole store, and fall back to an empty chat list. A chat that doesn't open is worse than a chat list that starts empty.

  6. 6

    Never restore a running answer. If the page closed while an answer was streaming, the saved message has status streaming or pending, but the connection is gone and nothing will ever finish it. On load, change those messages to error and keep their text, so the screen shows a cut-off answer with a retry, and the chat is idle, not stuck on "Mitra is typing…" forever. (A few servers can resume a stream; most apps don't.)

  7. 7

    When to write. Saving on every token rewrites the whole JSON hundreds of times per answer, on the main thread. Better: save when the state settles (done, error, stop, a new chat), save at most every few hundred milliseconds while streaming, and save once more when the page is hidden (visibilitychange to hidden, or pagehide), which is the last reliable moment on mobile. Writes in those last moments are best effort.

  8. 8

    Storage fails. setItem throws QuotaExceededError when full. In some browser modes localStorage isn't allowed and even touching it throws SecurityError. Catch both. When full, delete the oldest chats and try again, and tell the user ("Older chats were removed to save space"). When storage is blocked, keep working from memory and show a small "Chats won't be saved in this browser" note. The second live example shows both failures.

  9. 9

    Two tabs. Every tab keeps its own copy in memory and they share one storage. If tab A saves its whole copy and then tab B saves *its* whole copy, A's newest message is gone: last write wins, and nothing warns you. Fix it by re-reading storage right before writing and merging by message id, and by listening for the storage event (the browser fires it in the *other* tabs when a key changes) to refresh. The third live example loses a message the first way and keeps it the second way.

  10. 10

    When storage in the browser isn't enough: the server. If chats must follow the user to another device, are long, searchable, or kept for a set time, store them on your server, linked to the signed-in user. Store a chats table (id, user id, title, created and updated times) and a messages table (id, chat id, role, content or parts, status, created time, and later the model and token usage). Write the assistant message when the stream ends, on the server. Take the user id from the session, never from the request body. Make "delete chat" really delete. Topic 5.8 shows how the AI SDK helps with this.

Explain it without notes

01

Why must a stored chat be validated every time it is loaded?

02

What should happen to a message that was streaming when the page closed?

03

How can two tabs lose each other's messages, and how do you prevent it?

04

What do you store on the server for a chat, and who decides the user id?

Practice

01

Write trimStore(chats, max) that keeps only the max most recently updated chats. Each chat has an updatedAt number.

02

Write saveSafely(chats) that calls localStorage.setItem and returns "saved", "full" or "blocked" instead of throwing.

Trade-offs

  • ↔

    localStorage is the smallest thing that works: no server, no account, instant. It is per browser and per device, small, and visible to every script on your page. A server gives sync, size and control, and costs a backend, auth and a privacy policy.

  • ↔

    Saving often protects against crashes and costs main-thread time. Saving rarely is cheap and loses more when the tab dies. A short throttle plus a save when the page is hidden is the usual balance.

Done when you can

  • I can save chats under one versioned key and load them back.

  • I can validate stored data and fall back safely when it is corrupt or unusable.

  • I can mark an interrupted answer instead of restoring it as running.

  • I can handle a full or blocked storage without crashing.

  • I can explain how two tabs overwrite each other and one way to avoid it.