どんなアプリか
近所の野良猫の写真を撮ると「この子、もううちの図鑑にいる子じゃない?」を探してくれて、隣人と一緒に目撃記録を積み上げていくモバイルWebアプリ。PWA(Progressive Web App — ホーム画面にインストールしてアプリのように使えるWeb)として作った。
核心の決定は一つ。猫を見分けるAI推論をサーバーではなくユーザーのブラウザの中で実行する(これをオンデバイスAIと呼ぶ)。その結果:
- サーバー推論コストが0円。 GPUサーバーなしで、無料枠のPostgresだけで運用できる。サーバーの仕事は「似たベクトルを探す」軽い検索だけ。
- 写真の原本ではなく埋め込みベクトルだけがサーバーに渡る。 埋め込み(embedding)とは、AIモデルが画像を要約した数値のリスト(ここでは384個の数値)。この数値から元の写真は復元できないため、プライバシーに有利だ。
- モデルはブラウザキャッシュに残る。 初回に約22MBをダウンロードすれば、再訪時はダウンロードなしで即座に認識が始まる。

ご近所ねこフィード画面。隣人の記録が代表写真一枚ずつ積み上がり、「猫を撮影する」が認識パイプラインの入り口だ。
認識パイプライン — 写真一枚が図鑑の記録になるまで
カメラ/アップロード
│
▼ ① 検出 (ブラウザ)
MediaPipe EfficientDet-Lite2 ──失敗時──▶ COCO-SSD(tfjs)フォールバック ──失敗時──▶ 手動クロップ
│
▼ ② 埋め込み (ブラウザ、transformers.js · WebGPU→WASM)
DINOv3 ViT-S/16 (q8, ~22MB) CLSトークン → 384次元L2正規化ベクトル
│
▼ ③ マッチング (Supabase RPC)
ジオフィルタ + pgvectorコサイン整列 → 同じ猫? 図鑑に記録
① 検出 — 写真のどこに猫がいるかを見つけ、その部分だけを切り出す段階。第一候補はMediaPipeのEfficientDet-Lite2モデル、失敗したらTensorFlow.jsのCOCO-SSDに切り替え、それでもだめならユーザーが手で領域を描く手動クロップが最後の手段だ。どんなデバイスでも「認識できないから記録できない」状況だけは作らないための三段フォールバック。
② 埋め込み — 切り出した猫の画像をDINOv3というビジョンモデル(q8量子化で22MBまで縮めた版)に入れ、384個の数値に要約する。実行はtransformers.jsが担い、デバイスが対応していればWebGPU(ブラウザでGPUを使う標準)、なければWASM(ブラウザでネイティブ級速度を出す実行方式)で動く。ベクトルはL2正規化(長さを1に揃えること)しておく — こうすると二つのベクトルの「方向」だけを比べるコサイン類似度の計算が正確になる。
③ マッチング — サーバー(SupabaseのPostgres)はベクトル専用拡張のpgvectorで「近くで目撃されて + ベクトルの方向が最も似ている猫」を整列して返す。位置フィルタはPostGIS(地理データ拡張)が担当する。
事故を防ぐディテールが二つ。ベクトルごとにモデルバージョンを一緒に保存するので、後でモデルを差し替えても旧モデルと新モデルのベクトルが混ざって比較されることはない(異なるモデルが作ったベクトル同士の比較は無意味だ)。そして次元が違ったり数値が壊れた(NaNなど)ベクトルはDBに届く前に純粋関数のガードで例外にする — 一つの不正ベクトルが検索結果全体を静かに汚染するのを防ぐ。
正確な位置は誰にも分からない
野良猫アプリの危険な落とし穴: 正確な座標を公開すると虐待目的で悪用されうる。だから座標処理は独立した純粋関数パッケージに分離し、元の座標はこの関数の境界を絶対に越えない。
- 公開座標は250m(子猫は500m)のグリッドにスナップする。地図に見える位置は「このマスのどこか」という意味で、実座標ではない。
- マス内のマーカー位置は猫IDから計算した決定的オフセット。ランダムではないためリロードしても同じ場所に見え、何度も照会して平均を取る方法でも元座標は復元できない。
- 特に弱い立場の(
sensitive)猫は座標自体を公開しない。
キャッシュ設計 — サーバーが真実、残りは性能レイヤー
このアプリのデータ原則: サーバー(Postgres)がSSOT(Single Source of Truth — 唯一の真実の源)だ。 クライアントにあるキャッシュは全て「速く見せるための写し」であって真実ではない。サーバー照会データはタブ内のReact Queryメモリにだけ置き(10秒経ったら再確認)、リロードすればメモリは消えるのでサーバーに聞き直す。
ETagで「聞くけれど、変わっていなければほぼタダ」
ここで問題になるのが、無料枠での運用なのでサーバーに聞くコスト自体を減らす必要があること。かといってキャッシュの保持時間を延ばすと、隣人の新しい記録が遅れて見える。このジレンマを解く標準の道具がETagだ。
ETagの基本動作:
- サーバーは応答時に「このデータのバージョン標識」となる文字列(ETag)をヘッダーに載せて返す。
- ブラウザは次のリクエストに
If-None-Match: <受け取ったETag>ヘッダーを付ける。「前回このバージョンを受け取ったが、変わった?」という意味だ。 - サーバーから見てバージョンがそのままなら、本文なしで304 Not Modified(変更なし)だけを返す。ブラウザは手元の応答をそのまま使う。転送量はほぼゼロ。
ただし素朴に実装すると罠がある。普通は「応答本文を全部作って → ハッシュを計算して → ETagと比較」する — これだと304を返してもDB照会コストはそのままかかる。 節約できるのは転送量だけだ。
このアプリは順序を逆にした。payloadを照会する前にETagを計算できるよう、応答本文の代わりに「最終活動時刻」をバージョン標識に使う:
- 猫の詳細・コメント: その猫の
last_activity_at(いいね・コメントなど最後の活動時刻)だけを軽く読んでETagを作る。クライアントのETagと一致すれば重いjoin・RPCを一切実行せず、空ボディの304で終わる。 - 周辺の猫リスト: 照会領域(bbox)内で最新の
last_activity_atを使って同じ判断をする。 - ページ応答: ログイン状態で内容が変わるため
Cookieでキャッシュを分離し、5分単位のepochをバージョンにする。この経路だけ最大5分のeventual consistency(一時的に少し古いデータが見える)を意図的に許容した。
まとめると: staleTime(キャッシュの鮮度期間)を延ばしてトラフィックを減らすのではなく、ユーザーが体験する10秒の鮮度はそのままに、サーバー側のDBコストだけを削る選択だ。
写真 — 画像最適化を自作した理由
写真はSupabase Storageに保存されるが、リクエストのたびにStorageまで往復すると遅くて高い。普通はVercelの画像最適化機能がこの問題を代わりに解いてくれる。
しかしこのアプリはVercelの無料プランでデプロイしている。ユーザーが少ない今の段階では、画像最適化はコストに見合わなかった。そこでその穴を自作のキャッシュで埋めた。ユーザーが増えて有料プランに上げる際には撤去する過渡的な仕組みであることを明確にしておく。
自作したキャッシュは、サーバープロセスごとに置くLRU(Least Recently Used — 最も長く使われていない項目から捨てる方式、最大500件/50MB)だ。動作は三段のセーフティネットに整理できる。
- キャッシュヒット — すでに保持している写真ならStorageを飛ばして即座に応答する。
- 同時リクエストの統合 — 同じ写真にリクエストが一度に殺到しても、Storage照会は一回にまとめる。
- 最終フォールバック — キャッシュにもなく、Storage読み取りも失敗した場合はsigned URL(署名付きの一時アクセスリンク)へ307リダイレクトする。最悪の場合でも写真が表示されないことはない。
このLRUもSSOTではない。サーバーが再起動すれば消えてよい性能最適化にすぎない。
書き込み — 自分の行動は即座に見える
いいねを押したのにサーバー応答まで画面が変わらないとストレスだ。だから楽観的更新(optimistic update — サーバー応答を待たずに先に画面を変える手法)を使う:
- ボタンを押した瞬間、キャッシュを不変パッチ(元を直接書き換えず修正済みコピーを作る方式)で即座に更新する。このパッチロジックは純粋関数に分離し、境界値テストを先に書いた。
- サーバーリクエストが失敗したら、事前に撮ったスナップショットでロールバックする。
- リクエスト完了後は、この変更が影響する画面の範囲(詳細のみ/フィードまで/リスト全体)に合わせてキャッシュを無効化する — 必要な分だけ再取得する。
結果として、自分の行動は見ているページに即反映され、他人の行動だけが10秒の窓に乗って届く。
アーキテクチャ — 交換可能なピース
コードはFSD(Feature-Sliced Design)の規則に従う。app → views → widgets → features → entities → sharedの方向にしか参照できず、各ピースはindex.tsで公開したものだけを外に見せる。画面・機能・ドメインが一方向にだけ依存するので、変更の影響範囲が予測可能になる。
データアクセスはrepositoryパターンで包んだ。UIは「猫リポジトリ」インターフェースだけを知り、実アプリにはHTTP実装を注入する。テスト・デモではMSW(ブラウザでAPIを偽装応答するツール)とIndexedDB実装に差し替える — おかげでデモモードはSupabaseなしで全フローが動く。 楽観的パッチ、埋め込みガード、座標スナップといったドメインルールは全て純粋関数とし、テストを先に書く。
まだ入れていないもの — そしてその理由
- ポーリングなし。 定期的にサーバーへ聞きに行くポーリングは、トラフィック削減という目標に真っ向から逆行する。
- Realtimeなし。 リアルタイムソケットは「より新鮮に」の道具であって「より安く」の道具ではない。必要が確認されたら付ける。
- オフラインで過去データ表示なし。 サービスワーカーはアプリシェル(画面の枠)だけをキャッシュし、オフライン時のデータリクエストは明示的なエラー状態として見せる。古いデータを本物のように見せるより「今オフライン」と正直に言う方が、「サーバーがSSOT」という契約に合う。