オフラインフォーム送信とBackground Sync
フォーム送信をIndexedDBに保存し、Background Syncとonlineフォールバックで再送、Idempotency-Keyで重複注文を防ぎます。
フォームの送信をオフラインや不安定な接続環境でも確実に届けるには、ネットワーク通信を行う前にIndexedDBへ送信内容を書き込み、Background Syncタグを登録し、接続が回復したときにサービスワーカーのsyncイベントからキューに積まれたリクエストを再送する仕組みを構築します。さらに、Background Syncに対応していないブラウザ向けにonlineイベントのフォールバックを追加する必要があります。この最後の点は省略できません。Background Sync APIはChromiumベースのブラウザにしか実装されていないため、IndexedDBキューとonlineイベントリスナーの組み合わせが、あらゆる環境で動作するベースラインとなり、Background Syncはその上に重ねる拡張機能という位置づけになります。
本記事では、コンタクトフォーム/注文フォームを例に、このパターンを一通り解説します。具体的には、耐障害性のあるキュー、sync登録、サービスワーカーによる再送、全ブラウザ対応のフォールバック、重複送信の防止、そして誰も警告してくれない確認通知の問題を取り上げます。サービスワーカーの登録済みであること、プロミスとfetchに慣れていることを前提としています。キャッシュ戦略(cache-first、network-firstなど)は前提知識であり、本記事の主題ではありません。その点についてはMDNのキャッシュ戦略ガイドおよびWorkboxのstrategyモジュールを参照してください。
重要なポイント
- ネットワーク呼び出しの前に、クライアント側で生成したリクエストID、
queuedAtタイムスタンプ、リトライ回数をIndexedDBへ書き込むことで、途中で中断されたPOSTリクエストをロストではなく耐障害性のある状態にできます。 - 一回限りのBackground Sync(
SyncManager)はChromiumベースのブラウザ——Chrome、Edge、Opera、Samsung Internet——でのみサポートされており、Firefox、Safari、iOSでは利用できません。そのため、2026年においてもonlineイベントのフォールバックは必須です。 - サービスワーカーの
syncハンドラでは、キューの各アイテムをPOSTし、成功したら削除し、失敗したらthrowする必要があります。throwすることで、ブラウザに登録を維持させ、ブラウザ管理の指数バックオフでリトライさせることができます。 - リクエストIDを
Idempotency-Keyヘッダとして送信することで、サーバーがすでに受け付けた送信を再送時に重複排除でき、注文の二重登録を防止できます。 - WorkboxのBackgroundSyncPluginはキューへの追加と再送を自動化しますが、リトライするのは真のネットワーク障害のみです——4xxや5xxのレスポンスは「配信済み」として扱われ、再送されません。
単純なfetchでのフォーム送信がオフライン時に失敗する理由
フォーム送信時に素のfetchを使うと、デバイスがオフラインの場合にプロミスが拒否され、リクエストはサーバーに届かず、明示的なエラーハンドリングを書いていない限りユーザーには何も通知されません。サービスワーカーはアセットのキャッシュを解決しますが、フォームが行う新規データリクエストを自動的にリトライする機能はありません——失敗したPOSTはそのまま消えてしまいます。
オフライン時にPOSTが静かに失敗するのは、「ユーザーは成功したと思っているが、データは届いていない」という典型的なバグです。また、リクエストがバックエンドに到達していないため、バックエンドの監視にも何も記録されません。このような可視性のギャップを埋める手法がセッションリプレイです。リプレイはクライアント側の実態——送信ボタンのタップ、楽観的な「ありがとうございます」画面、その後のナビゲーション——を再現し、サーバーレコードが実際に作成されたかどうかと照合できます。この照合こそが、このパターン全体が防ごうとしている「信頼のギャップ」を検出する方法です。
「送信して終わり」は、ユーザーにとっては即時に感じられ、システムにとっては耐障害性があるべきです。ユーザーが送信ボタンをタップした瞬間にローカルへ保存され、配信はシステムの責任であってユーザーの問題ではありません。
Step 1: ネットワーク通信の前にIndexedDBへ送信内容をキューイングする
Discover how at OpenReplay.com.
まずIndexedDBへ送信内容を保存し、その後に配信を試みます。ネットワーク呼び出しより前に保存することで、失敗・中断・オフライン時のPOSTがすでに耐障害性のある状態になります——揮発性のページ状態ではなく、ストアから再送します。キューに積む各アイテムには、クライアント側で生成したリクエストID(後の重複排除用)、queuedAtタイムスタンプ、retryCountの3つのフィールドを付与します。
// db.js — IndexedDBを薄くPromiseでラップしたもの
const DB_NAME = 'outbox';
const STORE = 'submissions';
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function enqueue(payload) {
const db = await openDB();
const item = {
id: crypto.randomUUID(), // 冪等性のためのリクエストID
payload,
queuedAt: Date.now(),
retryCount: 0,
};
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(item);
tx.oncomplete = () => resolve(item);
tx.onerror = () => reject(tx.error);
});
}
crypto.randomUUID()は、MDNのCrypto.randomUUID()リファレンスによれば、すべてのモダンブラウザおよびサービスワーカースコープで利用可能です。フォームハンドラはenqueueを呼び出し、楽観的な「キュー済み」状態を表示してからsyncを要求します。
Step 2: Background Syncを登録し、syncイベントを処理する
Background Syncを使うと、ページから名前付きタグを登録でき、ブラウザが接続回復を判断したときにサービスワーカーへsyncイベントを発火します。このイベントは、ユーザーが別のページへ移動したりタブを閉じた後でも配信されます。このナビゲーション後の遅延配信動作こそが、ページが開いている間しか発火しないonlineリスナーより優れている点です。
利用前にserviceWorkerとSyncManagerの両方をフィーチャーデテクトし、どちらかが存在しない場合はすぐにフォールバックします。
// form handler、enqueue()の後
async function requestSync() {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
try {
await reg.sync.register('sync-forms');
return;
} catch {
// 登録失敗——フォールバックへ
}
}
flushQueue(); // 全ブラウザ対応のフォールバック(Step 3)
}
register('sync-forms')の呼び出しとSyncManagerインターフェースはMDNのBackground Synchronization APIに記載されています。サービスワーカー側では、タグを照合し、event.waitUntil()で処理をラップしてワーカーを生存させ、各アイテムをPOSTし、成功したら削除し、失敗したらthrowすることでブラウザに登録を維持させてリトライさせます。
// service-worker.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-forms') {
event.waitUntil(replayQueue(event));
}
});
async function replayQueue(event) {
const items = await getAll(); // IndexedDBから読み込む
for (const item of items) {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id, // サーバー側で重複排除
},
body: JSON.stringify(item.payload),
});
if (res.ok) {
await remove(item.id); // 成功したら削除
} else if (res.status >= 500) {
if (event.lastChance) await notifyFailure(item);
throw new Error('server error, retry'); // 登録を維持する
}
// 4xx: ペイロードが不正——やみくもにリトライせず、ユーザーに通知する
}
}
waitUntil内でthrowすることが、syncをペンディング状態に保つシグナルになります。このAPIをサポートするブラウザは、ブラウザが管理する指数バックオフを使って失敗したリクエストを自動的に再送します。試行回数や正確な間隔はブラウザが管理するものであり、仕様として明文化されていないため、「3回リトライ」のような前提をハードコードしないでください。代わりにMDNのSyncEventリファレンスに記載されているevent.lastChanceを確認し、最後の試行を検出して、送信が最終的に失敗したことをユーザーに通知してください——静かに消えてしまわないようにするためです。
Step 3: 全ブラウザで動作するフォールバック
Background SyncはChromium専用であるため、onlineイベントのフォールバックは補足事項ではなく、すべてのブラウザが実行するベースラインです。非Chromiumブラウザのパスをカバーするトリガーは2つあります。onlineイベントが発火するたびにキューを再フラッシュすること、そして前回のセッションでキューに積まれた送信をキャッチするためにページロード時にも一度フラッシュすることです。
// ページ上で実行、全ブラウザ対応
export async function flushQueue() {
if (!navigator.onLine) return;
const items = await getAll();
for (const item of items) {
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.id,
},
body: JSON.stringify(item.payload),
});
if (res.ok) await remove(item.id);
} catch {
await bumpRetryCount(item.id); // 次のトリガーまでキューに残す
}
}
}
window.addEventListener('online', flushQueue);
window.addEventListener('load', flushQueue);
このフォールバックには正直な制限があります。ページレベルのイベントに依存するため、自分のオリジンのページが開いている間しか動作しません。Background Syncのように閉じたタブを起動させることはできません。これが明確なトレードオフです——あらゆるブラウザに対応できるが、配信保証は弱い——だからこそBackground Syncを先に登録し、その後にフォールバックするわけです。
2026年時点でのBackground Syncのブラウザサポート状況
2026年6月時点で、一回限りのBackground Sync(SyncManager)はChromium専用のAPIです。MDNはこれを”Limited availability(限定的なサポート)“に分類しています——広く使われているブラウザの一部で動作しないため、Baselineには含まれていません。caniuseのデータでも以下の対応状況が確認できます。
| ブラウザ | 一回限りのBackground Sync(SyncManager) |
|---|---|
| Chrome | ✅ サポート済み |
| Edge(Chromium) | ✅ サポート済み |
| Opera | ✅ サポート済み |
| Samsung Internet | ✅ サポート済み |
| Firefox | ❌ 未サポート |
| Safari(macOS) | ❌ 未サポート |
| Safari(iOS) | ❌ 未サポート |
| Android WebView | ❌ 未サポート |
注意すべき点が2つあります。Microsoft EdgeはChromiumへの移行後にサポートを追加したため、「EdgeはBackground Syncをサポートしていない」という2019年頃の情報は現在では誤りです。また、Android WebView——ネイティブアプリがインアプリブラウジングに組み込むコンポーネント——はSyncManagerを公開していないため、PWAをWebViewシェルでラップするとネイティブのsyncキューが失われ、onlineイベントのパスにフォールバックします。なお、これはあくまで一回限りのBackground Sync APIの話であり、Periodic Background Syncは定期的な更新のための別の実験的APIです——混同しないようにしてください。
冪等性: 再送による注文の重複を防ぐ
リトライされたPOSTは重複のリスクを伴います。最初の試行がサーバーに届いてコミットされた後、接続が切れてレスポンスが返ってこなかった場合、クライアントはすでに処理済みのリクエストを再送することになります。これを防ぐには、エンキュー時に保存したitem.idと同じ値をIdempotency-Keyヘッダとして送信します。サーバーはこのキーを識別子として、重複リクエストに対しては新しいレコードを作成するのではなく元の結果を返します。
Idempotency-KeyはStripeやPayPalが広めた確立されたAPI慣例であり、IETFのInternet-Draft draft-ietf-httpapi-idempotency-key-headerの対象となっています。ただし、これは批准された標準ではなく進行中の慣例として扱ってください。HTTP Idempotency-KeyリクエストヘッダフィールドはPOSTやPATCHのような非冪等なHTTPメソッドを耐障害性のあるものにするために使用できますが、ドラフト自体には「作業中の文書としてのみ引用可能」という標準的な注意書きが含まれています。サーバー側の実装は単純明快です。既知の冪等性キーを持つ重複リクエストに対しては、リソースサーバーは以前に完了した操作の結果を返すべきです——成功であれエラーであれ。
ユーザーが離脱した後の確認通知UX
遅延配信はユーザーが別のページへ移動した後に完了することが多いため、リクエストが実際に届いた時点で楽観的な「キュー済み」状態を解消する手段が必要です。以下の3つのアプローチで対応できます。
- 「キュー済み」→「送信済み」をライブで更新する。 サービスワーカーがアイテムの再送に成功したら、
postMessageで開いているクライアントに通知し、UIをその場で更新します。navigator.serviceWorker.addEventListener('message', ...)でリッスンします。 - 次回のロード時に照合する。 起動時にキューのドレイン状態を読み込みます。アイテムが残っていれば保留中、存在しなければ配信成功です。送信時に設定したフラグではなく、ストアの状態からステータスを描画します。
- 最終的な失敗を通知する。
syncハンドラのevent.lastChanceを使って通知を発火するか(または次回のロード時にUIが読み取る「失敗」マーカーを保存するか)、リトライを使い果たした送信が静かに消えないようにします。
確認通知UXは、セッションリプレイが2番目に活躍する場面です。リプレイはユーザー側のタイムライン——キューに積まれた送信、表示された画面、「送信済み」確認がレンダリングされたかどうか——を可視化します。これは、リクエストがサーバーに届かずログに残らなかった場合に信頼のギャップを示す唯一のビューです。
Workboxをプロダクションのショートカットとして使う
キューを手動で実装したくない場合、Workbox(現在のメジャーバージョン7)が提供するBackgroundSyncPluginを使えば、失敗したリクエストをIndexedDBにキューイングし、syncイベントで再送してくれます。重要な点として、このプラグインは独自のフォールバックも備えています。BackgroundSync APIをネイティブにサポートしていないブラウザでは、Workbox Background Syncはサービスワーカーが起動するたびに自動的に再送を試みます。
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
const bgSync = new BackgroundSyncPlugin('order-queue', {
maxRetentionTime: 24 * 60, // 分単位; ドキュメントの例では24時間保持
});
registerRoute(/\/api\/orders/, new NetworkOnly({ plugins: [bgSync] }), 'POST');
よくある落とし穴が2つあります。まず、BackgroundSyncPluginはfetchDidFailプラグインコールバックにフックしており、fetchDidFailは例外がthrowされた場合——主にネットワーク障害——にのみ呼び出されます。つまり、4xxや5xxのエラーステータスを含むレスポンスが返ってきた場合はリトライされません。5xxレスポンスを再キューイングしたい場合は、response.status >= 500のときにthrowするfetchDidSucceedプラグインを追加してください。次に、テスト時はChrome DevToolsの「Application」→「IndexedDB」でリクエストがキューに積まれているか確認でき、「Application」→「Service Workers」から強制的に再送をトリガーできます。DevToolsの「Offline」チェックボックスでオフライン動作を検証しないでください——ページからのリクエストはブロックしますが、サービスワーカーからのリクエストは通してしまうため、テストしたいバグが隠れてしまいます。代わりに実際のネットワークを切断してください。
手動実装でもWorkboxを採用しても、アーキテクチャは同じです。
- ネットワーク通信の前にローカルへキャプチャする
- 利用可能な場合はBackground Syncを優先する
- それ以外の全ブラウザでは
onlineイベントにフォールバックする - 冪等性キーで再送の重複を排除する
- 配信が完了したらUIを照合・更新する
これら5つの要素を自分のフォームに組み込んだら、オフライン状態でキューイングし、再接続して、サーバーレコードが1件だけ作成されることを確認して、実際の再送フローを検証してください。
よくある質問
一回限りのBackground SyncとPeriodic Background Syncの違いは何ですか?
一回限りのBackground SyncはSyncManagerインターフェースを使って、キューに積まれたフォーム送信などの単一の遅延タスクを接続回復後に再送するもので、ブラウザはユーザーが別のページへ移動した後でもsyncイベントを発火できます。Periodic Background Syncは別のPeriodicSyncManagerインターフェースを使って、コンテンツの更新など、ブラウザが管理するスケジュールで定期的な更新を行うためのものです。これらは別個のAPIであり、Periodic Background Syncは実験的な段階にあるため、本番環境で使用する前に互換性テーブルを確認してください。
サーバーが400や500エラーを返したときにキューに積まれたリクエストがリトライされないのはなぜですか?
Background SyncとWorkboxのBackgroundSyncPluginがリトライするのは、真のネットワークエラーによって失敗したリクエストのみです。プラグインはfetchDidFailコールバックにフックしており、これは例外がthrowされたときにのみ発火します。400や500のレスポンスは「受信済みのレスポンス」として扱われるため、「配信済み」とみなされ再送されません。5xxレスポンスを再キューイングするには、response.statusが500以上のときにthrowするfetchDidSucceedプラグインを追加するか、手動実装のsyncハンドラ内で明示的にthrowしてください。
PWAをラップしたAndroid WebView内でBackground Syncは動作しますか?
動作しません。ネイティブアプリがインアプリブラウジングに組み込むAndroid WebViewはSyncManagerを公開していないため、WebViewシェルでラップされたPWAはネイティブのBackground Syncキューを失います。送信はonlineイベントのパスにフォールバックしますが、これはオリジンのページが開いている間しか発火せず、閉じたタブを起動させることはできません。この理由と、Firefox、Safari、iOSでのサポートがないことから、IndexedDBキューとonlineイベントのフォールバックは引き続きベースラインとして維持する必要があります。
オフラインのBackground Sync動作を確実にテストするにはどうすればよいですか?
DevToolsの「Offline」チェックボックスではなく、実際のネットワークを切断してください。Offlineチェックボックスはページからのリクエストをブロックしますが、サービスワーカーからのリクエストは通してしまうため、テストしたいバグが隠れてしまいます。キューに積まれたアイテムはChrome DevToolsの「Application」→「IndexedDB」で確認でき、「Application」→「Service Workers」から強制的に再送をトリガーできます。フロー全体を確認するには、オフライン状態で送信をキューイングし、再接続して、サーバーレコードが1件だけ作成されていることを確認してください。