How IndexedDB Gives an AI Chat App Offline Memory Without Breaking Server Authority
Let me first briefly introduce what AI Mind is. It's an AI Chat project written in Next.js, but unlike a typical chat box, it's not just "you ask, the AI answers." Instead, it breaks a single conversation into multiple displayable segments: the AI calling external tools to look up information, reading local files (resources), triggering preset prompt templates, executing a set of stable task patterns (Skills), or even running a multi-step autonomous task (Agent). The execution process of each step is visualized on the interface. These displayed contents—JSON returned by tools, Skill execution steps, Agent decision roadmaps, and AI-generated document artifacts—constitute what is called "rich UI chat records."
The problem is: these rich UI chat records are all lost when the page is refreshed.
This is not something that can be solved by "adding a localStorage cache." These contents have complex structures and significant volume, and some states—like a half-finished sentence during AI streaming output, or an Agent paused waiting for human confirmation—cannot and should not be persisted.
More critically, this local persistence must not break the existing architectural boundaries. In AI Mind, the server side has two things with distinct responsibilities: one is the Conversation Registry, responsible for recording "which conversations exist and who each conversation belongs to"; the other is ThreadState, responsible for providing the AI with short-term memory of "what was recently discussed and what important decisions were made previously." Neither of these saves the complete chat history—the Conversation Registry only manages identity, and ThreadState only keeps key information from the most recent rounds of conversation. The local snapshot can only manage UI display restoration; it cannot secretly upgrade itself to become a source of context for the AI, nor can it bypass the server-side conversation ownership verification.
What this version does is: Use the browser's IndexedDB to save complete UI snapshots of recent conversations. After a page refresh, restore the display from local storage first, then let the server perform authoritative calibration. Additionally, a unified conversation deletion capability for both desktop and mobile has been added. Deleting a conversation will simultaneously clean up the server-side Registry, ThreadState, and the local snapshot.
1. Three Layers of Data, Each Managing Its Own, Not Replacing Each Other
Let me first explain a few concepts that will be used repeatedly later:
- Conversation Registry: A server-maintained conversation registration table that records conversation IDs, titles, and ownership, retaining up to 10 recent conversations. It is the authoritative source for conversation identity.
- ThreadState: Short-term context saved by the server for the AI runtime—bounded recent text, summaries, pinned decisions. It does not contain the complete chat history or rich UI components.
- Local Snapshot: The complete user-visible UI display record saved in the browser's IndexedDB. It is the only complete source for restoring the chat interface after a refresh.
The relationship between these three is clarified in a single table:
| Data Object | Storage Location | Authoritative Responsibility | What It Doesn't Manage |
|---|---|---|---|
| Local Complete UI Snapshot | Browser IndexedDB | Restore complete chat display after refresh (text + rich UI components) | Conversation identity, AI context |
| Server Conversation Registry | Server PostgreSQL | Conversation identity, ownership, retention of 10 recent conversations | Complete message history |
| Server ThreadState | Server PostgreSQL | AI runtime short-term context | Complete rich UI display restoration |
Why can't these be merged into one system? Because the server does not store the complete chat history—ThreadState only retains text summaries from the most recent rounds of conversation (bounded recent text), excluding rich UI content like tool call results, Agent execution traces, and document artifacts. If recovery relied solely on server data, only the most recent rounds of plain text would be visible after a refresh, and all previously run Agent execution processes would be lost. Conversely, if the local complete snapshot were sent to the AI as model context, it would mean secretly turning display history into the AI's decision-making basis—this crosses a boundary.
What if the local snapshot and server ThreadState content are inconsistent? The answer is simple: Inconsistency is allowed, and sending messages is allowed to continue. The local snapshot only determines the UI display, and the server ThreadState determines the AI context. The two are not silently merged, overwritten, or patched. Requiring perfect consistency before allowing a send would turn the local cache into a synchronous dependency blocking the main chat flow—which is worse than the inconsistency itself.
2. Why IndexedDB, Not localStorage
Before v0.4.7, AI Mind was already using localStorage to save selected/draft hints—but those were simple string markers, a completely different magnitude from rich UI snapshots.
A single tool call result might contain hundreds of lines of JSON output, one Agent execution could produce multiple Agent Step display blocks, and one artifact could be a complete Markdown document. Storing this content in localStorage has three fatal flaws: capacity is typically only 5-10MB, setItem() is a synchronous API that blocks the UI, and it only supports strings, requiring manual JSON.parse/stringify for structured data.
IndexedDB solves all three problems: asynchronous API, native support for structured objects, and capacity far larger than localStorage. Data persists after a browser restart, fulfilling the promise of cross-restart retention.
No IndexedDB wrapper dependency was introduced—the native API is used directly, with transaction lifecycles encapsulated through runStoreOperation(). The database name is fixed as ai-mind-local-chat, with two Object Stores:
conversation-index: Conversation index, storing metadata like IDs, titles, and times for the 10 most recent conversations, along with two UI hints:selectedConversationIdandisDraft.conversation-snapshots: Conversation snapshots, stored independently perconversationId, with each record containing the complete recoverable message list for that conversation.
3. Not All Messages Can Be Saved: Filtering Rules for Stable Snapshots
This is the most critical layer of filtering in v0.4.7. The AI Mind frontend has a core Hook called useChatStream, which manages all messages for the current conversation—each message is a MindMessage object containing multiple parts (message parts), such as a piece of text, a tool call result, or an Agent execution step. But this message list contains many things that cannot be persisted—half-finished streaming output, control signals for Agent pauses awaiting human confirmation (AgentInterrupt), thread memory status hints (thread-memory-status), etc. If these were also written into IndexedDB, the restored content after a refresh would be unusable half-baked data.
stable-snapshot.ts (Stable Snapshot Projection) is responsible for extracting the "safely recoverable" subset from the message list. Its core logic is simple—a whitelist plus status filtering:
// Only keep these 8 part types—corresponding to the various display blocks users can see in the chat interface
// agent-step: Agent decision step reasoning: AI reasoning process
// tool: Tool call result resource: File/resource preview
// skill: Skill execution display workflow-progress: Task progress
// text: Plain text prompt: Prompt template
const RECOVERABLE_PART_TYPES = [
'agent-step', 'prompt', 'reasoning', 'resource',
'skill', 'text', 'tool', 'workflow-progress',
]
function isRecoverablePart(part: MindMessagePart): boolean {
// Status must be unset or completed—streaming/pending are rejected
if (part.status && part.status !== 'completed') return false
return RECOVERABLE_PART_TYPES.includes(part.type)
}
The complete filtering rules for projectRecoverableMessages():
- Only keep user and assistant messages—system messages and control messages do not enter the snapshot.
- Only keep messages with status unset or
completed—streaming, failed, aborted, and pending are all filtered out. - Only keep part types on the whitelist—control parts like
thread-memory-statusandAgentInterruptdo not enter. - Filter out incomplete artifacts—only keep text artifacts in a completed state.
- Remove empty messages—messages with no recoverable parts after filtering are directly discarded.
- Capacity trimming: A maximum of 120 messages; when exceeded, deletion starts from the oldest complete message.
Snapshots are only committed after streaming completes, a Q&A deletion completes, or a regeneration completes. During streaming, on request failure, or on user abort, the current round is not committed, and the last successful stable snapshot is retained. This strategy ensures one thing: what is restored after a refresh is definitely "content that was previously seen in its complete form."
4. Local-First Recovery: Local First, Then Server, Degrade on Failure
The complete recovery chain after a page refresh is a three-step process. First, let me explain a key concept—bounded hydration: The data returned by the server's /api/chat/thread endpoint is "conversation recovery data," but it only contains plain text from the most recent rounds of conversation, not rich UI components. In other words, it is not a complete chat record backup, but rather "the recent context the AI needs to know."
[Page Refresh]
│
├─ 1. Read IndexedDB
│ ├─ Read conversation-index → Immediately render the recent conversation list
│ └─ Read conversation-snapshots[selectedId] → Immediately render current conversation messages
│
├─ 2. Request GET /api/chat/conversations
│ ├─ Success → Replace local index with server list, clean up old conversations not in the list
│ └─ Failure → Retain local data, enter read-only cache state
│
└─ 3. Request GET /api/chat/thread?conversationId=xxx
├─ Success + Local snapshot exists → Retain local display, server only confirms
├─ Success + No local snapshot → Degrade display with bounded hydration
├─ Server returns "ThreadState temporarily unavailable" error + Local snapshot exists → Read-only cache
└─ Failure + No local snapshot → Recovery failed, maintain empty state
Step 2's "authoritative server calibration" deserves a separate explanation. use-conversation-sessions.ts (conversation list state management) records a local index baseline before requesting the registry:
// Record baseline before request
const baseline = {
revision: localIndex.revision,
conversationIds: localIndex.conversations.map(c => c.id),
}
// After successful request, use baseline for authoritative replacement
await reconcileLocalConversationIndex(baseline, serverPayload)
The behavior of reconcileLocalConversationIndex():
- Conversations in the server list → Update local index metadata (title, time), do not touch the local message snapshot for that conversation.
- Conversations in the baseline but not in the server list → Hard delete from local index, and delete the corresponding local message snapshot.
- New conversations created by other tabs after the request was initiated → Retained, not lost due to this authoritative replacement.
- Request failure/timeout/invalid → No cleanup performed, all local data retained.
The key to Step 3: The bounded hydration returned by the server only contains a limited number of plain text entries, not rich UI components. If a complete local snapshot already exists, the server data is only used to confirm "this conversation is still valid and messages can continue to be sent." If no local snapshot exists, bounded hydration serves as a degraded display, but it won't mislead the user into thinking this is the entire chat history.
5. Only Two Minimal Adjustments on the Server Side
v0.4.7 does not modify the @ai-mind/stream-core public protocol and does not add new PostgreSQL chat history business tables. Only two places were changed on the server side.
5.1 When ThreadState is Unavailable, Return an Explicit Error Code Instead of Pretending Success
Previously, when /api/chat/thread failed to read ThreadState, it returned an empty success response with restored: false. The frontend could not distinguish between "there truly is no bounded state" and "the server is temporarily unavailable"—these are two completely different degradation strategies.
Now it returns HTTP 503 (Service Unavailable) with the explicit error code CHAT_THREAD_HYDRATION_UNAVAILABLE. When the frontend's use-chat-stream.ts (core Hook for chat streaming interaction) receives this error: if a local snapshot exists, it enters a read-only cache state, displaying local messages but prohibiting sending; if no local snapshot exists, recovery simply fails.
5.2 Added DELETE /api/chat/conversations
Conversation deletion is not just about hiding the frontend list. The complete flow for DELETE /api/chat/conversations:
[User clicks delete → Confirmation dialog → Confirm]
│
├─ 1. Server verifies current browser session's ownership of the conversationId
├─ 2. Delete ThreadState data via the chat-memory checkpointer
├─ 3. Remove the conversation from the Conversation Registry
├─ 4. Return the updated registry payload (including new conversation list + fallback selected)
│
└─ 5. Client receives success response
├─ Delete the entry from the local IndexedDB index
└─ Hard delete the local message snapshot for that conversation
There is an important design decision here: Do not clean up local data if the deletion fails. If the server-side Registry deletion succeeds but ThreadState deletion fails, the client retains the local snapshot, so the user can at least still see the history. Conversely, if the local data were cleaned first and then the server deletion failed, the user would end up with nothing on either end.
The implementation order of deleteConversation() in conversation-registry.ts (conversation registry service) is to delete ThreadState first, then the Registry entry—because these two operations are not in the same database transaction and cannot guarantee "either both succeed or both roll back." Therefore, priority is given to ensuring ThreadState is cleaned up, avoiding a residual state where "it's gone from the Registry but historical data still exists."
6. What If the Server Goes Down: Read-Only Cache Degradation
When the server is unavailable, the local snapshot must not create the illusion that "chatting can continue." The core of the degradation strategy is: Can view, cannot act.
The condition for triggering the read-only cache is simple—the server registry request fails but the local index still exists, or thread hydration returns a "ThreadState temporarily unavailable" error but a local snapshot exists. If either condition is met, the page enters a read-only state.
In the read-only state, sending messages, creating new conversations, switching conversations, and deleting conversations are all disabled. An amber prompt is displayed at the top of the page, clearly informing the user that "the current display is from a local cache and has not yet been confirmed by the server," with a "Retry connecting to server" button next to it. The user can click retry, or simply refresh the page; once the server is available again, normal status can be restored.
The control logic for this read-only cache is unified within instantmind-page.tsx (main chat page component), merging the degradation signals from both the useConversationSessions and useChatStream layers, ensuring a half-baked state like "the list can be switched but the chat area cannot send" never occurs.
7. Interaction Details for Conversation Deletion
conversation-row-actions.tsx (conversation row action buttons) uses the project's existing shadcn/ui component library (a set of headless accessible components in the React ecosystem) with a combination of DropdownMenu + AlertDialog to implement a three-dot menu and a deletion confirmation dialog.
On desktop, the three-dot button appears on hover or focus; on mobile, since there is no hover, the three-dot button is always visible. The menu only has a "Delete" option. The confirmation dialog displays the conversation title and a warning message, with Cancel and Confirm buttons. The button is disabled during the deletion process to prevent duplicate submissions. If deletion fails, the dialog remains, showing an error message. Upon successful deletion, the dialog closes, and the client performs an authoritative replacement using the registry payload returned by the server, cleaning up the local snapshot.
When deleting the current conversation, it automatically switches to the fallback conversation returned by the server or a blank draft; when deleting a non-current conversation, the current display remains unchanged.
8. Multi-Tab and Capacity Boundaries
v0.4.7 does not promise real-time synchronization across tabs, but concurrent writes must not corrupt data consistency.
Concurrent writes for different conversations are simple: snapshots for different conversationIds are stored independently and do not overwrite each other. Updates to the shared index merge metadata by conversationId—Tab A updating metadata for Conversation A will not lose the metadata for Conversation B that Tab B just wrote.
Concurrent writes for the same conversation use revision-based optimistic locking. Each snapshot write carries a monotonically increasing revision, and before writing, it compares against the currently stored revision—an older version cannot overwrite a newer one. No message-level merging is performed. If two tabs independently perform deletions or regenerations on the same conversation, no merge is attempted; instead, the newer stable write is retained.
Regarding capacity, a single snapshot has a maximum of 120 messages; when exceeded, trimming starts from the oldest complete message. When local storage quota is exhausted, old messages are trimmed first and a retry is attempted; if it still fails, it silently degrades without affecting the main chat flow.
9. Summary
Looking back, the three most important things v0.4.7 accomplished:
First, the data boundaries were clearly separated. The local snapshot manages display, the server Registry manages identity, and the server ThreadState manages AI context. The three layers do not cross, merge, or replace each other. When adding an account system, full PostgreSQL history, or cross-device synchronization later, there will be no need to go back and decouple this layer.
Second, only stable data is saved; incomplete data is not. Half-finished streaming output, failed requests, and pending Agent reviews are all excluded from the snapshot. What is restored after a refresh is definitely content that was previously seen in its complete form.
Third, when the server is unavailable, it's read-only, not pretending chat can continue. The local snapshot enhances the experience but does not replace the server. In the read-only cache state, history can be reviewed, but sending, switching, and creating are all impossible.
Code changes are concentrated in 11 files under apps/webapp, with a new local-chat-persistence/ module added (schema + store + stable-snapshot), and adjustments made to useConversationSessions, useChatStream, instantmind-page, and two API routes. 7 focused test suites with a total of 63 tests passed. TypeScript type checking, code linting, and Git diff checks all passed. Real browser smoke tests covered plain text recovery, rich UI recovery, multi-tab isolation, and the deletion chain.
Project Links
👉 GitHub: https://github.com/HWYD/ai-mind
👉 Live Demo: https://ai.hwyblog.cloud/instant-mind
If this article or the AI Mind project has been helpful to you, feel free to give the project a Star⭐. This support is very important to me and will motivate me to continue documenting the implementation process, design trade-offs, and post-mortem reviews for subsequent versions.
Top 1 of 4 from juejin.cn, machine-translated. The original thread is authoritative.
Thanks for the article, host. I followed your approach and built something similar: https://github.com/webos2019/ai-pi-agten. If you have time, please review it.
The approach looks fine, but your project architecture could be optimized. If you're separating frontend and backend, tidy up the architecture and layer the file structure. You can have AI do the architecture design for you. If the article project helped, feel free to drop a star [rose].
No problem, thanks. I'll refactor it into an event-driven agent later.