オフライン対応
ネット切断時の振る舞いを設計する。「読む」はキャッシュで、「書く」は Background Sync で後送が定石。 IndexedDB でローカル DB を持つとアプリ的な体験になる。
オフライン UX の設計
- 初回オンライン時に必須リソースをキャッシュ(HTML / CSS / JS / 画像)
- オフラインを検知して通知(バナー等)
- オフライン時はキャッシュ済みデータを表示
- 「書き込み」はローカル保存 → オンライン復帰時に同期
- 同期できないデータは明示的に表示(「未送信」バッジ等)
オフライン検知
// 一発判定
if (navigator.onLine) { /* online */ }
// イベント
window.addEventListener("online", () => {
showToast("オンラインに復帰しました")
syncPending()
})
window.addEventListener("offline", () => {
showToast("オフラインモード")
})
navigator.onLine の限界
「ネットワークインターフェイスが繋がっているか」しか分からない。Wi-Fi に繋がっているがインターネットに出れない状況は検知できない。
実際の通信可否は fetch で確認する。
実通信での確認
async function isOnline() {
if (!navigator.onLine) return false
try {
await fetch("/ping", { method: "HEAD", cache: "no-store" })
return true
} catch {
return false
}
}
オフラインフォールバックページ
ナビゲーション時にネットも cache も無い場合のフォールバック HTML:
// sw.js
const FALLBACK = "/offline.html"
self.addEventListener("install", (e) => {
e.waitUntil(caches.open("v1").then((c) => c.add(FALLBACK)))
})
self.addEventListener("fetch", (e) => {
if (e.request.mode !== "navigate") return
e.respondWith((async () => {
try {
return await fetch(e.request)
} catch {
const cache = await caches.open("v1")
return (await cache.match(e.request)) || (await cache.match(FALLBACK))!
}
})())
})
画像のフォールバック
async function imageFallback(req) {
try { return await fetch(req) }
catch {
return caches.match("/offline-image.svg") ||
new Response('<svg xmlns="http://www.w3.org/2000/svg" />', {
headers: { "Content-Type": "image/svg+xml" },
})
}
}
IndexedDB でローカルストレージ
本格的なオフラインアプリはIndexedDBを使う。生 API は冗長なので idb ラッパが定番。
npm install idb
import { openDB } from "idb"
const db = await openDB("myapp", 1, {
upgrade(db) {
const store = db.createObjectStore("notes", { keyPath: "id" })
store.createIndex("by-updated", "updatedAt")
},
})
// 書き込み
await db.put("notes", { id: "1", title: "メモ", body: "...", updatedAt: Date.now() })
// 読み込み
const note = await db.get("notes", "1")
const all = await db.getAll("notes")
// インデックス検索
const recent = await db.getAllFromIndex("notes", "by-updated")
書き込みのオフライン対応
オフラインで作成・編集したものをキューに溜め、オンライン復帰時に送る:
async function saveNote(note) {
// ローカル DB に即保存
await db.put("notes", note)
// 送信キューにも追加
await db.add("outbox", { type: "save_note", note, ts: Date.now() })
// オンラインなら即試す
if (navigator.onLine) flushOutbox()
}
async function flushOutbox() {
const items = await db.getAll("outbox")
for (const item of items) {
try {
await fetch("/api/notes", {
method: "POST",
body: JSON.stringify(item.note),
})
await db.delete("outbox", item.id)
} catch (e) {
// また次回
break
}
}
}
window.addEventListener("online", flushOutbox)
Background Sync(推奨)
SW のBackground Syncを使うと、オンラインに復帰した時点で自動的に処理を走らせられる。 ユーザーがタブを閉じていてもOK。Chromium のみ。
// メイン側
async function queueSync() {
const reg = await navigator.serviceWorker.ready
if ("sync" in reg) {
await reg.sync.register("flush-outbox")
} else {
flushOutbox() // フォールバック
}
}
// SW 側
self.addEventListener("sync", (e) => {
if (e.tag === "flush-outbox") {
e.waitUntil(flushOutboxInSW())
}
})
楽観的更新
UI 上はすぐ反映、サーバ送信は裏で。失敗時のリトライと失敗時のロールバックを考える。
同期競合の扱い
- last-write-wins: 単純。タイムスタンプで判定
- マージ: ノートのテキスト等は CRDT(Yjs / Automerge)
- 明示的な競合 UI: 「ローカル / サーバ どちらを採用?」
ローカルファースト DB(PowerSync / WatermelonDB / RxDB)
本格的なオフラインアプリでは専用の同期エンジンを使うと楽。 サーバとの差分同期、競合解決、CRDT を提供。
UI の作り方
- オフラインバナー("You're offline. Showing cached data.")
- 「保存済み」「未送信」のバッジ
- ボタンを disabled にせずキューイングを伝える
- 同期失敗時の明示的な再試行ボタン
テスト
- Chrome DevTools → Network → Offline チェック
- 機内モードで実機テスト
- Lighthouse の PWA 監査
- 不安定な回線もシミュレート(Slow 3G)
✓ 最初の表示が完全にキャッシュから可能
✓ オフライン時に有意義なコンテンツが見える
✓ ユーザーがオフラインだと分かる UI
✓ 書き込みがローカル保存 + 後送される
✓ 同期エラーが見える / 再試行できる
✓ オンライン復帰時に Background Sync で自動同期