Навіщо фронтендерам мʼютекси: Web Locks API
Усім привіт! Мене звати Святослав Головін, я — Frontend Engineer у Solidgate.
Коли думаєш про роботу Frontend-розробника, уявляєш верстку та стилізацію кнопок. Проте на практиці часто доводиться занурюватися у складніші й цікавіші концепти, які стають ключовими під час розробки нового функціоналу чи розв’язання проблем.
Один із класичних викликів у Backend розробці — синхронізація паралельних процесів та керування спільними ресурсами. Здається, що для фронтенду такі завдання нетипові, але іноді й тут виникає потреба їх вирішувати та, відповідно, мати для цього необхідні інструменти.
З таким викликом зіштовхнувся і я, коли виникла наступна проблема: користувачів нашого застосунка розлогінювало, хоча їхня сесія ще мала бути активною. Як виявилось, причиною стали паралельні запити на оновлення сесії, які були не синхронізовані та перебивали один одного.

Рішенням стало використання концепції м’ютекса і Web Locks API як способу реалізації м’ютексів на фронтенді.
Цей досвід підштовхнув мене написати цю статтю, щоб поділитися тим, як суто бекендовий підхід може стати у пригоді й у Front-end розробці. У процесі я не лише розв’язав конкретну проблему, а й поглибив знання про інструменти браузера, які допоможуть у схожих ситуаціях у майбутньому.
Що таке мʼютекс
Мʼютекс — це засіб синхронізації, який обмежує можливість одночасного доступу до спільного ресурсу або накладає обмеження на виконання паралельної дії.
Де зустрічаються мʼютекси
Термін з Вікі зрозумілий, але на практиці завжди зрозуміліше. Пропоную розглянути класичний приклад зі світу Backend розробки. Він спрощений і в реальних випадках описана синхронізація вже є частиною бази даних, але тут не будемо ускладнювати.
Є така ситуація: мені потрібно зберегти в базі даних інформацію про нового користувача, але тільки якщо його username унікальний.
Може виникнути випадок, коли два користувачі намагаються одночасно зареєструватися й вводять однаковий username. В такому разі очікувана поведінка така — один із них повинен отримати помилку, а другий — успішно створити акаунт.

Виникає питання: як буде поводитись система, коли запити будуть виконані паралельно? Відповідь проста — буде два акаунти з однаковими іменами, що не є очікуваним.

Тут можна згадати про мʼютекс, який гарантуватиме, що лише один з користувачів зможе створити акаунт в певний момент часу, водночас інший буде чекати на свою чергу.

Таким чином оновлена система з використанням мʼютексу матиме:
- контроль доступу до спільного ресурсу, бази даних, і як результат — цілісність даних, що зазвичай реалізовано через транзакції, які виступають мʼютексами;
- гарантію порядку виконання запитів, тобто гарантію, що другий користувач не зможе зареєструватися, доки перший у процесі реєстрації.
Де виникає потреба в мʼютексах у світі Frontend
Насправді паралелізм присутній і в браузері, наприклад:
- в async/await операціях;
- мережевих операціях;
- між браузерними вкладками чи вікнами.
Приклад. Мені завжди подобається наводити цей приклад, бо кожен може зіштовхнутись у своїй роботі з чимось аналогічним.
Уявімо, що в нас є банківський застосунок з можливістю блокувати платіжну картку. Саме блокування має вигляд звичайної кнопки, яка змінює boolean стан.
Логіка її роботи проста: натискання на кнопку → у фоні була виконана асинхронна мутація → сервер надіслав нам оновлений стан → стан змінився.

Але тут є проблема — користувач може натискати на кнопку кілька разів поспіль. І його очікування прості: натиснув, щоб розблокувати, натиснув, щоб заблокувати.

Виникає нюанс: ми виконуємо асинхронну дію, тож коли користувач тисне два рази поспіль, наш застосунок просто робить запит на розблокування двічі й після всіх мережевих запитів результатом буде відсутність блокування.

Перше рішення, яке мені спало на думку — зробити кнопку неактивною, доки виконується зміна стану, але це знижує час відгуку інтерфейсу, що є антипатерном у світі веброзробки.

