WebSockets і реалтайм
Зміст
Класичний веб побудований на простій угоді: браузер щось просить, сервер відповідає, обмін завершується. Ця модель чудово працює для сторінок, форм і REST-API. Але щойно застосунку треба, щоб дані зʼявлялися самі — нове повідомлення в чаті, зміна ціни на біржі, оновлення статусу замовлення — модель «спитав-отримав» починає тріщати. WebSocket — це відповідь на питання «як зробити, щоб сервер міг заговорити першим».
Для AQA тема має подвійну вагу. По-перше, реалтайм-функції треба тестувати, а поводяться вони інакше, ніж звичайні запити. По-друге, WebSocket — стабільне джерело флаку (flakiness) в автотестах, бо повідомлення приходять асинхронно й непередбачувано в часі.
Чому звичайного HTTP не вистачає
У HTTP ініціатором завжди є клієнт. Сервер не має способу «постукати» в браузер сам — він може лише відповісти на вже надісланий запит. Це модель «запит-відповідь» (request-response), і в межах одного обміну вона поводиться напівдуплексно (half-duplex): спершу до кінця говорить один бік, потім інший. Сервер мовчить, доки його не спитають.
Коли розробникам потрібен потік свіжих даних поверх такого HTTP, вони історично використовували два обхідні прийоми.
Короткий опитувальний цикл (short polling) — клієнт раз у N секунд шле запит «є щось нове?», і сервер одразу відповідає, навіть якщо нового нема.
setInterval(async () => {
const res = await fetch('/api/messages?since=' + lastId);
const data = await res.json();
if (data.length) render(data);
}, 3000);
Проблеми очевидні: опитувати рідко — дані запізнюються; часто — маса порожніх запитів і зайве навантаження на сервер. Середня затримка доставки дорівнює половині інтервалу.
Довгий опитувальний цикл (long polling) — клієнт шле запит, а сервер не відповідає одразу, а «тримає» зʼєднання відкритим, доки не зʼявиться подія (або не спрацює таймаут). Отримавши відповідь, клієнт одразу шле наступний запит.
GET /api/messages/wait HTTP/1.1
Host: example.com
Long polling дає майже реалтайм і працює скрізь, де є звичайний HTTP, тому довго був стандартом для чатів. Але кожне повідомлення — це окремий цикл «відповідь + новий запит» із повним набором заголовків (headers), а канал односпрямований: сервер віддає дані лише у відповідь на черговий запит клієнта.
| Підхід | Затримка | Навантаження | Напрямок |
|---|---|---|---|
| Short polling | висока (до інтервалу) | багато порожніх запитів | клієнт → сервер |
| Long polling | низька | менше запитів, але зʼєднання «висять» | клієнт ініціює, сервер відповідає з даними |
| WebSocket | найнижча | одне зʼєднання на весь час | двобічний |
Що таке WebSocket
WebSocket — це протокол (RFC 6455), який дає одне постійне зʼєднання поверх одного TCP-зʼєднання, де обидва боки можуть слати дані будь-коли. Це і є повний дуплекс (full-duplex): сервер може надіслати повідомлення, не чекаючи запиту, і клієнт теж — одночасно, в обидва боки, як телефонна розмова, а не як рація.
Важлива деталь для розуміння: WebSocket — це окремий протокол, а не «HTTP, який тримається відкритим». Він лише починається як HTTP-запит, а після рукостискання те саме TCP-зʼєднання переходить на власний бінарний формат кадрів (frame). Тому зникає накладка HTTP: не треба щоразу слати заголовки, куки, повторювати автентифікацію. Заголовок кадру — це лічені байти (проти сотень байтів HTTP-заголовків), тож реалтайм виходить дешевшим і швидшим, ніж будь-який polling.
Кадр несе кілька службових полів: біт завершення повідомлення, код операції (opcode) — текст, бінарні дані, закриття, ping чи pong — і корисне навантаження. Одна деталь протоколу варта згадки, бо пояснює логіку рукостискання: кожен кадр від клієнта до сервера маскується випадковим 32-бітним ключем (сервер свої кадри не маскує). Це не про приватність — за формулюванням RFC, маскування потрібне, щоб не збивати з пантелику проміжні вузли (зокрема перехоплювальні проксі), і з міркувань безпеки: воно закриває клас атак, коли зловмисник намагається протягнути крізь кеш проксі дані, схожі на легітимну HTTP-відповідь (отруєння кешу).
Upgrade-handshake: як HTTP переходить у WebSocket
Зʼєднання починається звичайним HTTP-запитом GET зі спеціальними заголовками, які означають «хочу перемкнутися на WebSocket». Це називають рукостисканням (handshake).
Запит від браузера:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
Ключові заголовки:
| Заголовок | Роль |
|---|---|
Upgrade: websocket | просить змінити протокол |
Connection: Upgrade | підтверджує намір перемкнутися |
Sec-WebSocket-Key | випадковий ключ клієнта (base64), захист від випадкового апгрейду кешами/проксі |
Sec-WebSocket-Version: 13 | версія протоколу (13 — актуальна за RFC 6455) |
Якщо сервер згоден, він відповідає статусом 101 Switching Protocols — це і є момент, коли зʼєднання перестає бути HTTP:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Значення Sec-WebSocket-Accept сервер обчислює детерміновано: бере ключ клієнта, дописує до нього фіксовану константу-GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, рахує SHA-1 і кодує результат у base64. Клієнт повторює те саме обчислення й звіряє. Це не автентифікація й не шифрування — лише доказ, що на іншому боці справді WebSocket-сервер, який зрозумів запит, а не випадковий кеш чи проксі, що механічно повторив відповідь.
Після 101 те саме TCP-зʼєднання вже несе WebSocket-кадри, а не HTTP-повідомлення. У рукостисканні можна також домовитися про піддипротокол (Sec-WebSocket-Protocol) і розширення на кшталт стиснення (Sec-WebSocket-Extensions: permessage-deflate).
ws vs wss
Схема URL визначає, чи буде зʼєднання зашифроване.
| Схема | Що це | Порт за замовчуванням | Шифрування |
|---|---|---|---|
ws:// | WebSocket поверх звичайного TCP | 80 | немає |
wss:// | WebSocket поверх TLS | 443 | так (TLS) |
Співвідношення таке саме, як http до https: wss — це той самий WebSocket, але всередині TLS-тунелю. У проді використовують лише wss. Крім приватності є суто практична причина: проміжні проксі й корпоративні мережі часто ламають незашифрований ws, не розуміючи апгрейд, а зашифрований wss для них — непрозорий тунель, який вони пропускають як є. Ще одне правило браузерів: зі сторінки, відкритої по https, не можна відкрити ws:// — це змішаний контент (mixed content), і браузер його блокує (мовчки, з помилкою в консолі; обійти це заголовком чи прапорцем не можна).
Життєвий цикл: readyState і події
У браузері WebSocket — це обʼєкт із власним станом і чотирма подіями життєвого циклу. Поточний стан читається з властивості readyState:
| Константа | Значення | Стан |
|---|---|---|
WebSocket.CONNECTING | 0 | триває рукостискання |
WebSocket.OPEN | 1 | зʼєднання відкрите, можна слати |
WebSocket.CLOSING | 2 | почалося закриття |
WebSocket.CLOSED | 3 | закрите або не вдалося відкрити |
const socket = new WebSocket('wss://example.com/chat');
socket.addEventListener('open', () => {
// зʼєднання встановлене, можна слати
socket.send(JSON.stringify({ type: 'auth', token: 'abc123' }));
});
socket.addEventListener('message', (event) => {
// event.data — рядок або бінарні дані
const msg = JSON.parse(event.data);
console.log('прийшло:', msg);
});
socket.addEventListener('close', (event) => {
console.log('закрито', event.code, event.reason, event.wasClean);
});
socket.addEventListener('error', () => {
// деталей навмисно мало — з міркувань безпеки
console.log('сталася помилка зʼєднання');
});
- open — рукостискання успішне, зʼєднання готове. До цієї події
send()кидатиме помилку. - message — прийшли дані.
event.data— це або текст (типово JSON-рядок), або бінарні дані (Blob/ArrayBuffer). Кожне повідомлення приходить окремою подією; порядок збережений, але момент приходу непередбачуваний. - close — зʼєднання закрилося. У
CloseEventєcode,reasonіwasClean(чи це було охайне закриття, чи обрив). - error — щось пішло не так. Навмисно віддає мало інформації; майже завжди слідом іде
close.
Ще одна корисна властивість — bufferedAmount: кількість байтів, які ви вже передали в send(), але які ще не пішли в мережу. Якщо слати швидше, ніж канал встигає віддавати, буфер росте — це сигнал робити зворотний тиск (backpressure), а не сліпо накопичувати дані.
Коди закриття задані протоколом. Найпоширеніші:
| Код | Значення |
|---|---|
| 1000 | Нормальне закриття |
| 1001 | Going away (сторінка закривається або сервер вимикається) |
| 1002 | Помилка протоколу |
| 1006 | Аварійний обрив: close-кадру не було; код ставить сама реалізація й по мережі він не передається |
| 1009 | Повідомлення завелике |
| 1011 | Внутрішня помилка сервера |
Діапазони теж значущі: 1000–2999 зарезервовані для потреб самого протоколу, 3000–3999 — для бібліотек, фреймворків і застосунків (реєструються в IANA), 4000–4999 — для приватного вжитку за домовленістю сторін. Код 1006 варто памʼятати окремо: якщо ви його бачите, зʼєднання просто зникло (втрата мережі, вбитий процес, таймаут проксі), а не було охайно закрите.
Heartbeat і ping-pong
Відкрите зʼєднання може «мовчати» годинами. Проблема в тому, що проміжні вузли — балансувальники, проксі, NAT — часто закривають ідле-зʼєднання за таймаутом, і жоден бік не дізнається про це одразу. Тому потрібен спосіб перевіряти, що канал живий, і не давати йому «протухнути».
На рівні протоколу для цього є керівні кадри ping і pong: один бік шле ping, інший зобовʼязаний відповісти pong. Це вбудований механізм WebSocket. Важливий нюанс для тестувальника: браузерний WebSocket API не дає керувати ping/pong із JavaScript. Браузер сам відповідає pong на серверний ping, але події «прийшов ping» у JS немає, і надіслати ping вручну зі сторінки не можна.
Саме тому багато застосунків будують власний heartbeat поверх звичайних повідомлень — шлють JSON на кшталт { "type": "ping" } і чекають { "type": "pong" }, або просто регулярний «keepalive»:
setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'ping' }));
}
}, 25000);
Прикладний heartbeat вирішує ще одну задачу: якщо очікуваний pong не прийшов за N секунд, клієнт вважає зʼєднання мертвим, закриває його й перепідключається, не чекаючи, доки операційна система помітить обрив TCP (а це буває дуже довго).
Навіщо WebSocket на практиці
Будь-де, де важлива низька затримка й ініціатива на боці сервера:
- Чати й месенджери — повідомлення мусять приходити миттєво, без опитування.
- Нотифікації — «вам відповіли», «зʼявилося завдання», лічильники непрочитаного.
- Трейдинг і котирування — ціни змінюються десятки разів на секунду; тут polling неприйнятний за затримкою.
- Спільне редагування — курсори й зміни інших користувачів у реальному часі (документи, дошки, дизайн-редактори).
- Живі дашборди й моніторинг — метрики, логи, статуси прогонів, що оновлюються самі.
Показовий приклад із нашої предметної області: сторінка запуску (run) у системі керування тестами, де статуси тестів і лічильники «капають» у міру виконання, — типовий кандидат на WebSocket або SSE.
SSE проти WebSocket
Server-Sent Events (SSE) — це другий інструмент реалтайму, і його часто плутають із WebSocket. Головна відмінність — у напрямку.
| Ознака | WebSocket | SSE |
|---|---|---|
| Напрямок | двобічний (full-duplex) | лише сервер → клієнт |
| Протокол | власний, поверх TCP (після HTTP-апгрейду) | звичайний HTTP, потік text/event-stream |
| Формат даних | текст і бінарні дані | лише текст (UTF-8) |
| API у браузері | WebSocket | EventSource |
| Автоперепідключення | треба писати самому | вбудоване (з Last-Event-ID) |
| Обмеження на кількість | практично немає | на HTTP/1.1 — ~6 зʼєднань на домен |
SSE — це, по суті, одна нескінченна HTTP-відповідь, яку сервер поступово «доливає»:
GET /events HTTP/1.1
Accept: text/event-stream
data: {"price": 42.10}
data: {"price": 42.15}
const es = new EventSource('/events');
es.onmessage = (e) => console.log(JSON.parse(e.data));
SSE простіший, працює поверх звичайного HTTP (легше дружить із проксі й балансувальниками) і сам перепідключається після обриву, підставляючи Last-Event-ID, щоб не втратити події. Його слабина — однобічність (клієнт не може слати назад тим самим каналом) і обмеження браузера на кількість одночасних зʼєднань на домен по HTTP/1.1 (по HTTP/2 це обмеження практично знімається за рахунок мультиплексування — багато потоків ділять одне TCP-зʼєднання).
Коли що обирати:
- Потрібен двобічний обмін (чат, гра, спільне редагування, команди від клієнта) → WebSocket.
- Потрібні лише оновлення від сервера (стрічка новин, котирування, прогрес, нотифікації) → SSE: простіше, дешевше в підтримці, з безкоштовним перепідключенням.
- Оновлення рідкісні або допустима затримка в секунди → інколи чесніше залишити звичайний polling: менше рухомих частин, легше тестувати.
Чи переживає WebSocket перезавантаження сторінки
Ні. WebSocket живе в контексті виконання JavaScript конкретної вкладки. Жорстке перезавантаження (reload) або перехід за посиланням знищує цей контекст разом з обʼєктом WebSocket, і зʼєднання закривається — сервер побачить close (нерідко з кодом 1001 або аварійний 1006). Після завантаження нової сторінки все починається з нуля: новий handshake, новий стан, повторна автентифікація й повторна підписка на потрібні події.
Виняток лише один, і він насправді не суперечить правилу: у SPA «мʼяка» навігація без перезавантаження (коли змінюється лише вміст, а сторінка не грузиться заново) може зберегти зʼєднання — але тільки якщо сокет живе в постійному модулі чи сторі застосунку, а не привʼязаний до компонента, який розмонтувався.
Для AQA це прямий наслідок у тестах: після кожного page.reload() або переходу на нову сторінку стан реалтайму скидається. Не можна розраховувати, що підписка або отримані до перезавантаження повідомлення «доживуть» до наступного кроку.
Поведінка при втраті мережі та reconnect
Коли мережа зникає, WebSocket не завжди помічає це миттєво. Якщо був охайний обрив — прилетить close. Якщо звʼязок просто пропав (Wi-Fi відвалився, проксі вбив ідле-зʼєднання) — сторона може довго вважати, що зʼєднання живе, доки не спрацює heartbeat або TCP-таймаут. Ось чому прикладний ping/pong із попереднього розділу такий важливий: він робить «тихий» обрив помітним.
Логіку перепідключення (reconnect) для WebSocket доводиться писати вручну — на відміну від SSE, вбудованого автоперепідключення тут немає. Типова, добре поводжена реалізація:
let attempt = 0;
function connect() {
const socket = new WebSocket('wss://example.com/chat');
socket.addEventListener('open', () => { attempt = 0; });
socket.addEventListener('close', (e) => {
if (e.code === 1000) return; // закрили навмисно — не перепідключаємось
const delay = Math.min(1000 * 2 ** attempt, 30000); // експоненційна пауза
attempt++;
setTimeout(connect, delay);
});
}
connect();
Тут два важливі прийоми: експоненційне збільшення паузи (backoff), щоб не бомбардувати сервер запитами під час аварії (на практиці до паузи ще додають випадковий розкид — jitter, щоб клієнти не ломилися назад синхронно), і розрізнення «нас закрили навмисно» від «звʼязок обірвався». Після перепідключення застосунок мусить відновити стан — заново автентифікуватися, підписатися на канали й, в ідеалі, дозапросити пропущені за час обриву повідомлення (наприклад, за останнім відомим id). Якщо цього нема — після короткого обриву мережі частина даних просто зникне. Це класичний баг, який варто перевіряти окремо.
WebSocket очима AQA: як тестувати
Дивимося повідомлення в DevTools
Найшвидший спосіб побачити, що реально ходить каналом, — вкладка Network у DevTools. WebSocket-зʼєднання відображається як запит зі статусом 101 Switching Protocols; список можна звузити фільтром WS. Клік по цьому запиту відкриває вкладку з повідомленнями (у Chrome — Messages) — там видно кожен кадр: напрямок (стрілка вгору — надіслано клієнтом, вниз — прийшло від сервера), вміст і час. Вхідні й вихідні кадри додатково розрізняються кольором тла.
На що дивитися під час дослідницького тестування:
- заголовки апгрейду в запиті (чи справді
Upgrade: websocket, чи єSec-WebSocket-*); - реальний вміст кадрів — формат JSON, назви полів, чи є heartbeat-повідомлення;
- момент і код закриття, якщо зʼєднання рветься.
Нюанс. Назви вкладок і колонок («Messages» тощо) різняться між Chrome і Firefox та між версіями DevTools — суть однакова: окремий перегляд кадрів обраного зʼєднання.
Автоматизація на Playwright
Playwright уміє спостерігати за WebSocket через подію page.on('websocket') і слухати кадри:
page.on('websocket', ws => {
console.log('WS відкрито:', ws.url());
ws.on('framesent', f => console.log('→', f.payload));
ws.on('framereceived', f => console.log('←', f.payload));
ws.on('close', () => console.log('WS закрито'));
});
Це дає змогу перевіряти, що клієнт справді підписався, що прийшло очікуване повідомлення, або що після дії в UI пішов правильний кадр. Для повного контролю Playwright має також перехоплення й мокання WebSocket через page.routeWebSocket() — можна підмінити сервер і віддавати наперед задані повідомлення, ізолювавши тест від реального бекенду. Ця можливість зʼявилася у Playwright 1.48, тож у старіших версіях проєкту її не буде — варто перевірити версію перед використанням.
Cypress історично WebSocket-трафік не перехоплює (cy.intercept працює з HTTP/XHR/fetch, але не з WS), тому там мокають на рівні застосунку або клієнтської бібліотеки.
Чому WebSocket складніше автоматизувати
Причини лежать у самій природі протоколу, і всі вони — джерела флаку:
- Немає кореляції «запит-відповідь». У HTTP кожен запит має свою відповідь, і на неї легко почекати. У WebSocket повідомлення приходять коли завгодно й не привʼязані до конкретної дії — тест не може просто «дочекатися відповіді на цей запит».
- Асинхронність і час. Повідомлення прилітають із затримкою, яку тест не контролює. Спокуса поставити
waitForTimeout(2000)— прямий шлях до нестабільних тестів. Правильно — чекати на подію-наслідок (появу елемента в UI, конкретний кадр), а не на фіксований час. - Стан живе між діями. Одне зʼєднання тримає стан підписок, автентифікацію, чергу повідомлень. Це ускладнює ізоляцію стану між тестами: сокет, відкритий в одному тесті, або нотифікація, породжена іншим тестом на спільному середовищі, можуть протекти й зіпсувати сусідній прогін.
- Порядок і гонки. Кілька повідомлень можуть прийти майже одночасно; UI може відрендерити їх у неочікуваному порядку. Асерти на «кінцевий стан» надійніші, ніж на проміжні.
- Перепідключення. Тест може ненавмисно спровокувати reconnect (наприклад, паузою чи перемиканням вкладок), і після нього застосунок опиниться в іншому стані, ніж очікував сценарій.
- Локатори на динамічний вміст. Дані, що «капають» по WebSocket, зʼявляються поступово, тому локатори мусять бути стійкі до того, що елемент виникає не одразу, а асерти — до того, що список ще добудовується.
Практичний висновок: реалтайм-функції варто тестувати або через стійкі очікування UI-наслідків (найближче до досвіду користувача), або через мокання WebSocket (детерміновано, швидко, ізольовано) — залежно від того, що ви перевіряєте: інтеграцію з реальним сервером чи поведінку самого фронтенду. Найкрихкіший варіант — асертити на реальні повідомлення від живого бекенду з таймінгом «на око»; його лишають лише для наскрізних сценаріїв і завжди підпирають надійними очікуваннями замість пауз.
Що таке WebSocket і чим він відрізняється від звичайного HTTP?
WebSocket — це окремий протокол (RFC 6455), який дає одне постійне двобічне зʼєднання поверх TCP, де обидва боки можуть слати дані будь-коли. У HTTP ініціатор завжди клієнт: сервер не може «постукати» в браузер сам, лише відповісти на вже надісланий запит. WebSocket знімає це обмеження — після встановлення зʼєднання сервер надсилає повідомлення першим, не чекаючи запиту. Важливо не називати його «HTTP, який тримається відкритим»: зʼєднання лише починається як HTTP-запит, а після рукостискання те саме TCP-зʼєднання переходить на власний бінарний формат кадрів. Тому зникає накладка HTTP — не треба щоразу слати заголовки й куки, заголовок кадру — лічені байти. Типові застосування: чати, нотифікації, котирування, спільне редагування, живі дашборди.
Як зробити реалтайм без WebSocket? Що таке short polling і long polling?
Це два історичні обхідні прийоми поверх звичайного HTTP. Short polling — клієнт раз у N секунд шле запит «є щось нове?», і сервер одразу відповідає, навіть якщо нового нема; середня затримка доставки дорівнює половині інтервалу, а частина запитів — порожні. Long polling — сервер не відповідає одразу, а «тримає» зʼєднання відкритим, доки не зʼявиться подія або не спрацює таймаут; отримавши відповідь, клієнт одразу шле наступний запит. Long polling дає майже реалтайм і працює скрізь, де є HTTP, але кожне повідомлення — окремий цикл «відповідь + новий запит» із повним набором заголовків, і канал односпрямований. WebSocket виграє в обох: одне зʼєднання на весь час, найнижча затримка, двобічний обмін. Для тестувальника корисно вміти відрізнити ці підходи в Network-вкладці: polling видно як серію повторюваних запитів, WebSocket — як один запит зі статусом 101.
Що означає full-duplex і чим він відрізняється від моделі «запит-відповідь»?
Full-duplex (повний дуплекс) — режим, коли обидва боки можуть передавати дані одночасно й будь-коли, як у телефонній розмові, а не як у рації. HTTP у межах одного обміну поводиться напівдуплексно: спершу до кінця говорить один бік (запит), потім інший (відповідь), і сервер мовчить, доки його не спитають. WebSocket дає повний дуплекс: сервер шле повідомлення без запиту, клієнт теж, в обидва боки одночасно. Практичний наслідок для автотестів: у WebSocket немає кореляції «запит-відповідь», тож тест не може «дочекатися відповіді на цей запит» — доводиться чекати на подію-наслідок, наприклад появу елемента в UI або конкретний кадр.
Як встановлюється WebSocket-зʼєднання? Розкажіть про handshake
Зʼєднання починається звичайним HTTP-запитом GET зі спеціальними заголовками: Upgrade: websocket просить змінити протокол, Connection: Upgrade підтверджує намір, Sec-WebSocket-Key несе випадковий ключ клієнта в base64, Sec-WebSocket-Version: 13 вказує актуальну версію за RFC 6455. Якщо сервер згоден, він відповідає статусом 101 Switching Protocols із заголовком Sec-WebSocket-Accept — це момент, коли зʼєднання перестає бути HTTP. Після 101 те саме TCP-зʼєднання вже несе WebSocket-кадри, а не HTTP-повідомлення. У рукостисканні можна також домовитися про підпротокол (Sec-WebSocket-Protocol) і розширення на кшталт стиснення (Sec-WebSocket-Extensions: permessage-deflate). На співбесіді часто просять назвати саме статус 101 — і саме за ним QA знаходить WebSocket-зʼєднання в DevTools.
Чим ws:// відрізняється від wss:// і чому в проді лише wss?
Співвідношення таке саме, як http до https: wss — той самий WebSocket, але всередині TLS-тунелю (порт 443 проти 80 у ws). Крім приватності є суто практична причина: проміжні проксі й корпоративні мережі часто ламають незашифрований ws, не розуміючи апгрейд, а зашифрований wss для них — непрозорий тунель, який вони пропускають як є. Ще одне правило браузерів: зі сторінки, відкритої по https, не можна відкрити ws:// — це змішаний контент (mixed content), і браузер блокує його мовчки, лише з помилкою в консолі; обійти це заголовком чи прапорцем не можна. Для QA це типовий кейс «на localhost працює, на стейджі ні»: сторінка поїхала на https, а сокет лишився на ws.
Які стани має обʼєкт WebSocket у браузері та які події життєвого циклу?
Стан читається з властивості readyState і має чотири значення: CONNECTING (0) — триває рукостискання, OPEN (1) — можна слати, CLOSING (2) — почалося закриття, CLOSED (3) — закрите або не вдалося відкрити. Подій теж чотири: open — рукостискання успішне (до цієї події send() кидатиме помилку), message — прийшли дані (event.data — текст або бінарні дані), close — зʼєднання закрилося (у CloseEvent є code, reason і wasClean), error — щось пішло не так, причому деталей навмисно мало з міркувань безпеки, і майже завжди слідом іде close. Порядок повідомлень збережений, але момент приходу непередбачуваний — саме тому асерти в тестах привʼязують до подій, а не до часу.
Які коди закриття WebSocket ви знаєте і чому 1006 — особливий?
Найпоширеніші: 1000 — нормальне закриття, 1001 — going away (сторінка закривається або сервер вимикається), 1002 — помилка протоколу, 1009 — повідомлення завелике, 1011 — внутрішня помилка сервера. Код 1006 — аварійний обрив: close-кадру не було, код ставить сама реалізація, і по мережі він не передається. Якщо бачите 1006, зʼєднання просто зникло — втрата мережі, вбитий процес, таймаут проксі — а не було охайно закрите. Діапазони теж значущі: 1000–2999 зарезервовані для самого протоколу, 3000–3999 — для бібліотек і фреймворків, 4000–4999 — для приватного вжитку за домовленістю сторін. У багах на реалтайм код закриття — перше, що варто зафіксувати: 1000 проти 1006 одразу розводить «нас закрили навмисно» і «звʼязок обірвався».
Навіщо потрібні ping/pong і heartbeat, якщо зʼєднання й так постійне?
Відкрите зʼєднання може «мовчати» годинами, а проміжні вузли — балансувальники, проксі, NAT — часто закривають ідле-зʼєднання за таймаутом, і жоден бік не дізнається про це одразу. На рівні протоколу є керівні кадри ping і pong: один бік шле ping, інший зобовʼязаний відповісти pong. Нюанс, який часто перевіряють на співбесіді: браузерний WebSocket API не дає керувати ping/pong із JavaScript — браузер сам відповідає pong на серверний ping, але події «прийшов ping» у JS немає, і надіслати ping вручну зі сторінки не можна. Тому застосунки будують власний heartbeat поверх звичайних повідомлень — шлють JSON на кшталт { "type": "ping" } і чекають { "type": "pong" }. Прикладний heartbeat вирішує ще одну задачу: якщо pong не прийшов за N секунд, клієнт вважає зʼєднання мертвим і перепідключається, не чекаючи, доки ОС помітить обрив TCP (а це буває дуже довго). У DevTools такі heartbeat-кадри видно в переліку повідомлень — корисно знати, що це не «сміття», а механізм живучості.
Чи переживає WebSocket перезавантаження сторінки?
Ні. WebSocket живе в контексті виконання JavaScript конкретної вкладки: жорстке перезавантаження або перехід за посиланням знищує цей контекст разом з обʼєктом WebSocket, і зʼєднання закривається — сервер побачить close, нерідко з кодом 1001 або аварійним 1006. Після завантаження нової сторінки все починається з нуля: новий handshake, повторна автентифікація й повторна підписка на події. Єдиний виняток не суперечить правилу: у SPA «мʼяка» навігація без перезавантаження може зберегти зʼєднання, але тільки якщо сокет живе в постійному модулі чи сторі застосунку, а не привʼязаний до компонента, який розмонтувався. Для AQA наслідок прямий: після кожного page.reload() стан реалтайму скидається, і не можна розраховувати, що підписка чи отримані раніше повідомлення «доживуть» до наступного кроку тесту.
Чим SSE відрізняється від WebSocket і коли що обирати?
Головна відмінність — напрямок: WebSocket двобічний, Server-Sent Events — лише сервер → клієнт. SSE — це, по суті, одна нескінченна HTTP-відповідь із потоком text/event-stream, яку сервер поступово «доливає»; у браузері з нею працює API EventSource. SSE передає лише текст (UTF-8), зате має вбудоване автоперепідключення з Last-Event-ID, щоб не втратити події, і легше дружить із проксі й балансувальниками, бо це звичайний HTTP. Слабина, крім однобічності, — ліміт браузера близько шести зʼєднань на домен по HTTP/1.1 (на HTTP/2 мультиплексування практично знімає це обмеження). Правило вибору: потрібен двобічний обмін (чат, гра, команди від клієнта) — WebSocket; потрібні лише оновлення від сервера (стрічка, котирування, прогрес) — SSE; оновлення рідкісні або допустима затримка в секунди — інколи чесніше лишити звичайний polling, бо менше рухомих частин і легше тестувати.
Як подивитися WebSocket-трафік у DevTools?
У вкладці Network WebSocket-зʼєднання відображається як запит зі статусом 101 Switching Protocols; список звужується фільтром WS. Клік по запиту відкриває перегляд повідомлень (у Chrome — вкладка Messages), де видно кожен кадр: напрямок (стрілка вгору — надіслано клієнтом, вниз — прийшло від сервера), вміст і час; вхідні й вихідні кадри розрізняються кольором тла. Під час дослідницького тестування варто дивитися на три речі: заголовки апгрейду в запиті (чи справді Upgrade: websocket, чи є Sec-WebSocket-*), реальний вміст кадрів (формат JSON, назви полів, наявність heartbeat) і момент та код закриття, якщо зʼєднання рветься. Назви вкладок різняться між Chrome, Firefox і версіями DevTools, але суть однакова — окремий перегляд кадрів обраного зʼєднання.
Як автоматизувати перевірки WebSocket у Playwright? А в Cypress?
Playwright спостерігає за WebSocket через подію page.on('websocket'): на обʼєкті сокета можна слухати framesent і framereceived і асертити реальні кадри — що клієнт справді підписався, що прийшло очікуване повідомлення, що після дії в UI пішов правильний кадр. Для повного контролю є page.routeWebSocket() — перехоплення й мокання: тест підміняє сервер і віддає наперед задані повідомлення, ізолюючись від реального бекенду. Ця можливість зʼявилася в Playwright 1.48, тож перед використанням варто перевірити версію в проєкті. Cypress історично WebSocket-трафік не перехоплює — cy.intercept працює з HTTP/XHR/fetch, але не з WS, — тому там мокають на рівні застосунку або клієнтської бібліотеки. Вибір між «слухати реальні кадри» та «мокати» залежить від мети: інтеграція з живим сервером чи детермінована перевірка поведінки фронтенду.
Чому тести на WebSocket-функціональність часто флакі та як зробити їх стабільними?
Причини лежать у природі протоколу. Немає кореляції «запит-відповідь»: повідомлення приходять коли завгодно й не привʼязані до конкретної дії, тож нема на що «просто почекати». Асинхронність: затримку доставки тест не контролює, і waitForTimeout(2000) — прямий шлях до нестабільності; правильно чекати на подію-наслідок — появу елемента в UI або конкретний кадр. Стан живе між діями: сокет тримає підписки й автентифікацію, тому зʼєднання з одного тесту або нотифікація, породжена сусіднім тестом на спільному середовищі, можуть протекти в інший прогін. Додаються гонки (кілька повідомлень приходять майже одночасно, UI рендерить їх у неочікуваному порядку — асерти на кінцевий стан надійніші за проміжні) і ненавмисні reconnect, після яких застосунок опиняється в іншому стані, ніж очікував сценарій. Практичний висновок: тестувати або через стійкі очікування UI-наслідків, або через мокання WebSocket; найкрихкіший варіант — асертити реальні повідомлення живого бекенду з таймінгом «на око», його лишають лише для наскрізних сценаріїв.
Як має поводитися клієнт при втраті мережі та що тут перевіряти тестами?
Спершу — виявлення: «тихий» обрив WebSocket може довго не помічати, доки не спрацює heartbeat або TCP-таймаут, тому прикладний ping/pong робить обрив видимим. Далі — перепідключення, яке для WebSocket пишуть вручну (вбудованого, як у SSE, немає): добре поводжена реалізація розрізняє «закрили навмисно» (код 1000 — не перепідключаємось) і обрив, а між спробами робить експоненційно зростаючу паузу (backoff) з випадковим розкидом (jitter), щоб клієнти не бомбардували сервер синхронно під час аварії. Після успішного reconnect застосунок мусить відновити стан: заново автентифікуватися, підписатися на канали й дозапросити пропущені за час обриву повідомлення, наприклад за останнім відомим id. Якщо відновлення нема — після короткого моргання мережі частина даних просто зникне; це класичний баг, який варто перевіряти окремим сценарієм: обірвати зʼєднання посеред потоку подій і переконатися, що після відновлення нічого не загубилося й не задублювалося.
Що таке bufferedAmount і до чого тут backpressure?
bufferedAmount — властивість браузерного WebSocket, яка показує кількість байтів, уже переданих у send(), але ще не відправлених у мережу. Якщо застосунок шле дані швидше, ніж канал встигає їх віддавати, цей буфер росте — і це сигнал застосовувати зворотний тиск (backpressure): пригальмувати відправлення, а не сліпо накопичувати дані в памʼяті. Сильний кандидат згадує це як приклад того, що «постійне зʼєднання» не означає «нескінченна пропускна здатність». Для тестувальника це підказка на нефункціональні сценарії: як поводиться клієнт під потоком повідомлень, який він не встигає обробляти.
Навіщо в handshake потрібні Sec-WebSocket-Key і Sec-WebSocket-Accept? Це автентифікація?
Ні, і це улюблена пастка співбесід. Sec-WebSocket-Key — випадковий ключ клієнта в base64; сервер дописує до нього фіксовану константу-GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, рахує SHA-1, кодує в base64 і повертає результат у Sec-WebSocket-Accept, а клієнт повторює обчислення й звіряє. Це не автентифікація й не шифрування — лише доказ, що на іншому боці справді WebSocket-сервер, який зрозумів запит, а не випадковий кеш чи проксі, що механічно повторив відповідь. Захист від випадкового апгрейду, не від зловмисника: автентифікацію користувача застосунок робить окремо (наприклад, першим повідомленням із токеном після події open), а шифрування дає TLS у wss. Розуміння цієї межі одразу показує, що кандидат читав протокол, а не лише туторіали.
Навіщо клієнтські кадри WebSocket маскуються, якщо це не шифрування?
Кожен кадр від клієнта до сервера маскується випадковим 32-бітним ключем; сервер свої кадри не маскує. Це не про приватність — маскування тривіально знімається, ключ їде в самому кадрі. За формулюванням RFC, воно потрібне, щоб не збивати з пантелику проміжні вузли, зокрема перехоплювальні проксі, і з міркувань безпеки: маскування закриває клас атак, коли зловмисник намагається протягнути крізь кеш проксі дані, схожі на легітимну HTTP-відповідь, — отруєння кешу. Тобто випадковий ключ гарантує, що байти в каналі ніколи не виглядатимуть як передбачуваний HTTP, який проксі міг би закешувати. Це питання «на глибину»: воно перевіряє, чи розрізняє кандидат маскування, шифрування (TLS у wss) і автентифікацію — три різні механізми з різними цілями.
Три кейси навколо WebSocket очима AQA: як прочитати трафік у DevTools, як перевірити реалтайм на Playwright і як зробити тест детермінованим через мокання сокета. Усе — з прицілом на те, що WebSocket є стабільним джерелом флаку.
Кейс 1. Читаємо канал у DevTools і ловимо handshake
Найшвидший спосіб зрозуміти, що реально ходить каналом, — не лізти в код, а відкрити Network → фільтр WS і клікнути по запиту зі статусом 101 Switching Protocols. Спершу дивимось саме на рукостискання — це запит, який починається як звичайний HTTP GET:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
Відповідь сервера, після якої зʼєднання перестає бути HTTP:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Що дивитися і чому:
- Статус 101, а не 200. Якщо замість
101 Switching Protocolsбачите 200/400/403 — апгрейд не відбувся: сервер не зрозумів запит, або його зʼїв проксі. Реалтайму не буде, хоч JS і не кинув очевидної помилки. - Схема
wss, неws. Зі сторінки поhttpsбраузер мовчки заблокуєws://як змішаний контент (mixed content) — у Network зʼєднання просто не зʼявиться, а в консолі буде помилка. Обійти прапорцем не можна. - Вкладка з кадрами (у Chrome — Messages). Стрілка вгору — надіслано клієнтом, вниз — прийшло від сервера; тло вхідних і вихідних кадрів відрізняється кольором. Тут видно реальний формат JSON, назви полів і чи є heartbeat-повідомлення на кшталт
{ "type": "ping" }. - Код закриття. Якщо канал рветься — дивимось код у
close.1000— нормальне закриття,1001— сторінка чи сервер ідуть на вихід, а1006— аварійний обрив без close-кадру (втрата мережі, вбитий процес, таймаут проксі). Код1006по мережі не передається, його ставить сама реалізація.
Точні назви вкладок і колонок різняться між Chrome і Firefox та між версіями DevTools — звіряйте на своєму браузері.
Кейс 2. Playwright: перевіряємо, що після дії в UI пішов правильний кадр
Playwright бачить WebSocket через подію page.on('websocket') і дає слухати кадри. Типова помилка новачка — після кліку поставити waitForTimeout(2000) і сподіватись, що повідомлення встигне долетіти. Це прямий шлях до флаку: момент приходу кадру тест не контролює. Правильно — чекати на подію-наслідок.
import { test, expect } from '@playwright/test';
test('після відправки повідомлення клієнт шле кадр і UI оновлюється', async ({ page }) => {
const wsPromise = page.waitForEvent('websocket');
await page.goto('https://example.com/chat');
const ws = await wsPromise;
const sentFrame = ws.waitForEvent('framesent'); // готуємось ЗАЗДАЛЕГІДЬ
await page.getByRole('textbox').fill('привіт');
await page.getByRole('button', { name: 'Надіслати' }).click();
const frame = await sentFrame;
expect(frame.payload).toContain('привіт');
// асерт на UI-наслідок, а не на фіксований час
await expect(page.getByText('привіт')).toBeVisible();
});
Що дивитися і чому:
waitForEvent('framesent')створюємо ДО дії, аawait-имо ПІСЛЯ. Якщо підписатися після кліку, кадр може вже пройти — і тест зависне на власному ж очікуванні. Це класична гонка (race), не баг застосунку.- Асертимо кінцевий стан UI, а не таймінг.
expect(...).toBeVisible()із вбудованим авточеканням надійніше за будь-якийwaitForTimeout. Повідомлення прилітають асинхронно й у непередбачуваний момент — фіксована пауза або впаде на повільному CI, або даремно сповільнить прогін. - Після
page.reload()стан реалтайму скидається. WebSocket живе в контексті вкладки; перезавантаження знищує обʼєктWebSocketразом із підпискою й автентифікацією. Не розраховуйте, що отримані до reload повідомлення «доживуть» до наступного кроку — після перезавантаження буде новий handshake з нуля.
Ще одна перевірка того самого рівня — переконатися, що клієнт справді підписався одразу після відкриття:
page.on('websocket', ws => {
ws.on('framesent', f => console.log('→', f.payload));
ws.on('framereceived', f => console.log('←', f.payload));
ws.on('close', () => console.log('WS закрито'));
});
Кейс 3. Мокаємо WebSocket через routeWebSocket — детермінований тест без бекенду
Найкрихкіший варіант тесту — асертити на реальні повідомлення від живого бекенду з таймінгом «на око». Коли перевіряємо саме поведінку фронтенду (а не інтеграцію з сервером), надійніше підмінити сервер і віддавати наперед задані кадри. У Playwright для цього є page.routeWebSocket() — зʼявився у версії 1.48, тож у старіших проєктах його не буде, перевірте версію перед використанням.
import { test, expect } from '@playwright/test';
test('нове повідомлення від сервера рендериться у списку', async ({ page }) => {
await page.routeWebSocket('wss://example.com/chat', ws => {
ws.onMessage(message => {
// клієнт автентифікується — підтверджуємо
if (String(message).includes('auth')) {
ws.send(JSON.stringify({ type: 'auth_ok' }));
}
});
// віддаємо детерміноване повідомлення від «сервера»
ws.send(JSON.stringify({ type: 'message', text: 'детермінований привіт' }));
});
await page.goto('https://example.com/chat');
await expect(page.getByText('детермінований привіт')).toBeVisible();
});
Що дивитися і чому:
- Ізоляція від реального бекенду. Мок вирізає з рівняння мережу, таймінг і чужі повідомлення. Це прямо бʼє по трьох джерелах флаку з глави: асинхронність, гонки й протікання стану між тестами (нотифікація, породжена сусіднім тестом на спільному середовищі, сюди вже не долетить).
- Немає кореляції «запит-відповідь» — тому мок сам вирішує, коли слати. У HTTP на відповідь легко почекати; у WebSocket кадр не привʼязаний до конкретної дії. Тут ми самі керуємо моментом
ws.send(), тож тест стає повторюваним. - Що обирати. Мок — коли перевіряємо поведінку фронтенду (швидко, детерміновано, ізольовано). Стійкі очікування UI-наслідків із Кейсу 2 — коли перевіряємо інтеграцію з реальним сервером. Асерти на живі кадри з паузами лишаємо лише для наскрізних сценаріїв і завжди підпираємо надійними очікуваннями замість
waitForTimeout.
Окремо варто мати тест на reconnect: обірвати зʼєднання й перевірити, що після перепідключення застосунок дозапитав пропущені за час обриву повідомлення (наприклад, за останнім відомим id). Якщо цього нема — після короткого моргання мережі частина даних зникне. Це класичний баг, який живий-бекендний тест із «на око»-паузами майже гарантовано пропустить.
Чому HTTP не вистачає і що таке WebSocket
- Розумію, чому в HTTP ініціатором завжди є клієнт, а сервер не може «постукати» в браузер сам — модель «запит-відповідь», напівдуплекс у межах одного обміну.
- Можу пояснити short polling vs long polling: перший шле запити раз у N секунд (маса порожніх, затримка до інтервалу), другий «тримає» зʼєднання відкритим до події.
- Знаю, що WebSocket — це окремий протокол (RFC 6455) поверх одного TCP-зʼєднання, повний дуплекс: обидва боки шлють дані будь-коли.
- Розумію, чому WebSocket дешевший за polling: після рукостискання йдуть бінарні кадри без повторення HTTP-заголовків і куків щоразу.
- Можу пояснити, що клієнтські кадри маскуються випадковим 32-бітним ключем (сервер свої — ні), і що це не приватність, а захист проксі від отруєння кешу.
Upgrade-handshake, ws vs wss
- Розумію, що зʼєднання починається звичайним GET із заголовками
Upgrade: websocketіConnection: Upgrade, а перемикання підтверджує статус101 Switching Protocols. - Знаю роль
Sec-WebSocket-KeyіSec-WebSocket-Accept: сервер бере ключ, дописує фіксований GUID, рахує SHA-1 і base64 — це доказ «на іншому боці справді WS», не автентифікація й не шифрування. - Можу пояснити різницю
ws://(порт 80, без шифрування) vswss://(443, поверх TLS), і чому в проді лишеwss— проксі часто ламають незашифрований апгрейд. - Розумію, чому зі сторінки по
httpsне можна відкритиws://— це mixed content, браузер блокує мовчки, обійти прапорцем не можна.
Життєвий цикл, події та коди закриття
- Знаю чотири стани
readyState: CONNECTING (0), OPEN (1), CLOSING (2), CLOSED (3), і що до подіїopenвикликsend()кидає помилку. - Можу пояснити чотири події (
open,message,error,close) і щоevent.data— рядок (типово JSON) або бінарні дані; кожне повідомлення — окрема подія, порядок збережений, момент приходу непередбачуваний. - Розумію, навіщо
bufferedAmountі backpressure: якщо слати швидше, ніж канал віддає, буфер росте — треба гальмувати, а не накопичувати наосліп. - Знаю ключові коди закриття: 1000 (норма), 1001 (going away), 1006 (аварійний обрив — по мережі не передається, ставить сама реалізація), і що 1006 = зʼєднання просто зникло.
Heartbeat, reconnect і живучість
- Розумію, чому потрібен heartbeat: проміжні вузли рвуть ідле-зʼєднання за таймаутом, і жоден бік не дізнається одразу.
- Знаю, що протокольні ping/pong браузерний WS API не дає контролювати з JS, тому застосунки будують власний heartbeat поверх звичайних повідомлень (
{ "type": "ping" }). - Можу пояснити добру логіку reconnect: експоненційний backoff із jitter, розрізнення «закрили навмисно» (код 1000) від обриву, і дозапит пропущених повідомлень за останнім id.
- Розумію, чому після reload чи переходу WebSocket не переживає — знищується JS-контекст вкладки; виняток лише мʼяка SPA-навігація, якщо сокет живе в постійному сторі, а не в компоненті.
SSE проти WebSocket
- Можу пояснити головну відмінність: WebSocket двобічний, SSE — лише сервер → клієнт, поверх звичайного HTTP (
text/event-stream, APIEventSource). - Знаю переваги SSE: вбудоване автоперепідключення з
Last-Event-ID, простіше з проксі; і слабини: однобічність, лише текст, ~6 зʼєднань на домен по HTTP/1.1. - Розумію критерій вибору: двобічний обмін → WebSocket; лише оновлення від сервера → SSE; рідкісні оновлення з допустимою затримкою → інколи чесніше лишити polling.
WebSocket очима AQA
- Знаю, як подивитися кадри в DevTools: фільтр WS, запит зі статусом 101, вкладка з повідомленнями, напрямок за стрілкою/кольором.
- Можу пояснити, чому WebSocket — джерело флаку: немає кореляції «запит-відповідь», асинхронність, стан живе між діями, гонки порядку, ненавмисний reconnect.
- Розумію головне правило тестування: чекати на подію-наслідок (елемент у UI, конкретний кадр), а не на фіксований
waitForTimeout; асертити на кінцевий стан, не на проміжні. - Знаю інструменти: Playwright слухає кадри через
page.on('websocket')і мокає черезpage.routeWebSocket()(з 1.48), а Cypress WS-трафік не перехоплює — мокають на рівні застосунку. - Можу пояснити три стратегії й їхній компроміс: стійкі очікування UI-наслідків, мокання WebSocket (детерміновано, ізольовано) і найкрихкіше — асерти на живий бекенд з таймінгом «на око», лише для e2e з надійними очікуваннями.
Чому в класичному HTTP сервер не може першим «постукати» в браузер?
Питання
Чому звичайного HTTP не вистачає для реалтайму — чату, котирувань, живих статусів?