Web Locks APIは、同じサイトを開いた複数のタブやWorkerが同じ処理を同時に走らせないよう、名前付きのロックで実行順を調整するブラウザAPIです。navigator.locks.request() に名前とコールバックを渡すと、その名前のロックを取れたコンテキストだけがコールバックを実行し、ほかは解放されるまで待ちます。挙動の説明はW3Cの仕様書(2025年9月24日版)に沿い、主要な挙動は2026年10月20日にChrome 155・Firefox 157・WebKit 27.2(Playwright同梱版)でテストページを実行して確かめました。
まとめ:Web Locks APIの要点
navigator.locks.request(名前, コールバック)で、同じstorage bucketを共有するタブ・Worker間の処理を直列化できる。コールバックが終わるか例外を投げるか、タブが閉じられた時点でロックは解放される。- オプションは
mode・ifAvailable・steal・signalの4つ。signalが止めるのは取得待ちだけで、取得後の処理は止まらない。stealで奪われた側のコールバックも走り続ける。3エンジンとも同じ結果だった。 - トークン更新では「ロック取得後に有効期限を再確認する」か「
ifAvailableで担当を1タブに絞り、ほかは共有ロックで完了を待つ」のどちらかにする。 - Chrome 155では、bfcacheに入ったページがロックを持ったまま止まった。リーダー役のタブは
pagehideで手放す。 - Chrome 69・Firefox 96・Safari 15.4以降が対応し、Baselineは2024年9月14日に「広く利用可能」へ到達した。HTTPSかlocalhostのsecure contextでのみ使える。Node.jsはv24.5.0で実験的に追加された。
Web Locks APIが解決する競合とロックの有効範囲
同じアプリを2つのタブで開けば実行コンテキストは2つになり、トークン更新やIndexedDBとサーバーの同期は両方のタブで同時に走り出します。Web Locks APIは、この順番決めを自前のフラグ管理ではなくブラウザに任せる仕組みです。MDNはネットワークとIndexedDBの同期を1タブだけに担当させるリーダー選出を代表例に挙げています。
ロックマネージャーはstorage bucketごとに1つです。仕様書は「Pages and workers (agents) sharing a storage bucket opened in the same user agent share a lock manager even if they are in unrelated browsing contexts.」と定義し、続けて「Separate user profiles within a browser are considered separate user agents. Every private mode browsing session is considered a separate user agent.」と書いています。3エンジンとも、別のブラウザコンテキスト(シークレットウィンドウ相当)からは、保持中のロックを ifAvailable でそのまま取得できました。通常ウィンドウとシークレットウィンドウ、別プロファイルの間では同じロック名を使っても調整されません。
ロック名はアプリが自由に決める文字列で、同じ文字列を要求したコンテキスト同士が順番を取り合うだけです。ハイフン(U+002D)で始まる名前は仕様で予約されており、要求すると NotSupportedError になります。IndexedDBのストア単位でロックを分ける命名例として、仕様書は encodeURIComponent(db_name) と encodeURIComponent(store_name) をスラッシュで連結する方法を挙げています。ブラウザ側の保存先の違いはIndexedDBのデメリットと容量上限|localStorage・Cookieとの違いと使い分けで整理しています。
基本形とロックの自動解放
request() は名前とコールバックを受け取り、ロックを取得できた時点でコールバックを呼びます。コールバックが非同期関数なら、返すPromiseが決着するまでロックを保持します。以下の例の refreshAccessToken() などはアプリ固有の処理で、別途実装が必要です。
const result = await navigator.locks.request('token-refresh', async (lock) => {
// ここを実行できるのは、同じ名前のロックを取れた1コンテキストだけ
await refreshAccessToken();
return 'refreshed';
});
// ここに来た時点でロックは解放済み。result にはコールバックの戻り値が入る
解放のためのメソッドはありません。コールバックが例外を投げると request() はその例外でrejectされ、ロックは解放されます。ロックを持ったタブを閉じた場合も解放され、待っていた別タブが1秒以内に取得しました。try/finally での後始末は不要です。
released promiseとwaiting promiseの区別
仕様書はロックに2つのPromiseがあると定義しています。コールバックが返すPromiseがwaiting promiseで、これが決着した時点でロックが解放されます。request() 自体が返すPromiseがreleased promiseで、ロックが解放されたか要求が中断されたときに決着します。
let release;
const released = navigator.locks.request('leader', () => {
// resolve を外へ出しておけば、好きなタイミングまでロックを保持できる
return new Promise((resolve) => { release = resolve; });
});
// 手放すとき
release();
await released;
この分離があるので、タブが開いている間ずっとロックを持ち続ける主タブ方式(後述のリーダー選出)も仕様の想定内です。Promiseの合成やエラー伝播の基本はJavaScriptの非同期エラーハンドリングと並列実行|try/catch・Promise.all・allSettledの使い分けで扱っています。
4つのオプションと例外が投げられる条件
request() の第2引数には次の4つを渡せます。既定値は仕様書のIDL定義そのままです。
| オプション | 型 | 既定値 | 取れなかったときの挙動 |
|---|---|---|---|
| mode | exclusive / shared |
exclusive |
取得できるまで待機 |
| ifAvailable | boolean | false | 待たずにコールバックへ null を渡す |
| steal | boolean | false | 既存の保持者を解放させて割り込む |
| signal | AbortSignal | なし | 取得待ちの中断時に signal.reason でreject |
mode:読み取りだけなら共有ロック
既定の exclusive は同時に1つのコンテキストしか保持できません。shared のロックは複数のコンテキストが同時に保持できますが、その間は誰も exclusive を取れません。読み取り側を shared、書き込み側を exclusive にすると、データベースの共有ロック・排他ロックと同じ読み書き制御になります。この性質は、後述するトークン更新で「更新の完了だけを待つ」用途にも使えます。
await navigator.locks.request('settings', { mode: 'shared' }, async () => {
// 他のコンテキストも同時に shared で保持できるが、
// この間 exclusive のロックは誰も取得できない
applySettings(await readSettings());
});
ifAvailable:取得できない場合の処理スキップ
ifAvailable: true を指定すると、すぐに取得できない場合はコールバックが null を受け取ります。仕様書は「If the lock cannot be granted, the callback is invoked with null. (Since this is expected, the request is not rejected.)」と書いており、取得失敗はエラーではありません。3エンジンとも、別タブが保持中のロックに対して null が渡りました。
await navigator.locks.request('nightly-sync', { ifAvailable: true }, async (lock) => {
if (!lock) {
return; // 別タブが同期中。自分は何もしないで抜ける
}
await syncToServer();
});
同期的な tryLock() ではない点には注意が必要です。仕様書は「Note that this is still not synchronous; in many user agents this will require cross-process communication to see if the lock can be granted.」と注記しており、結果は必ず非同期で返ります。
signal:ロック取得待ちの打ち切り
取得待ちを打ち切りたいときは AbortSignal を渡します。取得前にabortされると、request() は signal.reason でrejectされます。引数なしの controller.abort() なら AbortError、abort('mytimeout') なら文字列 mytimeout でrejectされることを3エンジンで確認しました。
try {
await navigator.locks.request('resource', { signal: AbortSignal.timeout(200) }, async () => {
await doWork();
});
} catch (e) {
if (e.name !== 'TimeoutError') throw e; // doWork() の例外は握りつぶさない
// 200ms 以内にロックを取得できなかった
}
仕様書は「Once the lock has been granted, the signal is ignored.」と書いています。取得後30msでabortしても、コールバックは200msの処理を最後まで実行し、request() は正常に解決しました(3エンジン共通)。signal は「タイムアウト付きのロック」ではありません。ロックを持ったままハングした相手を待ち切れないときに諦めるための仕組みで、処理そのものを止めたいなら同じ AbortSignal をコールバック内の fetch() などに別途渡します。タブが閉じたりクラッシュしたりした保持者はブラウザが自動で解放するので、その対策として signal を付ける必要はありません。
steal:ロックの強制奪取と処理競合のリスク
steal: true は、保持中のロックを強制的に解放させて割り込みます。奪われた側の request() は AbortError でrejectされますが、そのコールバックの中で走っていたコードは止まりません。3エンジンとも、奪われた側のコールバックは奪取の後も最後まで実行されました。仕様書も「code previously holding a lock will now be executing without guarantees that it is the sole context with access to the resource」と警告しています。
用途は、調停役が「ロックを持つタブがもう応答していない」と判断したときの復帰処理に限られます。
NotSupportedErrorになる4つの条件
仕様書の request() のアルゴリズムは、次の4つのいずれかに当たると NotSupportedError でrejectすると定めています。3エンジンとも、この4条件(signal との併用は steal・ifAvailable の両方)ですべて NotSupportedError になりました。
- ロック名がハイフンで始まる
stealとifAvailableが両方truestealがtrueでmodeがexclusive以外signalを指定したうえでstealかifAvailableのどちらかがtrue
3番目はMDNのrequest()リファレンスの例外一覧には載っていません。奪取する側は排他ロックで要求する必要がありますが、奪われる側は共有ロックでも解放対象になり、共有ロックを奪われた側も AbortError を受け取りました。このほか、ドキュメントがfully activeでなければ InvalidStateError、ロックマネージャーを取得できない環境では SecurityError になります。
query() は現時点のロック状態のスナップショットを返します。返るのは held と pending の2配列を持つオブジェクトで、各要素は name・mode・clientId を持ちます。別タブが保持しているロックも見えます。
const { held, pending } = await navigator.locks.query();
for (const lock of held) console.log(`保持中: ${lock.name} (${lock.mode}) ${lock.clientId}`);
for (const req of pending) console.log(`待機中: ${req.name} (${req.mode})`);
clientId はService Workers仕様の Client の id と同じ値で、どのタブがロックを握ったままなのかを特定する手がかりになります。仕様書自身が「for logging or debugging purposes」と位置づけているとおり、query() の結果を見てから request() するコードは、2つの操作の間に状態が変わるため排他になりません。
アクセストークン更新の重複をWeb Locksで防ぐ実装
複数タブのSPAで最も多い用途がトークン更新です。OAuthのセキュリティ指針であるRFC 9700の4.14.2節は、公開クライアント向けの対策としてリフレッシュトークンのローテーションを挙げ、無効化済みのトークンが提示されると認可サーバーは有効なリフレッシュトークンを失効させると説明しています。2つのタブが同じリフレッシュトークンで同時に更新すると、後から送った側が「無効化済みのトークン」を提示したことになり、利用者が正当でもセッションごと失効しえます。更新リクエストは必ずロックの内側で送ります。
ロック取得後に有効期限を再確認する待機型
全タブが同じロックを待ち、取れた順に「まだ更新が必要か」を確かめる形です。ロックを待っている間に先行タブが更新を終えている可能性があるため、再確認はロックの内側で行います。
async function getFreshToken() {
await navigator.locks.request('token-refresh', async () => {
if (!isExpiringSoon(readToken())) {
return; // 待っている間に別タブが更新済み
}
await refreshAccessToken(); // 新しいトークンを共有ストレージへ保存する
});
return readToken();
}
呼び出し元は常に新しいトークンを受け取れます。欠点は、後続のタブが先行タブの更新を待ってから、もう一度ストレージを読む往復が発生することです。
ifAvailableで更新役を1タブに絞り、共有ロックで完了を待つ型
ロックが取れなかったタブは更新しない、と決めれば、同時に走った更新は1回にまとまります。ただし先行タブがロックを手放した直後に呼ばれたタブはロックを取れてしまうため、取れた側でも有効期限は確かめます。ただし ifAvailable だけで抜けると、そのタブは更新が終わる前の古いトークンを読みます。そこで、取れなかったタブは空のコールバックで共有ロックを要求し、更新役が排他ロックを手放すまで待ってから読みます。
async function getFreshToken() {
const role = await navigator.locks.request('token-refresh', { ifAvailable: true }, async (lock) => {
if (!lock) return 'follower';
if (!isExpiringSoon(readToken())) return 'leader'; // 直前に別タブが更新済み
await refreshAccessToken();
return 'leader';
});
if (role === 'follower') {
// 更新役が排他ロックを手放すまで待つだけ。shared なので後続タブ同士は同時に通過する
await navigator.locks.request('token-refresh', { mode: 'shared' }, () => {});
}
return readToken();
}
2つのタブで動かすと、3エンジンとも片方だけが更新を実行し、もう片方は更新の完了を待ってから新しいトークンを読めました。使い分けの基準は1つです。呼び出し元がその場で新しいトークンを必要とするなら、待機型か共有ロックで待つ型を使う。期限切れ前の定期更新のように、他タブの完了を待つ必要がない処理なら、ifAvailable で抜けるだけで足ります(ロックを取れた側での有効期限の確認は省けません)。どの型でも、ロックの外から更新リクエストを送るコードパスを1つも残さないことが前提です。
リーダー選出と複数ロックの実装パターン
解放しないロックによるリーダー選出
WebSocketを1本だけ張る、IndexedDBとの同期を1タブに任せる、といった用途では、全タブが同じ名前のロックを要求し、取れたタブがリーダーになります。リーダーはコールバックから解決しないPromiseを返してロックを持ち続け、閉じられると待機中のタブに自動で引き継がれます。他のタブへの結果の配信はBroadcastChannel APIとは?JavaScriptのタブ間通信の使い方・ブラウザ対応・React実装で行うのが定番で、Web Locksが実行役の調停、BroadcastChannelが通知という分担になります。TanStack DBとは?TanStack Queryとの違い・使い方とv0.9系の注意点で触れている @tanstack/offline-transactions も、送信待ちの変更を処理するタブをWeb Locksによるリーダー選出で1つに絞っています。
bfcacheでロックが残る問題とpagehideでの解放
リーダーのタブが別ページへ移動すると、ページは閉じられずにバックフォワードキャッシュ(bfcache)へ入ることがあります。Chrome 155で確かめたところ、bfcacheに入ったリーダーはロックを持ったままで、先に待っていた別タブは15秒待っても取得できませんでした(2回とも同じ)。新しくロックを要求したタブが現れたときは、キャッシュ中のページが破棄されてロックが渡りました。ChromeのPage Lifecycle APIの解説も、ページが凍結される前に「Release any held Web Locks.」と推奨しています。
let release;
let controller;
function requestLeadership() {
controller = new AbortController();
navigator.locks.request('myapp:leader', { signal: controller.signal }, () => {
startLeaderWork();
return new Promise((resolve) => { release = resolve; });
}).catch((e) => { if (e.name !== 'AbortError') throw e; });
}
requestLeadership();
addEventListener('pagehide', () => {
stopLeaderWork(); // ロックを手放す前にリーダー処理を止める
controller.abort(); // 取得待ちなら取り下げる(取得後は無視される)
release?.(); // 保持中ならロックを手放す
release = undefined;
});
addEventListener('pageshow', (e) => {
if (e.persisted) requestLeadership(); // bfcacheから戻ったら並び直す
});
この形に変えると、リーダーの移動直後(計測では2〜22ミリ秒)に待機中のタブがロックを取得し、元のページもbfcacheから復元されました。unload での解放はbfcacheの妨げになるので使いません。
複数ロックの取得順序統一によるデッドロック回避
ロックを入れ子で取ると、取得順が食い違ったときにデッドロックになります。AとBを逆順に要求する2つの処理を走らせると、3エンジンとも query() の held と pending の両方にAとBが残り、どちらも進みませんでした。止まるのは該当のコードだけで、ページの他のスクリプトは動き続けます。仕様書も「This will not affect the user agent as a whole, pause the tab, or affect other script in the origin」と説明しています。
予防策として仕様書が示しているのは、複数ロックを常に同じ順序で取得するヘルパーです。仕様書の例のままでは同じ名前を2回渡すと自分自身を待って止まるため、下の例では Set で重複を除いています。
async function requestMultiple(resources, callback) {
const sorted = [...new Set(resources)].sort();
const acquire = (i) =>
i === sorted.length
? callback()
: navigator.locks.request(sorted[i], () => acquire(i + 1));
return acquire(0);
}
// どちらの呼び出しも 'account' → 'inventory' の順で取得する
await requestMultiple(['inventory', 'account'], async () => { /* ... */ });
そもそも入れ子にしない設計のほうが安全です。データベース側のデッドロックの原因と解消手順はデッドロックとは?DBトランザクションで起きる原因・例と解消方法で解説しています。
3ブラウザでの実測結果(Chrome・Firefox・WebKit)
ここまでの挙動を、2026年10月20日にChrome 155.0.8059.82、Firefox 157.0、WebKit 27.2(いずれもヘッドレス。WebKitはPlaywright同梱版でSafari本体ではない)で実行した結果です。すべての項目で3エンジンの結果が一致しました。bfcacheの項目だけはChromeのみで確認しています。
| 確認した操作 | 結果(3エンジン共通) |
|---|---|
| 別タブ保持中に ifAvailable | コールバックに null |
| 保持中のタブを閉じる | 待機中の別タブが取得 |
| コールバックで例外 | その例外でreject、ロックは解放 |
| 取得前に abort() | AbortError(reason指定時はその値) |
| 取得後に abort() | 無視され、処理は最後まで実行 |
| steal(相手が exclusive / shared) | 相手は AbortError、相手の処理は継続 |
| NotSupportedError の4条件 | すべて NotSupportedError |
| 別のブラウザコンテキスト | ロックを共有しない |
| 別タブ内の Dedicated Worker | ロックを共有する(null) |
| http://LANのIPアドレス | navigator.locks が undefined |
| AとBを逆順に入れ子で取得 | デッドロック(held・pendingに残る) |
非対応・secure context外でのフォールバック
navigator.locks はIDLで [SecureContext] が付いており、HTTPSかlocalhostなどの安全なコンテキストでしか存在しません。LAN内のIPアドレスへHTTPで接続した検証用ページでは、3エンジンとも undefined でした。開発中だけ使えて、社内の検証サーバーで使えない、という食い違いはたいていこれが原因です。
非対応時の扱いは、処理が重複したときに何が起きるかで決めます。
- 重複しても実害がない処理(統計の送信、キャッシュの温め直し)は、ロックなしでそのまま実行する。
- 重複すると事故になる処理(決済、ローテーションするリフレッシュトークンの更新)は、クライアントのロックに頼らず、サーバー側の冪等キーや排他で守る。
- localStorageのフラグでmutexを自作しない。読み取りと書き込みの間に他タブが割り込めるうえ、フラグを立てたままタブがクラッシュすると残り続ける。
async function withTabLock(name, fn) {
if (!('locks' in navigator)) {
// 非対応ブラウザより先に、HTTPSで動いているかを疑う
console.warn('Web Locks API is unavailable (isSecureContext:', isSecureContext, ')');
return fn(); // 重複を許容できる処理に限る
}
return navigator.locks.request(name, fn);
}
Chrome 69・Firefox 96・Safari 15.4より古いブラウザを対象から外せるなら、フォールバックは検出してログに残すだけで十分です。
ブラウザ・ランタイムの対応状況(2026年10月時点)
| 環境 | 対応バージョン | 備考 |
|---|---|---|
| Chrome / Edge | 69 / 79 | Android版も69 |
| Firefox | 96 | Android版も96 |
| Safari(macOS / iOS) | 15.4 | 2022年3月14日リリース |
| Node.js | v24.5.0 | Stability 1(実験的) |
| Deno | 2.9 | navigator.locks を提供 |
| Bun | 未実装 | 公式の互換表に記載 |
Web PlatformのBaselineでは、Safari 15.4がそろった2022年3月14日に「新たに利用可能」、2024年9月14日に「広く利用可能」になりました(MDNのbrowser-compat-dataとweb-featuresのデータによる)。仕様書はW3C Web Applications Working Groupが2025年9月24日に公開したWorking Draftで、勧告には至っていませんが、実装が先に普及しています。IDLの Exposed=(Window,Worker) のとおりWorkerからも使え、別タブの中のDedicated Workerも同じロックを取り合いました。Workerの使いどころはWeb Workerとは?重い処理を別スレッドへ逃がす実装と使い分けを解説で扱っています。
Node.jsはv24.5.0でグローバルの navigator.locks を追加しました。ドキュメントは「coordinate access to resources that may be shared across multiple threads within the same process」と説明しており、対象は同じプロセス内のスレッドです。v26.5.0で確かめると、worker_threads.locks は navigator.locks と同じオブジェクトで、メインスレッドが保持中のロックに worker_threads 側から ifAvailable で要求すると null になりました。一方、別々に起動した2つのNodeプロセスでは、片方が保持中でももう片方が取得できました。複数プロセスやクラスタ間の排他には使えません。BunはNode.js互換性の一覧で「Missing language, languages and locks.」と明記しており、Bunで動かすコードは機能検出が必要です。
Web Locks APIを選ぶべきでない場面
このAPIで解決できる範囲は、1つのブラウザプロファイル(Node.jsなら1プロセス)の中に閉じています。次の4つに当てはまるなら、Web Locks APIは選択肢から外してください。
サーバー側のデータ整合性を守りたい場合は使えません。別の端末、別のブラウザ、シークレットウィンドウ、別プロファイルからのリクエストは調整されません。二重注文や在庫の重複更新を防ぐなら、サーバー側の排他制御や冪等キーが必要です。冪等キーを含むAPIの設計からまとめて任せたい場合は、API開発・システム連携の支援で相談を受けています。
Service Workerで長時間ロックを保持する設計は避けてください。Service Workerはイベント処理が終わるとアイドル状態で終了させられ、保持していたロックも失われます。調停役として常駐させる設計は成り立ちません。Service Workerそのものの扱いはPWAとは?Service Workerとマニフェストの実装手順・ネイティブアプリとの使い分けと採用判断を解説を参照してください。
処理そのものにタイムアウトを掛けたい場合、signal では要件を満たしません。取得後の signal は無視されます。長く走る可能性のある処理をロック内に置くなら、その処理自身に中断機構を持たせます。
タブ間でデータを受け渡したいだけなら別のAPIです。Web Locksは実行の順番を調停するだけで、値は運びません。データの配信にはBroadcastChannelを使います。
よくある質問
Web Locks APIとは何ですか?
同じオリジン(同じstorage bucket)の複数のタブやWorkerが同じリソースを同時に操作しないよう、名前付きのロックで実行順を調整するブラウザAPIです。navigator.locks.request() でロックを要求し、コールバックが終わるかタブが閉じられると自動的に解放されます。
ほとんどの場合、secure contextで実行していないことが原因です。HTTPSかlocalhostで開いているかを確認してください。LAN内のIPアドレスにHTTPで接続したページでは、Chrome・Firefox・WebKitのいずれも undefined でした。Chrome 69・Firefox 96・Safari 15.4以降なら、ブラウザの対応状況が原因になることはまずありません。
Web Locks APIはNode.jsでも使えますか?
v24.5.0以降で navigator.locks と worker_threads.locks が使えます。ただし安定度は実験的(Stability 1)で、ロックを共有するのは同じプロセス内のスレッド同士だけです。別プロセス間の排他にはファイルロックやデータベースのロックなど別の仕組みが必要です。
Wake Lock APIとWeb Locks APIは同じものですか?
別のAPIです。Wake Lock APIは画面がスリープしないよう保持するためのもので、Web Locks APIはリソースへのアクセス順を調整するためのものです。呼び出し方も navigator.wakeLock.request() と navigator.locks.request() で異なります。
JavaScriptでmutexを自前実装する必要はありますか?
1つのタブの中だけで非同期処理を直列化したいなら、Promiseのチェーンで足ります。タブやWorkerをまたぐ調停が必要な場合だけWeb Locks APIを使い、localStorageのフラグで自作しないでください。他の言語のmutexとの比較には、Goの実装を測ったGoのsync.MutexとRWMutexの違いと使い分け|実測ベンチで選ぶ排他制御が参考になります。