Claude API Chat History Persistence Design Guide
The Claude API is stateless: every request must include the full conversation history you want the model to see, because Anthropic's servers don't remember previous turns on your behalf. Persisting chat history correctly means designing a storage layer that reconstructs the right messages array on every call, keeps costs under control as conversations grow, and survives real-world failure modes like retries, edits, and multi-device sync.
This article covers the practical design decisions: what to store, how to structure it, when to trim or summarize, and how to avoid the two most common bugs — duplicated messages and silently dropped context.
Why statelessness changes your design
Unlike a traditional chat server that keeps session state in memory, Claude API calls are independent HTTP requests. The messages array you send is the entire context the model has. This has three consequences for your persistence layer:
- You own the source of truth. If you don't save a message, it's gone from the model's perspective forever.
- You control cost and latency. Every token in history is billed and processed again on the next turn, so your storage design directly affects your bill.
- You control correctness. Reordering, duplicating, or truncating messages incorrectly produces visibly broken conversations.
Core data model
A minimal, robust schema has three entities:
conversations (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
title TEXT,
system_prompt TEXT,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
)
messages (
id UUID PRIMARY KEY,
conversation_id UUID REFERENCES conversations(id),
role TEXT CHECK (role IN ('user','assistant')),
content JSONB NOT NULL,
token_count INT,
created_at TIMESTAMPTZ,
sequence INT NOT NULL
)
message_metadata (
message_id UUID REFERENCES messages(id),
model TEXT,
stop_reason TEXT,
usage JSONB
)
Key design choices here:
- Store content as JSONB, not plain text. Claude messages can include text blocks, tool_use blocks, and tool_result blocks. A rigid text column will force you to redesign later.
- Use an explicit
sequenceinteger, not justcreated_at, for ordering. Timestamps can collide or drift across replicas; a monotonically increasing sequence per conversation is unambiguous. - Separate the system prompt from messages. Claude's API takes
systemas a top-level parameter, not a message with role "system". Store it once per conversation (or per account) rather than duplicating it in every row. - Keep token_count per message. You'll need it constantly for trimming decisions, and recomputing it on every read is wasteful.
Reconstructing the request
When the user sends a new message, your backend typically:
- Loads prior messages for that conversation, ordered by
sequence. - Appends the new user message.
- Builds the
messagesarray in the shape Claude expects (alternating user/assistant roles, each withroleandcontent). - Sends the request, streams or waits for the response, then persists the assistant message before returning it to the client — not after, so a crash mid-stream doesn't lose data.
const history = await db.getMessages(conversationId);
const payload = {
model: "claude-sonnet-4-5",
system: conversation.systemPrompt,
messages: [...history, { role: "user", content: userInput }],
max_tokens: 1024
};
If you're routing this through SubToAPI instead of managing raw API keys and billing yourself, the request shape is the same — you call https://api.subtoapi.app/v1/messages with your sub_live_... key and the identical messages array. The persistence problem doesn't change; SubToAPI just gives you one HTTPS endpoint, streaming, and usage metadata per call so you can track token consumption per conversation without building your own metering. See /docs/messages for the request format and /docs/streaming for streaming responses.
Handling growth: trimming and summarization
Conversations that run for hundreds of turns will eventually exceed the model's context window or become expensive to resend. Three common strategies, often combined:
- Sliding window: keep only the last N messages (or last N tokens) plus the system prompt. Simple, but loses early context.
- Summarize-and-splice: periodically replace older messages with a single assistant-authored summary message, then continue appending new turns after it. This preserves gist while cutting token count dramatically.
- Retrieval-based recall: store all messages permanently but only inject relevant ones per turn, selected by keyword or embedding similarity, rather than the full chronological history.
A practical middle ground for most apps:
function buildContext(messages, maxTokens = 6000) {
let total = 0;
const kept = [];
for (let i = messages.length - 1; i >= 0; i--) {
total += messages[i].token_count;
if (total > maxTokens) break;
kept.unshift(messages[i]);
}
return kept;
}
Run summarization asynchronously once a conversation crosses a size threshold, store the summary as a dedicated message with a type: "summary" tag in its metadata, and exclude the summarized originals from future context builds — but keep them in the database for export and audit.
Handling edits, retries, and branching
Real chat UIs let users edit a past message or regenerate a response. Design for this up front:
- Edits create a new sequence branch, not an in-place update. Soft-delete the old message (or mark it inactive) and insert the edited one with a new sequence number. This keeps a clean audit trail.
- Retries should overwrite the previous assistant message, not append a duplicate, unless you want to show multiple candidate responses.
- Idempotency: tag each outgoing request with a client-generated request ID stored alongside the message, so a network retry doesn't create two assistant messages for one user turn.
Multi-device and team sync
If multiple clients can post to the same conversation (web + mobile, or a team seat), use optimistic locking: include the last-known sequence number in the write request and reject writes that don't match, forcing the client to refetch and retry. This avoids silent message interleaving. SubToAPI's team seat model (/pricing) handles API key and usage separation per user, but conversation-level concurrency control is still your application's responsibility.
Getting started quickly
If you're prototyping this architecture, start with the minimal schema above, a sliding-window context builder, and a single API integration point. /docs/quickstart walks through the first authenticated call, and /docs/tools covers persisting tool_use and tool_result blocks correctly, which follow the same JSONB content pattern as regular messages.
questions
Do I need to send the entire chat history on every API call? Yes — the Claude API has no server-side memory of past turns. You must include every message you want the model to consider in the messages array of each request.
What's the best way to store messages with tool use or images? Use a JSON/JSONB column for message content rather than plain text, since Claude messages can contain multiple content blocks (text, tool_use, tool_result, images) that a flat string can't represent.
How do I keep long conversations affordable? Combine a sliding token-count window with periodic summarization: drop or compress older messages into a single summary message once the conversation exceeds a size threshold, and keep full history in your database for records even if it's excluded from the live context.