●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Building an Offline-Capable AI Notes App with Claude API and Local-First Sync — A Production Design with Replicache and IndexedDB
A production design guide for combining Claude API with a local-first sync engine. Walks through Replicache, IndexedDB, mutation queues, and idempotency keys with full TypeScript code.
A user opens your notes app on a train, hits a tunnel, and the AI suggestions go silent. They reopen the app and find their draft missing. After one experience like that, they will never trust the AI features again.
Local-first design is the direct answer. Writes go to local storage first; sync and AI calls live in a separate layer. The idea sounds simple, but the moment you bring in a heavy, billed, latency-prone service like Claude API, the implementation difficulty explodes. I have built similar architectures across four sites, and getting the mutation queue wrong once meant calling the same prompt three times and tripling our API bill.
This article walks through a production design for an offline-capable AI notes app built around Replicache and IndexedDB, including the pitfalls I learned the hard way. The end goal is an app where typing and seeing AI suggestions never freezes — even in airplane mode — and where everything reconciles automatically when the connection returns.
Why naive AI plus local-first usually breaks
The first thing to internalize is that AI features and local-first sync run on fundamentally different rhythms.
Local-first sync assumes optimistic writes. Whatever the user types is saved locally first; the diff is sent to the server later. Replicache, Triplit, and Yjs are all built around this optimistic mutation pattern.
Claude API, on the other hand, has hundreds of milliseconds to several seconds of latency per request, and it bills you for every input token times every output token. Critically, sending the same prompt twice costs you twice. If you stuff API calls directly into Replicache's mutation queue, the retry logic happily re-runs every "failed" call and the bill keeps growing while reliability stays the same.
In other words, you have to design the boundary between "what gets synced" and "when AI is invoked" carefully or you will lose both reliability and cost control. The whole point of this article is how to draw that boundary.
Overall architecture
The structure I have settled on uses these layers.
UI layer (React): Reflects user input into local DB instantly. AI completions render via streaming.
Local DB layer (IndexedDB + Dexie): Stores note bodies, AI outputs, and metadata.
Sync engine (Replicache): Synchronizes notes across clients. AI outputs are not synced.
AI job queue (Cloudflare Queues): Processes Claude API calls server-side.
API gateway (Hono on Cloudflare Workers): Handles Replicache push/pull and AI job submission.
The crucial separation is "Replicache only syncs user edits" and "Claude API runs in an independent job queue." AI outputs are completed on the server and pushed back to local DB as result notifications.
With this design, note editing works perfectly in airplane mode, queued edits sync the moment the device comes back online, and AI jobs run after that.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Indie developers stuck with AI features that silently fail on flaky connections will be able to ship offline-capable apps with confidence using a Replicache + Claude API split design
✦You will get complete TypeScript code for caching AI completions in IndexedDB and replaying them through a mutation queue when the network returns
✦You will be able to prevent duplicate billing, version conflicts, and partial failures in production by separating sync and AI job execution at the right layer
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The reason AICompletion is decoupled from Note is that AI output is intentionally outside the sync boundary. If you want device A's completion to be visible on device B, you keep that data on the server and fetch it explicitly. This keeps Replicache's diff calculation lean and sync fast.
Why AI output should not be synced through Replicache
This is one of the most important design calls in the architecture, so let me unpack it.
AI outputs are heavy (a few KB each), updated frequently, and impossible to merge meaningfully when conflicts occur. Pushing them through Replicache's optimistic mutation pipeline triggers three pain points at once.
First, conflict resolution becomes incoherent. If device A and device B independently call Claude API on the same prompt, the outputs differ character by character. A "last writer wins" CRDT picks one arbitrarily, and the user has no way to interpret what happened.
Second, by design, mutation queues replay all unsent mutations on reconnect. If you put AI calls in mutations, every reconnect potentially re-bills you. For an app that sends long contexts to Sonnet, this is a customer-impacting incident waiting to happen.
Third, including AI output in pull responses can balloon the initial sync to multiple megabytes. On a mobile connection, that destroys the perceived launch experience.
My team made all three mistakes in the first iteration. After we moved to "AI output via dedicated API, Replicache for edits only," costs dropped to roughly a quarter.
Calling Claude API through a job queue
Here is the minimum server-side flow using Cloudflare Queues.
The hero of this code is idempotencyKey. Even if a flaky network causes the client to send the same request twice, the server enqueues the job exactly once. The client generates the key with crypto.randomUUID() plus a hash of noteId + prompt, and we deduplicate within a 24-hour window.
The queue worker looks like this.
// server/workers/ai-worker.tsimport Anthropic from "@anthropic-ai/sdk";interface Env { ANTHROPIC_API_KEY: string; COMPLETIONS: KVNamespace; DB: D1Database;}export default { async queue(batch: MessageBatch<AIJob>, env: Env): Promise<void> { const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); for (const message of batch.messages) { const job = message.body; try { const existing = await env.DB.prepare( "SELECT status FROM completions WHERE id = ?" ).bind(job.completionId).first<{ status: string }>(); if (existing?.status === "completed") { message.ack(); continue; } await env.DB.prepare( "UPDATE completions SET status = ? WHERE id = ?" ).bind("running", job.completionId).run(); const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, messages: [{ role: "user", content: job.prompt }], }); const output = response.content .filter((b) => b.type === "text") .map((b) => (b as { text: string }).text) .join(""); await env.DB.prepare(` UPDATE completions SET status = ?, output = ?, input_tokens = ?, output_tokens = ?, completed_at = ? WHERE id = ? `).bind( "completed", output, response.usage.input_tokens, response.usage.output_tokens, Date.now(), job.completionId, ).run(); message.ack(); } catch (err) { const isRetryable = err instanceof Anthropic.APIError && (err.status === 429 || (err.status ?? 0) >= 500); if (isRetryable && message.attempts < 3) { message.retry({ delaySeconds: 30 * Math.pow(2, message.attempts) }); } else { await env.DB.prepare( "UPDATE completions SET status = ?, error_message = ? WHERE id = ?" ).bind("failed", String(err), job.completionId).run(); message.ack(); } } } },};
What you should see: jobs land, the worker calls Claude API, results are written back to D1. Rate-limit and 5xx errors retry with exponential backoff; everything else is marked failed. Clients subscribe to that table via polling or WebSocket.
Client-side offline handling
Here is the heart of the offline experience: how the client requests an AI completion.
// client/ai-client.tsimport { db } from "../db/schema";import { ulid } from "ulid";export async function requestCompletion( noteId: string, prompt: string,): Promise<string> { const completionId = ulid(); const idempotencyKey = await sha256(`${noteId}:${prompt}:${Date.now()}`); // 1. Write a "pending" record to local DB first await db.completions.add({ id: completionId, noteId, prompt, output: null, status: "pending", createdAt: Date.now(), }); // 2. Try to submit to the server (failure is fine) try { await fetch("/api/completions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ completionId, noteId, prompt, idempotencyKey }), }); } catch { // Offline -> register a Background Sync to replay later await registerBackgroundSync(completionId); } return completionId;}async function sha256(input: string): Promise<string> { const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join("");}async function registerBackgroundSync(completionId: string): Promise<void> { if ("serviceWorker" in navigator && "SyncManager" in window) { const reg = await navigator.serviceWorker.ready; await (reg as ServiceWorkerRegistration & { sync: { register: (tag: string) => Promise<void> } }) .sync.register(`completion:${completionId}`); }}
Expected behavior: when online, the request goes straight to the server; when offline, the Service Worker's Background Sync API registers a replay tag and the request is sent automatically when connectivity returns. A "pending" record stays in local DB so the UI can render it immediately.
The Service Worker side reads the tag and replays the fetch.
// sw.ts (excerpt)self.addEventListener("sync", (event: SyncEvent) => { if (event.tag.startsWith("completion:")) { const completionId = event.tag.slice("completion:".length); event.waitUntil(replayCompletion(completionId)); }});async function replayCompletion(completionId: string): Promise<void> { const cached = await getCachedRequestBody(completionId); if (!cached) return; const res = await fetch("/api/completions", { method: "POST", headers: { "content-type": "application/json" }, body: cached, }); if (!res.ok && res.status !== 409) { // 409 (idempotency conflict) counts as success throw new Error(`replay failed: ${res.status}`); }}
Treating 409 as success matters because Background Sync may fire several times offline; the idempotency key on the server prevents double execution, and we must not throw when that happens or BackgroundSync will retry forever.
Receiving results in real time
There are three reasonable ways to get the finished AI output back to the client. I prefer Server-Sent Events over WebSocket because the channel is one-way, it plays well with Cloudflare Workers, and reconnecting is trivial.
// client/completion-subscriber.tsimport { db } from "../db/schema";export function subscribeCompletions(userId: string): () => void { let es: EventSource | null = null; let stopped = false; let retryCount = 0; const connect = () => { if (stopped) return; es = new EventSource(`/api/completions/stream?userId=${userId}`); es.addEventListener("completion", async (ev) => { const data = JSON.parse((ev as MessageEvent).data) as { id: string; output: string; inputTokens: number; outputTokens: number; }; await db.completions.update(data.id, { status: "completed", output: data.output, inputTokens: data.inputTokens, outputTokens: data.outputTokens, completedAt: Date.now(), }); }); es.addEventListener("error", () => { es?.close(); setTimeout(connect, Math.min(30_000, 1000 * Math.pow(2, retryCount++))); }); }; connect(); return () => { stopped = true; es?.close(); };}
Because the UI subscribes to the completions table via Dexie's liveQuery, the moment we call update the components re-render. Replicache is not involved, so AI updates are completely independent of the sync engine.
Integrating with Replicache — sync only the edits
The Replicache side is intentionally boring. The most important rule: never put AI logic in a mutator.
Why is AI in a mutator a trap? Replicache mutators run locally once and again on the server during push. Putting an API call inside means "one bill on the client + one bill on the server = double bill" every single time.
I have seen this anti-pattern more than any other in production. The instinct to "show optimistic AI output immediately" is real, but the correct solution is the separate AI completions table, not sneaking API calls into mutators.
Common pitfalls and fixes
Below are pitfalls I actually hit during and after launch.
Pitfall 1: Background Sync does not work on iOS Safari
iOS Safari (through the 16 line) does not support the Background Sync API. We fall back to a visibilitychange listener that retries pending requests when the user returns to the app.
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && navigator.onLine) { void retryPendingCompletions(); }});async function retryPendingCompletions() { const pending = await db.completions .where("status").equals("pending") .toArray(); for (const c of pending) { if (Date.now() - c.createdAt < 24 * 60 * 60 * 1000) { await retryRequest(c); } }}
It is not perfect, but for iOS users it is the most reliable trigger and produces an acceptable experience.
Pitfall 2: IndexedDB quota exhaustion
Storing every AI output locally adds up — thousands of notes can easily push you past tens of megabytes. Safari throws QuotaExceededError around the 1 GB mark, so periodic pruning is essential.
I run this once a week at app launch. The server has the canonical copy, so refetch is always possible if needed.
Pitfall 3: Optimistic updates clobber each other and notes "disappear"
Replicache is roughly last-writer-wins, but if a client keeps writing against a stale revision, it can overwrite another device's edits. The fix is strict version checking on the push endpoint.
app.post("/replicache/push", async (c) => { const push = await c.req.json<PushRequest>(); const userId = c.get("userId"); const lastMutationID = await getLastMutationID(userId, push.clientID); for (const mutation of push.mutations) { if (mutation.id !== lastMutationID + 1) { // Out of order — skip and let pull reconcile continue; } await applyMutation(userId, mutation); await setLastMutationID(userId, push.clientID, mutation.id); } return c.json({});});
The Replicache docs cover this, but it is easy to miss on first read. Skipping it produces "edit disappeared" reports from real users.
Pitfall 4: Claude API rate limits interacting with batch retries
Cloudflare Queues retries an entire batch on failure by default. If one message in a batch hits 429, all the already-acknowledged messages get re-run. The fix is per-message ack/retry, which the worker code above implements explicitly. Call message.retry() only on the rate-limited message and message.ack() on every success — otherwise a single 429 spawns a 10x retry storm.
Streaming partial output to the client
There is one feature where the offline-first separation creates a tension: streaming. Users expect AI suggestions to "type out" character by character, but if the network blinks during a stream, you do not want to lose the entire output.
The pattern I use stores partial chunks server-side as they arrive, then flushes the final string when the stream completes. The client receives chunks via SSE and writes them into the local DB on every chunk, so even if the connection breaks, the partial output already in IndexedDB stays visible.
The reason buffering happens on the server is that the client may disconnect mid-stream. If chunks were forwarded directly, every reconnect would force the user to start over. By replaying buffered state on reconnect, the user sees the same character position that existed before the network hiccup.
This pattern matters more than you would expect. In testing, the difference between "stream restarts on every reconnect" and "stream resumes" is the difference between users describing the app as "flaky" versus "magic."
Auth, multi-tenancy, and per-user budgets
For a hobby-scale app you can defer this section, but real users mean real bills. A single runaway prompt loop on a client can burn through a Claude budget overnight.
The two protections I never skip are per-user daily token caps and per-user concurrent job caps. Both are enforced at the API gateway before the job hits the queue.
The numbers are placeholders; tune them to your pricing tiers. The point is to make sure a buggy client cannot accidentally bankrupt you. Refunding a four-hundred-dollar mistake to one user out of pocket once is enough to take this seriously.
For per-job token bookkeeping, the worker reads the actual token usage from Claude's response and increments the daily counter atomically. Cloudflare KV is eventually consistent, so for stricter accounting use Durable Objects or D1 with a serialized increment.
Migrating from server-only to local-first
Many teams arrive at this design from an existing server-only app. The migration deserves its own playbook because users will already have notes in your server DB and active sessions you cannot drop.
The rollout I recommend has five steps. Step one is to add the IndexedDB schema and start writing every server response into local DB on read; nothing changes from the user's perspective. Step two is to read from local DB first and fall back to the server, which silently improves perceived speed. Step three is to introduce Replicache for the writes path, dual-writing to the existing server endpoints and Replicache push for a release window. Step four is to flip the writes path entirely to Replicache once you trust it. Step five is to retire the legacy endpoints and consolidate.
Each step is independently shippable, and you can stop at any point if metrics regress. Skipping the dual-write window is the most common mistake — without it, a Replicache bug means data loss with no easy recovery.
The hardest part of the migration is reconciling AI completions. Existing users have completions stored on the server keyed to legacy IDs. The simplest tactic is to lazy-load them on demand: when a note is opened, fetch the latest completions for it and write them to local DB. Background-filling old data is rarely worth the engineering cost.
Trade-offs and judgment
The local-first plus Claude API design is powerful but not universal.
When this design fits well: notes, task managers, scratch pads, code editors — anything that is essentially a personal record augmented by AI. Users in these domains expect their data to be readable offline.
When you should think harder: multi-user document editing à la Google Docs, ultra-real-time chat, or apps where the AI output itself is the shared artifact. In those cases, syncing AI output is part of the core value proposition, and the split design will feel disjointed.
A simple heuristic: ask yourself "is it a problem if AI output cannot be seen on another device?" If no, separating layers is dramatically easier. If yes, you need a server-driven, sync-first design from day one.
Encryption and privacy considerations
If you are building a personal notes app, your users are storing some of their most private writing in your DB. The local-first design opens an important window: you can encrypt note bodies on the client before they ever leave the device.
The pattern I use stores a per-user master key in the browser's crypto.subtle keystore (non-extractable, generated on first sign-in) and encrypts each note's body with AES-GCM before passing it to Replicache. The server only ever sees ciphertext.
// client/crypto.tsasync function encryptBody(plain: string, key: CryptoKey): Promise<string> { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, new TextEncoder().encode(plain), ); const combined = new Uint8Array(iv.length + ct.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(ct), iv.length); return btoa(String.fromCharCode(...combined));}
The trade-off is real: full-text search on the server becomes impossible because the server cannot read the body. If your product needs server-side search, encrypt only sensitive fields and accept that titles and tags remain plaintext. Or implement client-side search with Lunr or MiniSearch on top of decrypted local data — which actually fits the local-first model perfectly.
For Claude API calls, there is a subtler question: do you decrypt the note client-side and send plaintext to your server (which then forwards it to Anthropic), or do you call Anthropic directly from the client? The first approach is simpler operationally; the second avoids sending plaintext to your own server but makes API key management much harder. Most teams I have advised land on option one with an explicit privacy notice.
Metrics worth instrumenting
Four numbers I always wire up before launch.
Average sync latency (push to other-device pull)
AI job success rate and average duration
IndexedDB quota usage (over 95% means pruning is falling behind)
Duplicate-job rate (idempotency-deduplicated requests; over 5% indicates client bugs)
Pipe these into Cloudflare Workers Analytics Engine or ClickHouse and review weekly. A high duplicate rate often means your debounce on the client is too aggressive — extending it noticeably reduces both noise and cost.
A short word on testing
The hardest bug class in this architecture is "wrong thing got synced." Unit tests on Replicache mutators do not catch them. The test that actually pays for itself is an integration test that drives two simulated clients through a sequence of edits — including offline windows — and asserts that both ends converge. I run a small Playwright suite that toggles navigator.onLine on and off, queues completions during the offline window, and verifies that the final state matches the expected merge.
Spending two days writing this harness saved me weeks of debugging once real users started reporting weird intermittent issues. If you remember nothing else from this article, remember: build the convergence test first.
Next step
Build something small first: pick one note, attach one AI completion, and verify the experience in airplane mode. Replicache plus Dexie can be stood up in about an hour. Watching Background Sync behave on a real device makes the layering choices in this article click in a way reading cannot.
Local-first plus AI is shaping up to be one of the strongest differentiators for indie developers. An app that does not freeze when the network blinks earns user trust steadily — and that trust compounds.
If you only adopt one piece of this design today, make it the separation between sync and AI calls. Almost every other refinement in this article — encryption, budgets, streaming, migrations — gets easier once that boundary is in the right place. Pick that as your first commit and let the rest follow.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.