Web Locks APIとService Workerを使い、PWAの安全な更新を行う仕組みの図。複数のブラウザタブ(作業中、データ表示、フォーム入力)が共有ロックで保護され、待機中のService Workerが排他ロックを取得し、データ損失なく更新する様子が示されています。

はじめに

PWA を運用していると、更新通知を見落として古い画面を使い続けるユーザーが出てきます。
かといって「更新」ボタンで即リロードすると、別タブで開いていた画面や入力途中の内容まで巻き込んで失わせてしまいます。

この記事では、Web Locks API と Service Worker を組み合わせた二段階の排他制御で「更新しても他タブの作業を壊さない」仕組みを作った実装を紹介します。
方針は、更新通知は常設にし、作業のない画面では安全確認のうえ自動更新、入力途中は保存と読み戻しを確認できた場合だけ更新する、というものです。

こんな人におすすめ

  • Service Worker を使った PWA で、更新のタイミング制御に悩んでいる方
  • skipWaiting を呼んだ結果、他タブが壊れた経験がある方
  • Web Locks API を実務でどう使うか知りたい方
  • 古い HTML と新しい Service Worker の混在による不具合を防ぎたい方

罠1:更新通知を一度きりのトーストにしない

更新通知をトーストで一度だけ出す実装は、閉じられたり他の通知に押し出されたりすると再表示されません。
その結果、古い版が居座り続けます。

待機中(waiting)の Service Worker がある間は、通知を常設表示にするのが安全です。
「通知を出したか」ではなく「待機中の SW があるか」という状態を表示の根拠にします。

罠2:skipWaiting を任意のページから送らせない

よくある実装では、ページから SKIP_WAITING メッセージを送り、SW 側で無条件に skipWaiting() を呼びます。
この方式だと、他タブの状態を見ずに新しい SW が有効化されてしまいます。

そこで、メッセージを SAFE_SKIP_WAITING に置き換え、旧メッセージは拒否します。
安全性の判断を SW 側に持たせることで、どのページから呼ばれても同じ条件で守れます。

各タブが共有ロック、更新するタブが排他ロックを取る

他タブの存在を知るために Web Locks API を使います。
各タブは起動時に共有ロックを取り、更新したいタブだけが排他ロックを取りにいきます。

ifAvailable: true を指定すると、他タブが共有ロックを持っている間は待たずに失敗します。
そのため「更新を保留する」判断が簡単に書けます。

export async function withExclusiveUpdateLock(action: () => Promise<boolean>): Promise<boolean> {
  if (!coordinationAllowed || !navigator.locks || !releasePage) return false
  releasePage()            // 自分の共有ロックを手放す
  releasePage = null
  await pageLockReleased
  let updated = false
  try {
    await navigator.locks.request(LOCK_NAME, { ifAvailable: true }, async (lock) => {
      if (lock) updated = await action()
    })
  } catch {
    updated = false
  }
  if (!updated) await acquirePageLock() // 失敗時は共有ロックを取り直す
  return updated
}

ポイントは、自分の共有ロックを一度手放さないと排他ロックが取れないことです。
更新に失敗した場合は共有ロックを取り直し、他タブから見て「このタブは生きている」状態に戻します。

ロックの所有を Service Worker に引き継ぐ

更新元のタブが途中で閉じると、排他ロックも消えます。
その隙間に新しいタブが開かれると、古い HTML のまま起動してしまう事故が起こり得ます。

これを防ぐために、SW が activate まで排他ロックを保持します。

await self.navigator.locks.request('update-lock', { ifAvailable: true }, async (lock) => {
  if (!lock || !(await isSingleClient())) {
    event.ports[0]?.postMessage({ ok: false })
    return
  }
  // 送信元が閉じても、この SW が有効化完了までロックを保持する。
  const activated = new Promise<void>((resolve) => {
    self.addEventListener('activate', () => resolve(), { once: true })
  })
  event.ports[0]?.postMessage({ ok: true })
  await self.skipWaiting()
  await activated
})

activate を待つ Promise をロックのコールバック内に置くことで、ロックの寿命を SW の有効化に紐づけています。
ロックはコールバックが解決するまで保持されるため、この書き方だけで「有効化完了まで」の保持が実現できます。

古い HTML と新しい SW の混在を検知する

もう一つの落とし穴が、古い HTML をキャッシュから読んだまま、新しい SW がアクティブになる状態です。
参照する JS 資産が新しい版には存在せず、画面が壊れます。

起動時に、HTML が参照する JS のパスをアクティブな SW に問い合わせ、precache manifest に含まれるかを確認します。

controller.postMessage(
  { type: 'CHECK_APP_ASSET', asset: new URL(script.src).pathname },
  [channel.port2]
)

SW 側は manifest に該当パスがあるかを返すだけの単純な問い合わせです。
タイムアウトした場合は「不明」として扱い、強制更新はしません。

入力途中の保存は「読み戻せた」で判定する

入力途中の内容がある画面では、更新前に作業を退避します。
このとき「書き込みに成功した」ではなく「書いた内容を読み戻して一致した」ことを成功条件にします。

容量不足などで保存に失敗した場合は、更新せず作業を保持します。
更新の遅れより、入力内容の消失のほうがはるかに高くつくためです。

つまづきやすいポイント

  • 初回導入時は更新が待機する:旧版を開いているタブには新しいロックの仕組みがないため、すべて閉じるまで更新が保留されます。仕様としてドキュメントに残しておくと混乱を避けられます。
  • 共有ロックを手放し忘れる:自分の共有ロックを持ったまま排他ロックを要求すると、永遠に取得できません。
  • Web Locks が使えない環境:非対応ブラウザでは更新が永久に保留されかねません。代替の更新導線を用意しておく必要があります。
  • 複数条件の組み合わせ:複数タブ、容量不足、途中クローズなどの組み合わせは手作業では確認しきれないため、E2E テストでの網羅を検討してください。

まとめ

PWA の更新は「通知する」「リロードする」だけでは、他タブや入力中の作業を壊す危険があります。
更新通知の常設化、SAFE_SKIP_WAITING による SW 側での判断、共有ロックと排他ロックの二段階制御、そして SW へのロック引き継ぎを組み合わせると、更新の安全性を大きく高められます。
入力の保存は「読み戻せた」ことを条件にし、迷ったら更新を保留する側に倒すのが基本方針です。