Skip to main content

Що таке WebSocket і як він працює?

WebSocket - це протокол, який переводить HTTP-з'єднання в постійний, повнодуплексний TCP-канал, де клієнт і сервер надсилають повідомлення будь-коли без повторних запитів.

Теорія

TL;DR

  • HTTP - це як листування: відправив, отримав відповідь, з'єднання закрилося. WebSocket - це як телефонний дзвінок: лінія відкрита, обидві сторони говорять вільно.
  • Головна різниця: одне постійне TCP-з'єднання проти повторних циклів запит-відповідь з накладними витратами TCP-handshake щоразу.
  • Handshake починається як HTTP, переходить через відповідь 101 Switching Protocols, після чого з'єднання залишається відкритим.
  • Використовуй WebSocket коли потрібна затримка до 100ms або сервер має надсилати дані без запиту клієнта. Інакше SSE або HTTP/2 достатньо.
  • ws:// для локальної розробки, завжди wss:// у продакшені. Браузери блокують ws:// на HTTPS-сторінках.

Швидкий приклад

javascript
const ws = new WebSocket('wss://echo.websocket.org'); ws.onopen = () => { console.log('З\'єднано'); // спрацьовує один раз ws.send('Ping'); // надсилай будь-коли - цикл запит-відповідь не потрібен }; ws.onmessage = (e) => console.log('Відповідь:', e.data); // Відповідь: Ping ws.onerror = (e) => console.error('Помилка:', e); ws.onclose = () => console.log('З\'єднання закрито');

Чотири обробники покривають повний lifecycle сокета. З'єднання залишається відкритим після onopen - без поллінгу, без повторних запитів.

Як працює handshake

Клієнт надсилає звичайний HTTP/1.1 GET-запит з двома додатковими заголовками:

GET /chat HTTP/1.1 Upgrade: websocket Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

Сервер відповідає 101 Switching Protocols і заголовком Sec-WebSocket-Accept. Це SHA1-хеш ключа клієнта з фіксованим GUID (258EAFA5-E914-47DA-95CA-C5AB0DC85B11), закодований у base64. Клієнт перевіряє хеш, і з цього моменту TCP-з'єднання переходить на WebSocket-фреймінг. HTTP більше не використовується.

Дані передаються у фреймах. Кожен фрейм містить opcode (текст, бінарні дані, ping, pong, close), маску і payload. Браузерні клієнти завжди маскують payload захисним 4-байтним ключем. Сервер повинен демаскувати перед читанням. Біт FIN позначає останній фрейм повідомлення.

Ключова різниця від HTTP

HTTP є stateless за задумом. Кожен запит відкриває з'єднання, надсилає запит, отримує відповідь - і закривається. Для real-time додатків це означає поллінг: клієнт питає щосекунди «є новини?» - більшість відповідей порожні, кожен round trip займає 150-200ms. Long polling тримає з'єднання до появи даних, але сервер все одно не може надсилати дані самостійно.

WebSocket знімає це обмеження. Після переходу сервер надсилає дані одразу, як щось відбулося. Затримка падає з 200ms+ до менше 10ms. Обидві сторони надсилають фрейми незалежно одна від одної.

Коли використовувати

  • Живий чат (Slack, Discord): індикатори набору, миттєва доставка - WebSocket.
  • Мультиплеєрні ігри: синхронізація позицій на 60fps потребує WebSocket. Нічого іншого не достатньо швидко.
  • Торгові дашборди: оновлення цін кожні 100ms, клієнти також надсилають ордери - WebSocket, а не SSE.
  • Collaborative-редактори (Figma, Google Docs): позиції курсорів, concurrent-операції - WebSocket.
  • Односторонні сповіщення: SSE простіше (тільки сервер до клієнта, вбудований auto-reconnect).
  • Рідкі оновлення (раз на хвилину): звичайний HTTP-поллінг достатній, постійне з'єднання не потрібне.

Порівняння: WebSocket та альтернативи

ПараметрHTTP PollingLong PollingSSEWebSocket
НапрямокТільки за запитомТільки за запитомТільки сервер до клієнтаПовний дуплекс
З'єднанняНове для кожного запитуТримається до появи данихПостійне, auto-reconnectПостійне, закривається вручну
Накладні витратиВисокіСередніНизькіНайнижчі
Підтримка браузерівПовнаПовнаВсі сучасні браузери95%+ глобально
Найкраще дляСтатичні сторінкиПростий real-timeСтрічки, сповіщенняЧат, ігри, collaboration
Коли обиратиРідкі оновленняНемає WebSocketТільки push від сервераДвосторонній, низька затримка

SSE добре підходить для потоку даних від сервера і автоматично відновлює з'єднання. WebSocket - правильний вибір коли клієнт теж часто надсилає дані.

Як фрейми працюють всередині

Пакет ws для Node.js і нативний WebSocket API браузера працюють поверх TCP-стека ОС. Фрейм починається з 2-байтного заголовка: 1 біт FIN, 3 зарезервовані біти, 4 біти opcode, 1 біт MASK, 7 біт довжини payload (розширені для великих повідомлень). Браузери маскують payload 4-байтним ключем - захист від cache poisoning через проксі. Сервер демаскує перед читанням.

Фрейми ping і pong (opcodes 0x9 і 0xA) - це механізм heartbeat. Сервер надсилає ping, браузер автоматично відповідає pong. Якщо pong не прийшов у межах таймауту - з'єднання вважається мертвим і закривається.

Типові помилки

Немає логіки перепідключення

javascript
const ws = new WebSocket(url); // без обробника onclose

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

javascript
ws.onclose = () => setTimeout(() => connect(url), 1000);

Надсилання об'єктів без JSON.stringify

javascript
ws.send({ msg: 'hi' }); // відправить "[object Object]" - сервер впаде на парсингу

WebSocket передає рядки або бінарні дані. Об'єкти потрібно серіалізувати: ws.send(JSON.stringify({ msg: 'hi' })).

Без heartbeat у продакшені

Це вбиває більше деплойментів, ніж будь-яка інша WebSocket-проблема. Локально все працює, у staging Nginx закриває з'єднання. Корпоративні проксі та Nginx з дефолтним конфігом закривають idle-з'єднання після 60 секунд. Надсилай ping кожні 30 секунд:

javascript
setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.send('ping'); }, 30000);

