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
setItemthrowsQuotaExceededError. - 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.
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.
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).
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.
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 change06What 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.
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
Stop interrupted answers from being fixed
In the first example, change the
.map((m) => (m.status === "streaming" || m.status === "pending" ? … : m))part so it returnsmunchanged, 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
Pretend the version is current
In the first example, change the last test's
version: 3toversion: 2and 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
Make the storage smaller
In the second example set the limit
480to250.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
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
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 unavailableThe 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
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 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
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 3Break 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.
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
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
**
localStoragein 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 youJSON.stringifyon the way in andJSON.parseon the way out. And it is small (a few megabytes per site; the exact limit depends on the browser), andsetItemthrows when it is full. In this course's live preview,localStorageis 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
What to save. *(Recall from Topic 4.1: a message is
id,role,content,status,createdAt.)* The chats, their titles, and each message'sid,role,content,statusandcreatedAt. Do not save things that only describe the current moment (busy, the live error, theAbortController), because they will be wrong at the next start. Never save secrets or API keys: anything inlocalStoragecan 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
Version it. Save
{ version: 2, chats: [...] }, not a bare array. When you change the shape next month, the new code readsversion, 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
Validate everything you load.
JSON.parsethrows on half-written text. The result may be the wrong shape (chatsis a string), an old version, or data a browser extension or a person edited. Wrap the read intry … 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
Never restore a running answer. If the page closed while an answer was streaming, the saved message has status
streamingorpending, but the connection is gone and nothing will ever finish it. On load, change those messages toerrorand keep their text, so the screen shows a cut-off answer with a retry, and the chat isidle, not stuck on "Mitra is typing…" forever. (A few servers can resume a stream; most apps don't.) - 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 (visibilitychangetohidden, orpagehide), which is the last reliable moment on mobile. Writes in those last moments are best effort. - 8
Storage fails.
setItemthrowsQuotaExceededErrorwhen full. In some browser modeslocalStorageisn't allowed and even touching it throwsSecurityError. 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
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
storageevent (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
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
chatstable (id, user id, title, created and updated times) and amessagestable (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
Why must a stored chat be validated every time it is loaded?
What should happen to a message that was streaming when the page closed?
How can two tabs lose each other's messages, and how do you prevent it?
What do you store on the server for a chat, and who decides the user id?
Practice
Write trimStore(chats, max) that keeps only the max most recently updated chats. Each chat has an updatedAt number.
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.