Persistent Conversation Storage
Why Conversations Disappear
You've built a beautiful chat UI. The user has a deep, ten-minute conversation with an AI assistant. They accidentally hit refresh. Everything is gone.
This is the number one complaint users have with AI chat interfaces. And it's not just about refresh — they switch devices, close a tab, come back tomorrow. They expect their conversations to be there, like they are in iMessage, Slack, or ChatGPT.
Persistent conversation storage is what separates a demo from a product. And getting it right involves more decisions than you might expect: where to store data, how to structure it, how to load it efficiently, and how to keep it in sync across tabs and devices.
Think of conversation storage like a filing cabinet with multiple drawers. The top drawer (in-memory state) is open on your desk — fast to grab from, but everything falls out if someone bumps the desk. The middle drawer (IndexedDB) is in your office — survives desk bumps, but you can only access it from this room. The bottom drawer (server database) is in a fireproof vault — accessible from anywhere, survives anything, but takes a walk down the hall to reach. A production app uses all three: the top drawer for speed, the middle for offline resilience, and the bottom for cross-device durability.
Storage Tier Decision
Not every app needs a server database. Not every app can get away with just useState. The right answer depends on your requirements.
| Storage Tier | Capacity | Speed | Survives Refresh | Cross-Device | Best For |
|---|---|---|---|---|---|
| useState / useRef | Limited by RAM | Instant | No | No | Throwaway demos, ephemeral chats |
| localStorage | 5-10 MB | Sync (blocks main thread) | Yes | No | Small config, last conversation ID |
| IndexedDB | Hundreds of MB+ | Async (non-blocking) | Yes | No | Full offline-first storage |
| Server database | Unlimited | Network latency | Yes | Yes | Production apps, multi-device sync |
Here's the decision framework most production apps follow:
- In-memory only — prototyping, or intentionally ephemeral chats (think: "This conversation won't be saved")
- IndexedDB — single-device apps, offline-first PWAs, or as a local cache layer in front of server storage
- Server + IndexedDB cache — production apps where users expect to sign in on another device and see their history
Data Model Design
Before writing any storage code, you need a solid data model. AI conversations have a natural hierarchy: a user has many conversations, each conversation has many messages, and each message can have multiple content blocks.
The Schema
interface Conversation {
id: string
userId: string
title: string
model: string
createdAt: number
updatedAt: number
messageCount: number
isArchived: boolean
}
interface Message {
id: string
conversationId: string
role: 'user' | 'assistant' | 'system'
content: ContentBlock[]
createdAt: number
tokenCount?: number
}
type ContentBlock =
| { type: 'text'; text: string }
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
| { type: 'tool_result'; toolUseId: string; content: string; isError?: boolean }
A few things worth calling out:
Why content is an array of blocks, not a string. Modern AI APIs (Anthropic, OpenAI) return structured content. An assistant message might contain text and a tool call in the same response. If you flatten this to a string, you lose the ability to render tool calls properly or replay them.
Why timestamps are numbers, not Date objects. IndexedDB can store Date objects, but they're painful to index and query. Unix timestamps (milliseconds) are numbers, which means they sort naturally, compare with < and >, and serialize to JSON without any conversion.
Why messageCount lives on the conversation. Counting messages requires loading them all or running a separate query. Denormalizing the count onto the conversation object lets you show "47 messages" in a conversation list without touching the messages table.
IDs: UUID vs ULID vs nanoid
You need unique IDs for conversations and messages. The choice matters more than you think:
// UUID v4 — random, no ordering
// "f47ac10b-58cc-4372-a567-0e02b2c3d479"
import { v4 as uuid } from 'uuid'
// ULID — time-sortable, 48-bit timestamp prefix
// "01ARZ3NDEKTSV4RRFFQ69G5FAV"
import { ulid } from 'ulid'
// nanoid — compact, URL-safe, configurable length
// "V1StGXR8_Z5jdHi6B-myT"
import { nanoid } from 'nanoid'
For conversations, ULIDs are the best choice. They sort chronologically by default (the first 48 bits encode a timestamp), which means your database index naturally orders conversations by creation time without a separate ORDER BY. They are also lexicographically sortable as strings, so even IndexedDB string indexes work correctly.
Client-Side Storage with IndexedDB
The raw IndexedDB API is notoriously painful. It uses an event-based pattern from 2011 that feels ancient compared to modern async/await. Nobody should write raw IndexedDB in 2026.
Dexie.js is the standard wrapper. It gives you a Promise-based API, schema versioning, and compound indexes — basically everything IndexedDB should have been.
Setting Up the Schema
import Dexie, { type Table } from 'dexie'
class ChatDatabase extends Dexie {
conversations!: Table<Conversation>
messages!: Table<Message>
constructor() {
super('chat-db')
this.version(1).stores({
conversations: 'id, userId, updatedAt, isArchived',
messages: 'id, conversationId, createdAt'
})
}
}
const db = new ChatDatabase()
The string after each table name defines the indexed fields. The first field is the primary key. Only indexed fields are queryable — but every field is still stored. Don't index everything; indexes cost write performance and storage.
CRUD Operations
async function createConversation(userId: string, model: string): Promise<Conversation> {
const conversation: Conversation = {
id: ulid(),
userId,
title: 'New conversation',
model,
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: 0,
isArchived: false,
}
await db.conversations.add(conversation)
return conversation
}
async function addMessage(
conversationId: string,
role: Message['role'],
content: ContentBlock[]
): Promise<Message> {
const message: Message = {
id: ulid(),
conversationId,
role,
content,
createdAt: Date.now(),
}
await db.transaction('rw', db.messages, db.conversations, async () => {
await db.messages.add(message)
await db.conversations.update(conversationId, {
updatedAt: Date.now(),
messageCount: await db.messages
.where('conversationId')
.equals(conversationId)
.count(),
})
})
return message
}
async function getConversations(userId: string): Promise<Conversation[]> {
return db.conversations
.where('userId')
.equals(userId)
.reverse()
.sortBy('updatedAt')
}
async function getMessages(conversationId: string): Promise<Message[]> {
return db.messages
.where('conversationId')
.equals(conversationId)
.sortBy('createdAt')
}
Why transactions matter for addMessage
Notice we wrap addMessage in a transaction. Without it, two things can go wrong. First, if messages.add succeeds but conversations.update fails (maybe the conversation was deleted between the two calls), you end up with an orphaned message. Second, if two tabs call addMessage simultaneously, the messageCount could be incorrect because both read the old count before either writes the new one. Transactions give you atomicity (all-or-nothing) and isolation (no interleaving).
Schema Migrations
Your data model will evolve. Dexie handles this with version numbers:
class ChatDatabase extends Dexie {
conversations!: Table<Conversation>
messages!: Table<Message>
constructor() {
super('chat-db')
this.version(1).stores({
conversations: 'id, userId, updatedAt, isArchived',
messages: 'id, conversationId, createdAt',
})
this.version(2)
.stores({
conversations: 'id, userId, updatedAt, isArchived, [userId+isArchived]',
messages: 'id, conversationId, createdAt, [conversationId+createdAt]',
})
.upgrade((tx) => {
return tx
.table('conversations')
.toCollection()
.modify((conv) => {
if (conv.isArchived === undefined) {
conv.isArchived = false
}
})
})
}
}
The compound index [userId+isArchived] lets you efficiently query "all active conversations for user X" in a single index lookup instead of filtering after the fact. The upgrade callback runs once per user when they load the new version — it's your migration script.
Server-Side Storage
For multi-device access, you need server-side storage. Here's a clean API design that works with any database (PostgreSQL, SQLite, PlanetScale, etc.).
API Design
// GET /api/conversations?cursor=<ulid>&limit=20
// Returns conversations sorted by updatedAt desc
// GET /api/conversations/:id/messages?cursor=<ulid>&limit=50
// Returns messages sorted by createdAt asc
// POST /api/conversations
// Body: { model: string }
// POST /api/conversations/:id/messages
// Body: { role: string, content: ContentBlock[] }
// PATCH /api/conversations/:id
// Body: { title?: string, isArchived?: boolean }
// DELETE /api/conversations/:id
// Soft-delete: sets deletedAt timestamp
Cursor-Based Pagination
Offset pagination (SKIP 100 LIMIT 20) breaks when data is added or removed between pages. Cursor pagination uses the last item's ID as a bookmark:
async function getConversations(userId: string, cursor?: string, limit = 20) {
let query = db
.select()
.from(conversations)
.where(eq(conversations.userId, userId))
.orderBy(desc(conversations.updatedAt))
.limit(limit + 1)
if (cursor) {
const cursorConv = await db
.select({ updatedAt: conversations.updatedAt })
.from(conversations)
.where(eq(conversations.id, cursor))
.limit(1)
if (cursorConv[0]) {
query = query.where(
lt(conversations.updatedAt, cursorConv[0].updatedAt)
)
}
}
const results = await query
const hasMore = results.length > limit
const items = hasMore ? results.slice(0, limit) : results
return {
conversations: items,
nextCursor: hasMore ? items[items.length - 1].id : null,
}
}
The limit + 1 trick is elegant: you fetch one extra item. If you get it, there's a next page. If not, you're at the end. The client never sees the extra item because you slice it off.
Loading Long Conversations
A conversation with 500 messages should not load all at once. Load the most recent chunk first, then let the user scroll up to load more:
async function getMessages(
conversationId: string,
cursor?: string,
limit = 50
) {
let query = db
.select()
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(desc(messages.createdAt))
.limit(limit + 1)
if (cursor) {
const cursorMsg = await db
.select({ createdAt: messages.createdAt })
.from(messages)
.where(eq(messages.id, cursor))
.limit(1)
if (cursorMsg[0]) {
query = query.where(lt(messages.createdAt, cursorMsg[0].createdAt))
}
}
const results = await query
const hasMore = results.length > limit
const items = hasMore ? results.slice(0, limit) : results
return {
messages: items.reverse(),
nextCursor: hasMore ? items[items.length - 1].id : null,
}
}
Notice that we query in descending order (newest first) but reverse the results before returning. This gives us the most recent messages first for pagination, but returns them in chronological order for display.
A common mistake: loading messages in ascending order and paginating from the beginning. This means the user has to wait for all 500 messages to load before seeing the most recent ones. Always load the newest messages first and paginate backwards. The user sees the latest context immediately and can scroll up to load history.
Auto-Titling Conversations
"New conversation" as a title is useless when you have 50 of them. Production apps auto-generate titles from the first exchange.
Strategy 1: First Message Truncation
The simplest approach — use the first few words of the user's first message:
function generateSimpleTitle(firstMessage: string): string {
const cleaned = firstMessage.replace(/\n/g, ' ').trim()
if (cleaned.length <= 60) return cleaned
return cleaned.slice(0, 57) + '...'
}
This is fast and free, but produces titles like "Can you help me understand how to..." — not great.
Strategy 2: LLM-Generated Titles
Send the first exchange to the LLM with a summarization prompt:
async function generateTitle(
userMessage: string,
assistantMessage: string
): Promise<string> {
const response = await fetch('/api/title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [
{
role: 'user',
content: `Summarize this conversation in 4-8 words as a title. No quotes, no punctuation at the end. Just the title.
User: ${userMessage.slice(0, 500)}
Assistant: ${assistantMessage.slice(0, 500)}`,
},
],
}),
})
const data = await response.json()
return data.title
}
The trick is to trigger this asynchronously after the first assistant response. Don't block the UI waiting for a title. Update it in the background:
async function handleFirstExchange(
conversationId: string,
userMsg: string,
assistantMsg: string
) {
generateTitle(userMsg, assistantMsg).then(async (title) => {
await db.conversations.update(conversationId, { title })
})
}
Strategy 3: Hybrid
Use truncation immediately (so the sidebar shows something), then replace with an LLM-generated title once it arrives. This gives instant feedback with eventual quality.
Search Across Conversations
Once users accumulate dozens of conversations, they need search. There are three tiers of search, each with different trade-offs.
Tier 1: Client-Side Filter (IndexedDB)
For small datasets (under 1000 conversations), a simple client-side filter works:
async function searchConversations(query: string): Promise<Conversation[]> {
const lowerQuery = query.toLowerCase()
return db.conversations
.filter((conv) => conv.title.toLowerCase().includes(lowerQuery))
.toArray()
}
This scans every conversation, but for a few hundred records it's fast enough. Don't over-engineer.
Tier 2: Full-Text Search on Messages
Searching message content is harder. IndexedDB doesn't have full-text search. You have two options:
Option A: Dexie's where().startsWithIgnoreCase() — works for prefix matching, not substring matching. Limited but free.
Option B: A lightweight search index. Build an inverted index in a separate IndexedDB table:
async function indexMessage(message: Message) {
const text = message.content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
.map((b) => b.text)
.join(' ')
const tokens = text
.toLowerCase()
.split(/\W+/)
.filter((t) => t.length > 2)
const uniqueTokens = [...new Set(tokens)]
await db.searchIndex.bulkPut(
uniqueTokens.map((token) => ({
token,
messageId: message.id,
conversationId: message.conversationId,
}))
)
}
Tier 3: Server-Side Full-Text Search
For production, use your database's built-in full-text search:
-- PostgreSQL full-text search
ALTER TABLE messages
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', content_text)
) STORED;
CREATE INDEX idx_messages_search ON messages USING gin(search_vector);
-- Query
SELECT m.*, ts_rank(search_vector, query) AS rank
FROM messages m, to_tsquery('english', 'indexeddb & pagination') query
WHERE m.search_vector @@ query
ORDER BY rank DESC;
PostgreSQL's tsvector handles stemming, stop words, and ranking out of the box. For most apps, this is plenty — you don't need Elasticsearch until you're at a massive scale.
Export and Import
Users want to own their data. Conversation export is both a feature and a trust signal.
Export Format
JSON is the natural choice. Keep it human-readable and self-contained:
interface ConversationExport {
version: 1
exportedAt: string
conversation: {
id: string
title: string
model: string
createdAt: string
messages: Array<{
role: string
content: ContentBlock[]
createdAt: string
}>
}
}
async function exportConversation(conversationId: string): Promise<string> {
const conversation = await db.conversations.get(conversationId)
if (!conversation) throw new Error('Conversation not found')
const messages = await db.messages
.where('conversationId')
.equals(conversationId)
.sortBy('createdAt')
const exportData: ConversationExport = {
version: 1,
exportedAt: new Date().toISOString(),
conversation: {
id: conversation.id,
title: conversation.title,
model: conversation.model,
createdAt: new Date(conversation.createdAt).toISOString(),
messages: messages.map((m) => ({
role: m.role,
content: m.content,
createdAt: new Date(m.createdAt).toISOString(),
})),
},
}
return JSON.stringify(exportData, null, 2)
}
function downloadExport(json: string, title: string) {
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}.json`
a.click()
URL.revokeObjectURL(url)
}
Import with Validation
Never trust imported data. Validate the structure before inserting:
function validateExport(data: unknown): data is ConversationExport {
if (typeof data !== 'object' || data === null) return false
const d = data as Record<string, unknown>
if (d.version !== 1) return false
if (typeof d.conversation !== 'object' || d.conversation === null) return false
const conv = d.conversation as Record<string, unknown>
if (typeof conv.title !== 'string') return false
if (!Array.isArray(conv.messages)) return false
return conv.messages.every(
(m: unknown) =>
typeof m === 'object' &&
m !== null &&
'role' in m &&
'content' in m &&
Array.isArray((m as Record<string, unknown>).content)
)
}
async function importConversation(json: string): Promise<string> {
const data = JSON.parse(json)
if (!validateExport(data)) {
throw new Error('Invalid conversation format')
}
const newId = ulid()
const conversation: Conversation = {
id: newId,
userId: getCurrentUserId(),
title: data.conversation.title,
model: data.conversation.model,
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: data.conversation.messages.length,
isArchived: false,
}
const messages: Message[] = data.conversation.messages.map((m) => ({
id: ulid(),
conversationId: newId,
role: m.role as Message['role'],
content: m.content,
createdAt: new Date(m.createdAt).getTime(),
}))
await db.transaction('rw', db.conversations, db.messages, async () => {
await db.conversations.add(conversation)
await db.messages.bulkAdd(messages)
})
return newId
}
Always generate new IDs on import. If you reuse the original IDs, importing the same conversation twice causes a primary key collision. New IDs also prevent a malicious export file from overwriting existing conversations by targeting their IDs.
Sync and Conflict Resolution
This is where persistence gets genuinely hard. Two scenarios cause problems: multiple tabs open on the same device, and multiple devices for the same user.
Multi-Tab Sync
IndexedDB changes in one tab are not automatically visible in another tab. You need a communication channel.
BroadcastChannel API is the cleanest solution:
const channel = new BroadcastChannel('chat-sync')
async function addMessageWithSync(
conversationId: string,
role: Message['role'],
content: ContentBlock[]
) {
const message = await addMessage(conversationId, role, content)
channel.postMessage({
type: 'message-added',
conversationId,
message,
})
return message
}
channel.onmessage = (event) => {
const { type, conversationId, message } = event.data
switch (type) {
case 'message-added':
updateConversationInUI(conversationId, message)
break
case 'conversation-deleted':
removeConversationFromUI(conversationId)
break
case 'title-updated':
updateTitleInUI(conversationId, event.data.title)
break
}
}
BroadcastChannel works across all tabs of the same origin. No server involved. It's synchronous delivery (if the other tab is listening), and the API is dead simple.
Multi-Device Sync
Cross-device sync requires a server as the source of truth. The simplest pattern that works: pull on focus, push on change.
async function syncOnFocus() {
const lastSync = localStorage.getItem('lastSyncTimestamp')
const response = await fetch(
`/api/conversations/changes?since=${lastSync || 0}`
)
const changes = await response.json()
await db.transaction('rw', db.conversations, db.messages, async () => {
for (const conv of changes.conversations) {
await db.conversations.put(conv)
}
for (const msg of changes.messages) {
await db.messages.put(msg)
}
})
localStorage.setItem('lastSyncTimestamp', String(Date.now()))
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
syncOnFocus()
}
})
Conflict Resolution
When two devices edit the same conversation (rare for chat, but possible with title edits or archiving), you need a strategy:
Last-write-wins (LWW) is the pragmatic choice for chat apps. Each record has an updatedAt timestamp. When syncing, the newer timestamp wins. This is simple, predictable, and correct for 99% of chat scenarios because:
- Messages are append-only — you don't edit a message after sending it
- Metadata changes (title, archive) are idempotent — the "latest" is almost always what the user intended
- True conflicts (two devices editing the same title at the same exact moment) are extremely rare
async function resolveConflict(
local: Conversation,
remote: Conversation
): Promise<Conversation> {
if (remote.updatedAt > local.updatedAt) {
return remote
}
return local
}
If you need fancier conflict resolution (operational transforms, CRDTs), you're probably building a collaborative editor, not a chat app. Don't over-engineer.
When LWW breaks down
Last-write-wins fails when two users collaboratively edit the same conversation's metadata simultaneously — say, both rename it at the same time. But in a personal AI chat app, conversations belong to a single user. The only "conflict" is between the same user's devices, and the most recent edit is almost always the intended one. If you're building a shared/team chat with AI, you'd want CRDTs or operational transforms for the shared metadata, but the messages themselves are still append-only and conflict-free.
Putting It All Together
Here's the architecture for a production-grade conversation storage system:
The write path: user sends a message → update React state (instant UI) → write to IndexedDB (persist locally) → POST to server (persist remotely) → broadcast to other tabs.
The read path: open app → load from IndexedDB (instant) → pull latest from server in background → merge changes → update React state.
- 1Use ULIDs for IDs — they are time-sortable and work as natural chronological indexes
- 2Store message content as an array of typed ContentBlock objects, never flatten to a string
- 3Always paginate from newest to oldest when loading messages — users need recent context first
- 4Wrap related IndexedDB writes in transactions for atomicity
- 5Generate new IDs on import — never reuse original IDs from exported data
- 6Use BroadcastChannel for cross-tab sync and pull-on-focus for cross-device sync
- 7Auto-title conversations asynchronously — never block the chat flow for a title
| What developers do | What they should do |
|---|---|
| Storing all messages as a single JSON string in localStorage localStorage is synchronous (blocks the main thread), limited to 5-10MB, and stores only strings. A conversation with images or tool calls can easily exceed this. IndexedDB is async, supports structured data, and can store hundreds of megabytes. | Use IndexedDB with Dexie.js for structured, indexed, async storage |
| Loading all messages when opening a conversation A conversation with 500+ messages creates a massive DOM, wastes bandwidth, and causes noticeable load time. Cursor-based pagination from the newest messages gives users what they need instantly. | Load the most recent 50 messages and paginate backwards on scroll-up |
| Using offset pagination (SKIP/LIMIT) for conversation lists Offset pagination breaks when items are added or deleted between pages — you either skip items or show duplicates. Cursor pagination is stable regardless of concurrent modifications. | Use cursor-based pagination with the last item's ID or timestamp |
| Blocking the first assistant response to generate a title The user cares about the response, not the sidebar title. Adding an extra LLM call in the critical path increases perceived latency for something that can happen in the background. | Generate the title asynchronously after the first exchange completes |
| Reusing original IDs when importing a conversation export Reusing IDs causes primary key collisions if the conversation is imported twice, and allows malicious exports to overwrite existing data by targeting known IDs. | Always generate fresh IDs for imported conversations and messages |