Без обмеження розміру повідомлень на сервері

javascript
ws.on('message', (msg) => { if (msg.length > 1_000_000) return ws.close(1009, 'Too large'); // обробляй msg });

Без цієї перевірки клієнт може надіслати фрейм на 1GB і обвалити процес.

ws:// на HTTPS-сторінці Браузери блокують mixed content. На HTTPS завжди використовуй wss://.

Де використовується

  • Socket.io (React/Node): WebSocket із fallback на поллінг і абстракцією кімнат. Використовується в live-курсорах Trello.
  • Phoenix Channels (Elixir): чат в стилі Discord, тримає 10k+ користувачів на кімнату з низьким споживанням пам'яті.
  • ActionCable (Rails): система сповіщень GitHub.
  • uWebSockets.js: бекенд WhatsApp Web для мільйонів паралельних з'єднань.
  • Нативний ws (Node): найпоширеніший вибір для Express-based API та внутрішніх дашбордів.

Follow-up питання

Q: Що містить відповідь 101 Switching Protocols?
A: Заголовок Sec-WebSocket-Accept: SHA1-хеш Sec-WebSocket-Key клієнта з фіксованим GUID, закодований у base64. Клієнт перевіряє це значення перед виходом з HTTP-режиму.

Q: Що відбувається коли мережа обривається посередині сесії?
A: Спрацьовує onclose з кодом 1006 (abnormal closure, без close-фрейму). Автоматичного перепідключення у WebSocket API немає. Exponential backoff реалізуєш сам.

Q: WebSocket чи HTTP/2 multiplexing - що обрати?
A: HTTP/2 мультиплексує кілька потоків запит-відповідь через одне TCP-з'єднання, але кожен потік все одно залишається запит-відповідь. У WebSocket справжній двосторонній push. Для повідомлень від сервера - WebSocket. Для паралельних API-запитів - HTTP/2.

Q: Як налаштувати проксі WebSocket через Nginx?
A: Додай у конфіг: proxy_http_version 1.1; та proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";. Без цього Nginx видаляє заголовок Upgrade і handshake падає з 400.

Q: 1 мільйон паралельних WebSocket-з'єднань - як масштабуватись без порушення stickiness?
A: Consistent hashing за ID клієнта направляє з'єднання на конкретні ноди (sticky sessions). Повідомлення між нодами маршрутизуються через pub/sub, наприклад Redis. Heartbeat-и підтримують з'єднання через балансувальник навантаження. ОС теж потребує налаштування: net.core.somaxconn, ліміти файлових дескрипторів. «Просто PM2» тут не допоможе - PM2 не вирішує маршрутизацію між процесами.

Приклади

Базовий браузерний клієнт із перепідключенням

javascript
function connect(url) { const ws = new WebSocket(url); ws.onopen = () => { console.log('З\'єднано'); ws.send(JSON.stringify({ type: 'join', room: 'general' })); }; ws.onmessage = (e) => { const data = JSON.parse(e.data); console.log(`${data.user}: ${data.text}`); // Вивід: Alice: hello }; ws.onclose = () => { console.log('Перепідключення через 2с...'); setTimeout(() => connect(url), 2000); }; return ws; } const ws = connect('wss://yourapi.com/chat');

Цикл перепідключення в onclose - мінімальний робочий патерн. У продакшені додавай exponential backoff: починай з 1s, подвоюй кожну спробу, обмеж 30s.

React-компонент чату з WebSocket

javascript
import { useState, useEffect, useRef } from 'react'; function ChatRoom({ roomId }) { const [messages, setMessages] = useState([]); const ws = useRef(null); useEffect(() => { ws.current = new WebSocket(`wss://yourapi.com/chat/${roomId}`); ws.current.onmessage = (e) => { const msg = JSON.parse(e.data); setMessages((prev) => [...prev, msg]); // Вивід: додає { user: 'Bob', text: 'Hi' } }; return () => ws.current?.close(); // закрити при unmount - важливо }, [roomId]); const sendMessage = (text) => { if (ws.current?.readyState === WebSocket.OPEN) { ws.current.send(JSON.stringify({ text })); } }; return ( <div> {messages.map((m, i) => <p key={i}>{m.user}: {m.text}</p>)} <input onKeyDown={(e) => e.key === 'Enter' && sendMessage(e.target.value)} /> </div> ); }

useRef зберігає екземпляр сокета між рендерами без зайвих ре-рендерів. Перевірка readyState === WebSocket.OPEN в sendMessage запобігає помилкам під час перепідключення.

Node.js сервер з heartbeat і broadcast

javascript
const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', (ws) => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); // скидаємо прапор при отриманні pong ws.on('message', (raw) => { if (raw.length > 1_000_000) return ws.close(1009, 'Message too large'); let msg; try { msg = JSON.parse(raw); } catch { return ws.close(1007, 'Invalid JSON'); } wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify(msg)); // відправляємо всім підключеним клієнтам } }); }); }); const interval = setInterval(() => { wss.clients.forEach((ws) => { if (!ws.isAlive) return ws.terminate(); // немає pong = з'єднання мертве ws.isAlive = false; ws.ping(); }); }, 30000); wss.on('close', () => clearInterval(interval)); // зупиняємо інтервал при зупинці сервера

Прапор isAlive скидається в false на кожному тіку і повертається в true тільки коли приходить pong. Немає pong протягом 30 секунд - клієнт пішов, з'єднання видаляємо. clearInterval в wss.on('close') зупиняє інтервал після зупинки сервера.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?