Наступне рішення, як можна здогадатись, використовує мʼютекс і дозволяє зробити наш інтерфейс оптимістичним.
Ми можемо не одразу виконувати запит на зміну стану, а чекати на завершення попереднього запиту та оновлення асинхронного стану, і тільки після виконувати наступний за чергою асинхронний запит.
Таким чином отримаємо дуже схожу логіку до тієї, яка була в прикладі для бекенду, з головним доповненням у вигляді оптимістичних оновлень стану інтерфейсу. Тепер користувач не буде чекати на завершення асинхронних дій, а одразу побачить оновлений стан.

Де я зіштовхнувся з потребою в мʼютексах
Перейдемо до ситуації, яка і дала початок цій статті.
Для контексту: у нас на проєкті є ряд застосунків, кожен з яких інтегрований з внутрішнім сервісом аутентифікації. За один з таких застосунків відповідаю я.
Система аутентифікації побудована на базі OAuth 2.0 протоколу з використанням JWT та Refresh-токенів. У такій звʼязці JWT відповідає за надання доступу до застосунку, а Refresh потрібен для поновлення сесії користувача, якщо він активно користується ним. Таким чином ми можемо уникнути закінчення активної сесії попри те, що час життя автентифікаційного (JWT) токену вичерпано.
Логіка така — якщо отримано 401 Unauthorized помилку від сервера, спершу потрібно виконати запит на refreshToken, якщо він пройшов успішно, повторити запит з помилкою, а інакше — закінчити сесію.

Як і в попередніх прикладах, все звучить прямолінійно, але на практиці застосунок завжди може робити кілька запитів на сервер водночас і, якщо кожен з них отримає помилку 401, це спровокує кілька паралельних запитів на refreshToken.
Це призведе до того, що перший запит буде виконано успішно з результатом 200 OK, а другий вже з помилкою 401, оскільки він використав застарілий refresh токен. А це, своєю чергою, призведе до завершення сесії, погіршення UX та проблеми, що була на самому початку статті, коли користувачів неочікувано розлогінює.

І знову нам потрібен мʼютекс...
Перша імплементація (локальний мʼютекс)
Історичним розв’язанням цієї проблеми було використання бібліотеки, що надає функціонал мʼютекса з асинхронним інтерфейсом на основі Promise.
Завдяки йому логіка виклику refreshToken матиме такий вигляд: якщо є помилка 401 — робимо запит на отримання мʼютексу. Якщо він доступний — робимо запит на refreshToken та повторюємо помилковий запит. Якщо мʼютекс недоступний — чекаємо на його звільнення і просто повторюємо запит, без додаткового запиту на refreshToken, оскільки ми знаємо, що доки ми чекали, цей запит вже був виконаний.

Так ми уникли неочевидних закінчень сесії та оптимізували запити на refreshToken, щоб не доводилося робити їх повторно. Імплементація має такий вигляд:
async function refreshToken() {
if (mutex.isLocked()) {
// already refreshing, wait for it to finish and skip additional request
await mutex.waitForUnlock();
return;
}
const release = await mutex.acquire();
try {
// ...
} catch (error) {
// ...
} finally {
release();
}
}
Крім цього, у нас весь цей час існувала інша проблема: запити на сервер можуть продовжувати виконуватися одночасно з запитом на refreshToken. Відповідно, нам необхідно спершу дочекатися завершення refreshToken, тобто звільнення мʼютексу, а вже потім виконувати запит.

У коді це можна репрезентувати завдяки патерну Middleware таким чином:
async function refreshTokenMiddleware(next) {
await mutex.waitForUnlock();
try {
return await next();
} catch (error) {
if (error.response.status === 401) {
await refreshToken();
return await next();
}
throw error;
}
}
Рішення начебто зрозуміле, але в ньому є не дуже очевидна проблема, з якою я і зіштовхнувся.
Поточний мʼютекс я назвав локальним через те, що його дія обмежена рамками однієї браузерної вкладки. Тому, коли користувач відкриває кілька вкладок з нашим застосунком, і відбувається запит на refreshToken, мʼютекс не спрацює, оскільки кожна вкладка має свій окремий мʼютекс і вони ніяк не взаємодіють між собою.
Відсутність взаємодії — це проблема, оскільки обидві вкладки керують одним і тим самим Refresh-токеном, бо він зберігається в Cookie-сховищі, яке є спільним для всіх вкладок. Таким чином у нас знову виникають труднощі при роботі зі спільними ресурсами.

