Skip to content
Consultation

Chat History That Lives in Local Storage

Adityo Guni Waluyo

The AI search box now remembers yesterday's questions. The trick: localStorage, a 30-turn cap, and a try/catch that silently surrenders when the browser says no

The search popup had a problem. You type a question, get an answer, close the popup, reopen it later — gone. Every question was disposable. I wanted to flip that. Make the popup a scrollable thread where each Q&A pair persists, and you can jump back to any previous turn with numbered pagination at the bottom: 1, 2, 3, 4, 5. Click number 5, yesterday's answer scrolls into view with its sources.

The straightforward part was the pagination. One number per turn. Active number highlights. Click scrolls to that turn and displays its sources. New questions auto-advance the number. The popup renders in whatever mode that turn used (grid for about, list for search) regardless of the current toggle. That part was plumbing.

The part I didn't think hard enough about was the persistence layer.

localStorage Looks Easy Until It Isn't

My first instinct was localStorage.setItem directly. Dump the turns array, done. And yeah, it works on most machines. But I've been bitten before — you test everything on your own Chrome with a normal profile, it all works, you ship it, then a user reports "my history disappeared" and you have no idea why.

The answer was sitting in front of me: private browsing. Safari's private mode gives you a localStorage object that silently discards everything you write. Firefox does something worse — it throws QuotaExceededError when you try to write even a single byte. Chrome in guest mode can behave like either. The MDN docs [7] lay this out pretty clearly, but until you've debugged a "it works on my machine" bug caused by this, the docs feel theoretical.

So the fix is boring but non-negotiable. Wrap every setItem in a try/catch. If it throws, you swallow the error and the app keeps working — you just don't persist. No crash, no unhandled exception, no confused user. The history just doesn't survive a page reload. That's an acceptable degradation for a feature that makes a search popup slightly nicer.

try {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch {
  // QuotaExceededError in private browsing — fail silently
}

The storage key is versioned: adityo.chat.history.v1. Not because I expect to version it often, but because the first time you ship a localStorage schema and need to change it, you'll wish you'd thought about migration. The v1 means if I ever need a breaking change, I bump the key and old data is just orphaned instead of parsed into garbage. There's a 30-turn cap on the array — arbitrary, but the popup isn't meant to be an archive. Old turns get trimmed from the front.

Why sessionStorage Isn't the Answer Either

I considered sessionStorage during the design phase. It resets per tab, so no quota headaches across sessions, right? But that's the whole problem — I *want* the data to live across page reloads and new tabs. The search popup is a transient UI element. You open it, search, close it. If the history dies every time you close the popup, what was the point of writing it at all?

sessionStorage makes sense for state that's scoped to one page interaction — form drafts, temporary filters. For something like chat history that a user might reasonably want to revisit five minutes later, you need localStorage. And that means you need to deal with quota failures [8].

The broader lesson is cheap for me to state and annoying to implement: client-side persistence must be designed to fail gracefully. The browser is not your database. It can say no. It can say no silently. It can say no differently depending on which browser, which mode, which OS. The only honest defense is feature detection plus try/catch. There's no fallback that gives you the same guarantees — IndexedDB has the same quota issues, cookies have size limits, and none of them work offline by default.

I added a clear-history button too, with an inline confirmation before wiping. Simple enough — localStorage.removeItem inside a confirm dialog. But even that goes through the same try/catch, because if the write failed, the remove might behave oddly too.

The Real Interaction

The actual UX improvement was smaller than I expected but more useful than I thought. The pagination numbers are just a list of clickable indicators at the bottom of the popup. Each represents one Q&A turn. Clicking number 3 scrolls to that turn and shows the sources it was paired with. The active number follows whichever turn you're looking at. It's the kind of micro-interaction that doesn't feel like a feature until you try going back without it.

The commits are in the repo if you want to see the exact implementation. I also wrote about building the search toggle that this popup sits on top of — how I ended up with two rendering modes for the same component.

---

Sources:

[7] MDN, "Using the Web Storage API" — browsing context, private mode behavior, and data isolation. MDN Web Docs

[8] MDN, "Storage.setItem()" — QuotaExceededError when storage is full or unavailable. MDN Web Docs

Related articles