ノートアプリを電車の中で使っていて、トンネルに入った瞬間に AI 補完が無言で止まる。再開しても下書きが消えています。あの体験を一度でも踏むと、ユーザーは二度と AI 機能を信頼してくれません。
ローカルファースト設計はこの課題への直接の答えです。書き込みはまずローカルへ、同期と AI 呼び出しは別レイヤーへ追いやる。一見シンプルですが、Claude API のような「重い・課金される・遅延がある」処理を絡めると途端に難易度が上がります。私自身、4 つのサイト運営で似た構造を組んでみて、Mutation Queue の設計を一度間違えると同じプロンプトを何度も叩いてしまい、API コストが想定の 3 倍になった経験があります。
この記事は、Replicache と IndexedDB を中核に据えたオフライン対応 AI ノートアプリの本番設計を、実装の落とし穴と一緒にお伝えします。完成イメージは「機内モードでも入力と AI 提案表示が止まらず、回線復帰で自動同期される」アプリです。
なぜ「AI × ローカルファースト」は普通に書くと壊れるのか
最初に押さえておきたいのは、AI 機能とローカルファースト同期が本質的に違うリズムで動くという点です。
ローカルファースト同期は、楽観的書き込みを前提とします。ユーザーが何を入力してもまずローカルに保存し、後から差分をサーバーに送ります。Replicache や Triplit、Yjs といった代表的なライブラリはこの「Optimistic Mutation」を中心に設計されています。
一方で Claude API は、リクエストごとに数百ミリ秒〜数秒の遅延と、入力トークン × 出力トークンの課金があります。何より、同じプロンプトを 2 回送ると 2 回課金されます。これを Replicache の Mutation Queue にそのまま放り込むと、再送ロジックが「失敗した API 呼び出し」を律儀にリトライして、課金だけが増えるパターンに容易に陥ります。
つまり「同期するもの」と「AI を呼ぶタイミング」を分けて設計しないと、信頼性とコストの両方を失います。本記事の中心テーマは、この分離をどう実装するかです。
アーキテクチャ全体像
私が採用している構成は次のようなレイヤーになります。
UI 層 (React) : ユーザー入力を即座にローカル DB へ反映。AI 補完はストリーミング表示
ローカル DB 層 (IndexedDB + Dexie) : ノート本文・AI 出力・メタデータを保存
同期エンジン (Replicache) : クライアント間でノートを同期。AI 出力は同期しない
AI ジョブキュー (BullMQ on Cloudflare Queues) : Claude API 呼び出しをサーバー側で処理
API Gateway (Hono on Cloudflare Workers) : Replicache の push/pull と AI ジョブ投入を担当
ポイントは「Replicache が同期するのはユーザーの編集だけ」「Claude API への呼び出しは独立したジョブキューで」という分離です。AI 出力はサーバー側で完成してからローカル DB へ「結果通知」として届けます。
この設計により、機内モードでもノート編集は完全に動作し、復帰したタイミングで未送信の編集が同期され、その後 AI ジョブが処理されます。
ローカル DB スキーマの設計
IndexedDB は生で扱うと辛いので Dexie を使います。スキーマは AI 出力を別テーブルに切り出すのがコツです。
// db/schema.ts
import Dexie, { type Table } from "dexie" ;
export interface Note {
id : string ;
title : string ;
body : string ;
updatedAt : number ;
// Replicache が管理するフィールド
rev : number ;
}
export interface AICompletion {
id : string ; // ULID で生成
noteId : string ;
prompt : string ;
output : string | null ; // null は処理中
status : "pending" | "running" | "completed" | "failed" ;
errorMessage ?: string ;
inputTokens ?: number ;
outputTokens ?: number ;
createdAt : number ;
completedAt ?: number ;
}
export class NotebookDB extends Dexie {
notes !: Table < Note , string >;
completions !: Table < AICompletion , string >;
constructor () {
super ( "notebook" );
this . version ( 1 ). stores ({
notes: "id, updatedAt" ,
completions: "id, noteId, status, createdAt" ,
});
}
}
export const db = new NotebookDB ();
AICompletion を Note から分離している理由は、AI 出力が同期対象外だからです。デバイス A で生成した AI 補完をデバイス B で表示したい場合は、別途サーバー側で永続化して引き出します。これにより、Replicache の差分計算に AI 出力という重いデータが乗らず、同期が速く保たれます。
なぜ AI 出力を Replicache で同期しないのか
ここは設計判断のキモなので少し丁寧に説明します。
AI 出力は重い (1 出力で数 KB)、頻繁に更新される、競合が起きると意味的に統合できない、という三重苦のデータです。これを Replicache の Optimistic Mutation 経由で同期すると次の問題が一気に起きます。
第一に、コンフリクト解決ができません。デバイス A とデバイス B が同じプロンプトで別々に Claude API を呼ぶと、出力が文字単位でズレます。CRDT で「最後に書いた方が勝つ」をやってもユーザーには何が起きたか分かりません。
第二に、Mutation Queue の設計上、未送信の Mutation はネットワーク復帰時に必ず再実行されます。AI 呼び出しを Mutation に含めると、復帰時に再課金が発生します。Claude Sonnet で長いコンテキストを送るアプリだと、1 ユーザーあたり数百円の事故になりかねません。
第三に、Replicache の pull リクエストに AI 出力を含めると、初回ロードでメガバイト単位のデータが流れる可能性があり、モバイル回線で UX が崩壊します。
私のチームは初期版でこれを全部ミスりました。やり直して「AI 出力は専用 API で取得、Replicache は編集だけ」に変えてからコストが 1/4 になりました。
ジョブキューを介した Claude API 呼び出し
サーバー側で Claude API を呼ぶフローは次の通りです。Cloudflare Queues を使った最小構成を示します。
// server/api/completions.ts
import { Hono } from "hono" ;
interface Env {
AI_QUEUE : Queue < AIJob >;
COMPLETIONS : KVNamespace ;
}
interface AIJob {
completionId : string ;
noteId : string ;
prompt : string ;
userId : string ;
idempotencyKey : string ; // 重複実行防止
}
const app = new Hono <{ Bindings : Env }>();
app. post ( "/completions" , async ( c ) => {
const body = await c.req. json <{
completionId : string ;
noteId : string ;
prompt : string ;
idempotencyKey : string ;
}>();
const userId = c. get ( "userId" );
// すでに同じ idempotencyKey で受け付け済みなら 200 を返してスキップ
const existing = await c.env. COMPLETIONS . get ( `idem:${ body . idempotencyKey }` );
if (existing) {
return c. json ({ status: "accepted" , completionId: existing }, 200 );
}
await c.env. COMPLETIONS . put ( `idem:${ body . idempotencyKey }` , body.completionId, {
expirationTtl: 60 * 60 * 24 ,
});
// ジョブをキューに投入
await c.env. AI_QUEUE . send ({
completionId: body.completionId,
noteId: body.noteId,
prompt: body.prompt,
userId,
idempotencyKey: body.idempotencyKey,
});
return c. json ({ status: "accepted" , completionId: body.completionId }, 202 );
});
export default app;
ここで重要なのは idempotencyKey です。クライアントがネットワーク不調で同じリクエストを 2 回送っても、サーバー側でこのキーを見て 1 度しかキューに積みません。クライアントは crypto.randomUUID() で発行し、ノート本文 + プロンプトのハッシュと組み合わせて 24 時間以内の重複を防ぎます。
キューワーカーは次のような構造になります。
// server/workers/ai-worker.ts
import 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 ();
}
}
}
} ,
} ;
期待する動作: ジョブ投入後、ワーカーが Claude API を呼び、結果を D1 に書き戻します。429 や 5xx は指数バックオフで再試行、それ以外は failed としてマークします。クライアントは Polling か WebSocket でこのレコードを購読します。
クライアント側のオフライン対応
クライアントから AI 補完を依頼するときの流れを書きます。ここがオフライン体験を作る心臓部です。
// client/ai-client.ts
import { 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 () }` );
// ① まずローカル DB に "pending" として書き込む
await db.completions. add ({
id: completionId,
noteId,
prompt,
output: null ,
status: "pending" ,
createdAt: Date. now (),
});
// ② サーバーへ投入を試みる(失敗しても OK)
try {
await fetch ( "/api/completions" , {
method: "POST" ,
headers: { "content-type" : "application/json" },
body: JSON . stringify ({ completionId, noteId, prompt, idempotencyKey }),
});
} catch {
// オフラインなら BackgroundSync 経由で再送
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 }` );
}
}
期待する出力: オンラインなら即座にサーバーへ POST、オフラインなら Service Worker の Background Sync API に登録され、回線復帰で自動的に再送されます。ローカル DB には「pending」状態のレコードが残るので、UI 側はそれを表示できます。
Service Worker 側は登録された tag を見て fetch を実行します。
// sw.ts (抜粋)
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) は成功とみなす
throw new Error ( `replay failed: ${ res . status }` );
}
}
409 を成功とみなしているのは、オフライン中に複数回 sync が発火しても idempotencyKey で弾かれるからです。エラーで例外を投げると BackgroundSync が無限リトライに入るので、明確に「成功」を返す必要があります。
結果の取得とリアルタイム反映
サーバー側で完成した AI 出力をクライアントに届ける方法は 3 通りあります。私は WebSocket より SSE (Server-Sent Events) を推します。理由は単方向通信で十分なこと、Cloudflare Workers との相性、再接続の単純さです。
// client/completion-subscriber.ts
import { db } from "../db/schema" ;
export function subscribeCompletions ( userId : string ) : () => void {
let es : EventSource | null = null ;
let stopped = false ;
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 ++ )));
});
};
let retryCount = 0 ;
connect ();
return () => {
stopped = true ;
es?. close ();
};
}
UI 側は Dexie の liveQuery で completions テーブルを購読しているので、ここで update した瞬間に再描画されます。Replicache を経由しないため、AI 出力の反映は同期エンジンとは独立に動きます。
Replicache との統合 — ノート編集だけを同期
Replicache でノートだけ同期する側のコードは標準的なものになります。重要なのはミューテーターに AI 関連の処理を一切入れないことです。
// client/replicache.ts
import { Replicache } from "replicache" ;
export const rep = new Replicache ({
name: "notebook" ,
licenseKey: import . meta .env. VITE_REPLICACHE_KEY ,
pushURL: "/api/replicache/push" ,
pullURL: "/api/replicache/pull" ,
mutators: {
updateNote : async ( tx , args : { id : string ; title : string ; body : string }) => {
await tx. set ( `note/${ args . id }` , {
... args,
updatedAt: Date. now (),
});
},
deleteNote : async ( tx , args : { id : string }) => {
await tx. del ( `note/${ args . id }` );
},
// ❌ ここに requestCompletion などを書かない
},
});
なぜミューテーターに AI 処理を書いてはいけないか。Replicache のミューテーターはローカルでは即座に実行され、サーバーでは push 時に再実行されます。AI 呼び出しを書くと「ローカルで 1 回 + サーバーで 1 回 = 2 回課金」が確実に起こります。
私が見たアンチパターンの中で最も多かったのがこれです。「ローカルで Optimistic に表示したい」という気持ちは分かりますが、それは AI 出力を別テーブルで管理する設計で解決すべきです。
よくある落とし穴と対処法
ここからは、本番投入前後で実際に踏んだ落とし穴を共有します。
落とし穴 1: BackgroundSync が iOS Safari で動かない
iOS Safari (16 系まで) は Background Sync API をサポートしません。私のチームは「iOS では visibilitychange イベントで再送する」フォールバックを実装しています。
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);
}
}
}
iOS の場合「アプリに戻ってきたタイミング」で未送信を再送するのが一番確実です。完璧ではありませんが UX 的には許容範囲に収まります。
落とし穴 2: IndexedDB のクォータ枯渇
AI 出力をすべてローカルに保存すると、ノート数千件規模で簡単に数十 MB を消費します。Safari は 1GB 程度で QuotaExceededError を投げるため、古い completion を定期的に剪定する必要があります。
async function pruneOldCompletions () : Promise < void > {
const cutoff = Date. now () - 30 * 24 * 60 * 60 * 1000 ; // 30 日
await db.completions
. where ( "createdAt" ). below (cutoff)
. and (( c ) => c.status === "completed" )
. delete ();
}
私は週 1 回、アプリ起動時にこの関数を呼ぶようにしています。サーバー側に永続化しているので、必要なら再取得できる前提です。
落とし穴 3: 楽観的更新の競合でノートが消える
Replicache は基本的に「最後に書いた方が勝つ」ですが、デバイス A が古いリビジョンに対して書き込みを続けると、デバイス B の編集を上書きすることがあります。これを防ぐには、サーバー側の push エンドポイントで version 番号を厳密にチェックします。
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 ) {
// 順序が崩れているのでスキップ(後続の pull で同期される)
continue ;
}
await applyMutation (userId, mutation);
await setLastMutationID (userId, push.clientID, mutation.id);
}
return c. json ({});
});
Replicache 公式ドキュメントには書かれていますが、初見では見落としやすい部分です。実装漏れがあると「同じノートを 2 デバイスで編集すると 1 つの編集だけ残る」現象が出ます。
落とし穴 4: Claude API のレート制限とジョブキューの相互作用
Cloudflare Queues は失敗時にバッチ全体を再試行します。バッチ内の 1 件が 429 を受けると、ack 済みの 9 件まで再実行される設計です。これを避けるには、メッセージごとに ack/retry を分離する処理が必須です。前掲のワーカーコードはそれを意識した書き方になっています。
具体的には、429 を受けたメッセージだけ message.retry() を呼び、成功したメッセージは message.ack() を呼ぶことで、バッチレベルの巻き戻しを防ぎます。これを誤ると 1 件のレート制限で 10 倍のリトライが走ります。
ストリーミング出力とオフラインの両立
機能として一つだけオフラインファーストと相性が悪いのがストリーミング表示です。ユーザーは AI 補完が一文字ずつ「タイプアウト」されるのを期待しますが、途中で回線が切れたら全部やり直しというのは避けたい。
私の採用しているパターンは、サーバー側で部分チャンクを蓄積し、SSE で逐次クライアントへ流すという形です。クライアントは届いたチャンクを毎回ローカル DB に書き込むので、回線が切れても IndexedDB の中に途中まで保存された出力がそのまま残ります。
// server/workers/ai-worker.ts (抜粋 — ストリーミング版)
const stream = await client.messages. stream ({
model: "claude-sonnet-4-6" ,
max_tokens: 1024 ,
messages: [{ role: "user" , content: job.prompt }],
});
let buffer = "" ;
for await ( const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta" ) {
buffer += event.delta.text;
await env. SSE_CHANNEL . send (job.userId, {
event: "completion.partial" ,
data: { id: job.completionId, output: buffer },
});
}
}
const finalMessage = await stream. finalMessage ();
await persistCompleted (env. DB , job.completionId, buffer, finalMessage.usage);
サーバー側でバッファリングする理由は、クライアントが途中で切断する可能性があるからです。チャンクを直接転送する方式だと、回線復帰のたびに最初から再生する必要があります。バッファ済みの状態を再送できれば、ユーザーから見て「切れた直前の文字位置から再開」したように見えます。
地味な差ですが、実機テストでは「再接続のたびに最初に戻る」状態と「再開する」状態で、ユーザーからの評価が「不安定」と「魔法のよう」くらい変わります。
暗号化とプライバシー設計
個人向けノートアプリを作る場合、ユーザーは自分の最もプライベートな文章をあなたの DB に預けることになります。ローカルファースト設計は重要な選択肢を一つ開きます: ノート本文をクライアントで暗号化してから DB に出すことができるのです。
私が採用しているパターンは、ユーザーごとのマスター鍵をブラウザの crypto.subtle キーストアに非エクスポータブルで保存し、AES-GCM で各ノート本文を暗号化してから Replicache に渡します。サーバーは暗号文しか見ません。
// client/crypto.ts
async 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));
}
トレードオフは明確で、サーバー側全文検索はできなくなります。検索が必要ならタイトルとタグだけ平文にして本文は暗号化、あるいは復号後のローカルデータに対して MiniSearch を走らせるという選択になります。後者は実はローカルファーストの設計と非常に相性が良いです。
Claude API への送信時にもう一つ判断点があります: クライアントで復号して平文をサーバー経由で Anthropic に送るか、クライアントから直接 Anthropic API を叩くか。前者は運用が単純、後者はサーバーが平文を見ない代わりに API キー管理が難しくなります。私が相談を受けたチームの大半は、明示的なプライバシー通知を出した上で前者を選んでいます。
トレードオフと判断基準
ローカルファースト + Claude API という設計は強力ですが、向き不向きがあります。
採用を勧められるケース: ノート、タスク管理、メモ、コードエディタなど「個人の記録 + AI 補助」型のアプリ。ユーザーが「自分のデータがオフラインでも見られる」ことを期待する領域。
慎重に検討すべきケース: 多人数協業のドキュメント編集 (Google Docs 型)、リアルタイム性が高いチャット、AI 出力そのものが共有資産になるアプリ。これらは AI 出力の同期が本質的な機能なので、本記事の設計だと UX が分裂します。
判断のフレームワーク: 「AI 出力を別デバイスで見られないと困るか」を自問してください。困らないならローカルファーストで分離する設計が楽になります。困るならサーバー駆動で同期前提に組むべきです。
設計判断の軸が言語化されていて、本記事のような分離戦略がなぜ機能するかが腑に落ちます。
計測すべきメトリクス
本番運用で最低限見ておきたい指標を 4 つ挙げます。
平均同期遅延 (push から pull で他デバイスに届くまで)
AI ジョブの成功率と平均実行時間
IndexedDB クォータ使用率 (95% を超えたユーザーは pruning が間に合っていない)
重複ジョブ率 (idempotencyKey で弾かれた割合 = 5% 以上ならクライアント設計に問題)
これらを Cloudflare Workers Analytics Engine や ClickHouse に流し込み、週次で確認します。重複ジョブ率が高い場合、ユーザーの編集中に頻繁に補完を要求している可能性があるので、デバウンス時間を伸ばす検討が必要です。
テスト戦略について少しだけ
このアーキテクチャで一番厄介なバグは「間違ったものが同期された」系です。Replicache ミューテーターの単体テストでは検知できません。本当に効くのは、シミュレートした 2 クライアントをオフライン期間を含めた一連の編集シナリオで走らせ、最終状態が一致することを確認する統合テストです。私は Playwright で navigator.onLine を切り替えながら、オフライン期間中に補完を要求し、復帰後の最終状態が期待値とマージされているかを検証する小さなスイートを回しています。
このテストハーネスを書くのに 2 日かけたおかげで、リリース後に発生した「たまにおかしい」系の不具合の特定が数週間分短縮できました。この記事から一つだけ持ち帰るなら、収束テストを最初に組むことをおすすめします。
次のステップ
この記事を読み終えた今、まずは小さく試すなら 1 つのノートに 1 つの AI 補完を結びつけ、機内モードで挙動を確認してみてください。Replicache のセットアップと Dexie のテーブル定義は 1 時間程度で動かせます。BackgroundSync の挙動を実機で確かめると、設計判断の理由がより腑に落ちるはずです。
ローカルファーストと AI 機能の組み合わせは、これから個人開発者にとって差別化しやすい領域だと感じています。回線が不安定な環境でも止まらないアプリは、ユーザーの信頼を確実に育てます。