Тут нам на допомогу, як і завжди, приходить браузер...
Web Locks API. Браузер надає нам просте й зрозуміле API, яке дозволило мені імплементувати власне рішення з мʼютексами. Перед цим дам трохи контексту й відповім на питання: «А що саме нам дає браузер?».
Введення
Перед викликом потрібної нам функції ми можемо спитати в браузера, чи є така можливість, тобто чи вільний наразі мʼютекс. Якщо вільний — виконуємо, інакше — чекаємо:
navigator.locks.request('my_mutex', async (lock) => {
await users();
});
Інтерфейс досить прямолінійний, все, що передано в callback, буде виконано, коли мʼютекс стане доступним. Водночас доки callback виконується, мʼютекс буде заблоковано.
Іноді виникає потреба виконати операцію за межами callback-функції. На щастя, JavaScript дає нам можливість таке зробити, використовуючи Promise:
async function acquire(): Promise<() => void> {
return new Promise((resolve, reject) => {
navigator.locks
.request('my_mutex', () => {
return new Promise<void>((r) => {
resolve(r);
});
})
.catch(reject);
});
}
const release = await acquire();
try {
await users();
release();
} catch (error) {
console.error(error);
}
Сподіваюсь, ви зауважили, що таке використання release-функції може призвести до того, що мʼютекс залишиться заблокованим, якщо в нас станеться помилка в запиті на сервер і ми потрапимо в catch-блок.
Така ситуація називається deadlock і з нею краще не жартувати. У цьому випадку це призведе до того, що всі наступні запити чекатимуть на звільнення мʼютекса, тобто запит на отримання користувачів не буде виконуватися й інтерфейс буде відображати нескінченне завантаження списку даних.
Виправити це легко. Потрібно, щоб release викликався незалежно від фінального результату виконання коду:
// ...
const release = await acquire();
try {
await users();
} catch (error) {
console.error(error);
} finally {
release(); // always release the lock
}
Особливості API
Браузер дає нам декілька гарантій:
- мʼютекс існує глобально в межах одного origin, відповідно, потенційні колізії з іншими застосунками відсутні;
- мʼютекс існує в межах всього браузера, тому ми можемо не лише синхронізувати різні вкладки, але й цілі браузерні вікна;
- у випадку, коли користувач закриє вкладку, м’ютекс завжди буде звільнено автоматично, тому ризик deadlock у такому сценарії відсутній;
- мʼютекси блокуються послідовно, за принципом черги, тому ми можемо бути певні, що попередні запити не очікуватимуть виконання нових;
- request-метод приймає різну конфігурацію, а саме:
navigator.locks.request(
'my_mutex',
{
signal: undefined, // AbortSignal
steal: false, // boolean
ifAvailable: false, // boolean
mode: 'exclusive', // "exclusive" | "shared"
},
async (lock) => {
await users();
},
);
Розглянемо кожен з параметрів детальніше.
signal
Як і всі сучасні асинхронні API, request дає можливість передавати AbortController для переривання дій під час їх виконання.
steal
Іноді потрібно виконати операцію незалежно від стану м’ютексу. У такому разі можна передати в параметр steal значення true. Це призведе до розблокування поточного м’ютексу й виконання операції першою в черзі.
Важливий нюанс використання — ризик одночасного виконання кількох операцій, тобто конкурентна поведінка. Це відбувається тому, що під час виконання операції параметр steal не перериває її, а лише блокує м’ютекс і водночас виконує іншу операцію.
ifAvailable
Зі steal зрозуміло, але існує мʼякша альтернатива — параметр ifAvailable. Якщо передати true, запит у request виконуватиметься негайно, незалежно від того, чи заблокований м’ютекс. Поточний стан м’ютекса можна визначити за параметром lock, що передається у callback: якщо значення є — м’ютекс було заблоковано цим запитом; якщо null — callback виконано під час зайнятого іншими м’ютекса.
Це може бути корисним для зчитування поточного стану мʼютекса:
async function isLocked(): Promise<boolean> {
return new Promise((resolve, reject) => {
navigator.locks
.request(
'my_mutex',
/*
`ifAvailable` ensures that callback will be fired immediately, even if the lock is held
*/
{ ifAvailable: true },
(lock) => {
resolve(lock === null); // null === the lock is already held
},
)
.catch(reject);
});
}
mode
Якщо ви працювали з мʼютексами, могли чути про патерн readers-writer. Він дозволяє кільком функціям, що класифікуються як reader, одночасно блокувати мʼютекс, якщо він ще не заблокований функцією writer. Водночас тільки один writer може блокувати мʼютекс.
Якщо передати значення shared у параметр mode, request переключиться в саме такий режим.

query
Відходячи від параметрів request, API також дає можливість отримати стан всіх поточних мʼютексів.
Це зручний інструмент відладки, але використовувати його для логік у коді не рекомендовано.
Використання має такий вигляд:
const state = await navigator.locks.query();
А результатом є такий обʼєкт:
const state = {
"held": [
{
"clientId": "af7993cd-aa63-4997-9620-74ff0a1bf056",
"mode": "exclusive",
"name": "my_mutex"
}
],
"pending": [
{
"clientId": "af7993cd-aa63-4997-9620-74ff0a1bf056",
"mode": "shared",
"name": "my_mutex"
}
]
}
Браузерна підтримка
Підтримка API на момент виходу статті майже 95%, що дозволяє вільно інтегрувати його у свої застосунки.

Я вирішив перестрахуватися у своєму застосунку та додав логіку з локальним м’ютексом на випадок недоступності API. Такий підхід неефективний для роботи між вкладками, але не вплине на досвід користувачів у межах однієї вкладки, якщо вони зайдуть зі старого браузера.
Альтернативно можна використати поліфіл для розв’язання проблеми, але мене повністю влаштувало таке рішення:
async function acquire(): Promise<() => void> {
const isApiAvailable =
Boolean(navigator.locks?.request) &&
typeof navigator.locks.request === 'function';
if (!isApiAvailable) {
return await mutex.acquire();
}
return new Promise((resolve, reject) => {
navigator.locks
.request('my_mutex', () => {
return new Promise<void>((r) => {
resolve(r);
});
})
.catch(reject);
});
}
Обмеження API
Слід зазначити декілька незручних обмежень наразі:
— немає можливості підписатися на стан одного чи всіх м’ютексів, тому доводиться реалізовувати polling, що є не оптимальним з точки зору використання ресурсів.
async function getLock(): Promise<Lock | null> {
return new Promise((resolve, reject) => {
navigator.locks
.request('my_mutex', { ifAvailable: true }, (lock) => {
resolve(lock);
})
.catch(reject);
});
}
async function pollForLock(): Promise<Lock> {
let lock: Lock | null = null;
while (lock === null) {
lock = await getLock();
if (lock) {
return lock;
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
}
— немає можливості вказати максимальний час очікування на заблокований м’ютекс, що може призвести до нескінченного очікування. Тут можу запропонувати власне рішення з використанням уже відомого AbortController:
async function acquire(timeout?: number): Promise<() => void> {
const abortController = new AbortController();
const { signal } = abortController;
return new Promise((resolve, reject) => {
navigator.locks
.request('my_mutex', { signal }, () => {
return new Promise<void>((r) => {
resolve(r);
});
})
.catch(reject);
if (timeout) {
setTimeout(() => {
abortController.abort();
}, timeout);
}
});
}
Друга імплементація (Web Locks API)
Підбиваючи підсумок: у нас є локальний м’ютекс, розуміння роботи Web Locks API та потреба в синхронізації між вкладками.
Рішення — об’єднати весь описаний функціонал у клас, який надає зручний інтерфейс для роботи з наявним API та додає локальний м’ютекс на випадок, якщо браузер користувача не має відповідної підтримки.
import { Mutex } from 'some-mutex';
class CrossTabMutex {
private static readonly isApiAvailable =
Boolean(navigator.locks?.request) &&
typeof navigator.locks.request === 'function';
private readonly fallbackMutex = new Mutex();
private static readonly keyPrefix = 'cross_tab_mutex_';
private key: string;
constructor(key: string) {
this.key = `${CrossTabMutex.keyPrefix}${key}`;
}
async acquire(options: LockOptions = {}): Promise<() => void> {
if (!CrossTabMutex.isApiAvailable) {
return await this.fallbackMutex.acquire();
}
return new Promise((resolve, reject) => {
navigator.locks
.request(this.key, options, () => {
return new Promise<void>((r) => {
resolve(r);
});
})
.catch(reject);
});
}
async isLocked(): Promise<boolean> {
return new Promise((resolve, reject) => {
if (!CrossTabMutex.isApiAvailable) {
resolve(this.fallbackMutex.isLocked());
return;
}
navigator.locks
.request(this.key, { ifAvailable: true }, (lock) => {
resolve(lock === null); // null === the lock is held
})
.catch(reject);
});
}
async waitForUnlock(): Promise<void> {
if (!CrossTabMutex.isApiAvailable) {
return await this.fallbackMutex.waitForUnlock();
}
return new Promise((resolve) => {
navigator.locks.request(this.key, () => {
resolve();
});
});
}
}
export default CrossTabMutex;
Тепер можна повернутися до функції refreshToken. Використання м’ютекса залишається таким самим, проте з’являється потреба в синхронізації між вкладками, щоб уникнути подвійних запитів на оновлення токенів. Для цього можна використати добре відомий localStorage:
async function refreshToken() {
const previouslyRefreshedAt = localStorage.getItem('my_refresh_token');
const release = await crossTabMutex.acquire();
if (previouslyRefreshedAt !== localStorage.getItem('my_refresh_token')) {
// another tab has already refreshed the token, while we were waiting to acquire the lock
release();
return;
}
try {
// ...
} catch (error) {
// ...
} finally {
localStorage.setItem('my_refresh_token', `${Date.now()}`);
release();
}
}
Таким чином ми не будемо виконувати другу операцію, якщо вона чекала на виконання першої, бо токени для аутентифікації в цей момент вже будуть актуалізовані.

Далі варто докладніше розглянути refreshTokenMiddleware. Новий клас має аналогічний інтерфейс, тож попередня версія продовжить працювати, проте пропоную розглянути його детальніше:
async function refreshTokenMiddleware(next) {
await mutex.waitForUnlock();
try {
return await next();
} catch (error) {
if (error.response.status === 401) {
await refreshToken();
return await next();
}
throw error;
}
}
refreshToken може запускатися під час виконання іншого запиту — це може призвести до непередбачуваних помилок 401.

Розв’язання цієї проблеми — використання параметра mode. Якщо передати значення shared, запит на refreshToken почекає, поки завершаться всі поточні мережеві операції, а потім виконається. Водночас всі інші запити автоматично очікуватимуть на оновлення токена, як це було раніше.
Можна також скористатися режимом exclusive, але тоді всі мережеві запити виконуватимуться послідовно, що не дуже ефективно з погляду ресурсів браузера.
Фінальний варіант коду виглядає так:
async function fetchWithLock(execute: () => Promise<unknown>) {
const release = await crossTabMutex.acquire({ mode: 'shared' });
try {
return await execute();
} catch (error) {
throw error;
} finally {
release();
}
}
async function refreshTokenMiddleware(next) {
try {
return await fetchWithLock(next);
} catch (error) {
if (error.response.status === 401) {
await refreshToken();
return await fetchWithLock(next);
}
throw error;
}
}
Можливі покращення. Поточне рішення повністю справляється зі своєю задачею, хоча є кілька аспектів, які можна покращити.
Проблеми з deadlock. Як уже було показано, якщо не викликати release, усі подальші операції з м’ютексом зависнуть.
Одним із рішень може бути автоматичне звільнення м’ютекса через певний проміжок часу. Проте це не завжди бажано, адже деякі операції можуть тривати довше.
Альтернативний підхід — відмовитися від концепції окремої release-функції і реструктуризувати код у стилі, рекомендованому документацією Web Locks API. Тобто виконувати всі асинхронні запити всередині переданої callback-функції:
navigator.locks.request('my_mutex', async (lock) => {
await users();
});
Обмеження в один браузер
Наразі Web Locks API працює лише в межах одного браузера. Тобто, якщо користувач відкриє декілька вікон у різних браузерах, механізм працюватиме так само як локальний м’ютекс.
Це обмеження не є проблемою при роботі з refresh токеном, оскільки кожен браузер буде мати своє cookie сховище, що прибирає необхідність роботи з спільним ресурсом, але відсутність кросс-браузерної підтримки все ще може викликати проблеми в інших випадках.
Одне з потенційних рішень — відстежувати події, пов’язані зі зміною активності вкладки. Як наслідок, коли вкладка стає неактивною, всі операції зупиняються. Це прибирає потребу в м’ютексі та додатково оптимізує використання ресурсів користувача.
Реалізувати це можна за допомогою Page Visibility API та подій blur і focus для обʼєкта window:
window.addEventListener('blur', () => console.log('Bye!'));
window.addEventListener('focus', () => console.log('Hi!'));
Недоліком є те, що не всі асинхронні запити, включно з мережевими, можна завжди успішно скасувати, якщо вони вже виконуються. Це буде призводити до потенційних конкурентних помилок.
Конкурентна поведінка при закритті вкладки
Одним із плюсів API є те, що м’ютекс автоматично звільняється при закритті вкладки. Але саме це може призвести до конкурентної поведінки.
Наприклад, під час виконання refreshToken користувач закриває вкладку. Мережевий запит не зупиняється, а от м’ютекс звільняється. Наступний запит refreshToken у черзі може виконатися паралельно. Це створює ризик небажаних помилок.
Рішенням стало побудувати канал звʼязку між вкладками, використовуючи Broadcast Channel API. Таким чином при закритті однієї вкладки інші можуть про це дізнаватися й підлаштовувати свою поведінку так, щоб уникати конкурентних запитів.
Під час розробки поточного рішення було розглянуто можливі альтернативи.
Workers API
Сучасні браузери надають ряд API для роботи з окремими процесами. Коли мова заходить про синхронізацію, першим на думку спадає Shared Worker, основною задачею котрого є можливість використання спільного середовища різними вкладками. Це API не було використано через низьку підтримку на момент створення рішення, хоча воно й має потенціал для розв’язання аналогічних проблем.

Також були ідеї використати Service Worker як proxy шар, що буде централізовано перехоплювати запити та за потреби виконувати оновлення сесії. Це рішення не було впроваджено, оскільки вимагало змін в інфраструктуру нашого застосунка й вносило додаткову складність.
BFF
Ідейно схожим рішенням до Service Worker API є використання патерну Backend for Frontend, окремого сервісу, що виконує проксювання та трансформацію запитів між фронтенд-застосунком та іншими сервісами системи. Рішення нам підходило, оскільки BFF вже присутній у нашій системі й це дозволило б спростити поточний застосунок і уникнути проблеми синхронізації рефреш-запитів.
Однак після додаткового дослідження було виявлено ряд недоліків:
- необхідність зберігати та управляти станом на рівні BFF, що вимагало впровадження кешу чи аналогічного рішення;
- вичерпування ресурсів та додаткове навантаження на BFF, оскільки кожен запит вимагає додаткової логіки з менеджментом сесії.
Таким чином попереднє рішення з мʼютексом було вдосконалено шляхом інтеграції Web Locks API.
Підсумок
Отже, м’ютекси — це не лише поняття, що живе на сервері. Вони можуть стати у пригоді й на фронтенді, коли йдеться про паралельні операції чи доступ до спільного ресурсу, наприклад:
- при роботі з localStorage чи cookies (спільний ресурс);
- при координації роботи Web Workers (паралельні операції);
- при роботі з Service Workers, коли потрібне власне кешування (спільний ресурс).
Не бійтеся використовувати м’ютекси у своїх проєктах. А щоб зробити їхнє використання ефективнішим, варто залучити Web Locks API браузера.
Ресурси:
— Мʼютекс
— Взаємне блокування (Deadlock)
— Optimistic UI
— JSON Web Token
— HTTP 401 Unauthorized
— HTTP 200 OK
— Promise
— Chain of Responsibility Pattern
— Web Locks API
— AbortController
— Readers-Writer Lock
— Web Locks API Support
— Polling (Computer Science)
— Document.cookie
— Page Visibility API
— Web Workers API
— Service Worker API
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

60 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів