Події, надіслані з сервера, пулінг та довгий пулінг: що це таке та коли їх використовувати
Polling, long polling та Server-Sent Events (SSE) - це три техніки на основі HTTP для доставки серверних оновлень у браузер. Кожна по-своєму балансує між кількістю запитів, навантаженням на з'єднання і затримкою.
Теорія
TL;DR
- Polling питає сервер "є щось нове?" за фіксованим таймером, навіть якщо нічого не змінилось
- Long polling тримає запит відкритим, поки сервер не матиме даних, потім одразу перезапускає його
- SSE тримає одне з'єднання відкритим постійно - сервер сам надсилає події коли хоче
- Аналогія: polling - дитина, яка перевіряє поштову скриньку кожні 5 хвилин; long polling стоїть біля скриньки і чекає; SSE - листоноша, який телефонує тобі напряму по одній відкритій лінії
- Правило вибору: рідкісні перевірки → polling; мала затримка без WebSocket → long polling; часті односторонні події → SSE
Швидкий приклад
// Polling: запит кожні 3с, навіть якщо даних немає
setInterval(() => fetch('/data').then(r => r.json()).then(console.log), 3000);
// Long polling: один запит чекає на дані або таймаут
async function longPoll() {
const res = await fetch('/long-data'); // сервер тримає ~30с
console.log(await res.json()); // виводить раз на цикл
longPoll(); // одразу перезапускаємо
}
longPoll();
// SSE: одне з'єднання, браузер перепідключається автоматично
const es = new EventSource('/events');
es.onmessage = e => console.log(e.data); // виводить кожну подіюPolling виводить результат незалежно від наявності нових даних. Long polling - раз за цикл. SSE - одразу, як тільки сервер щось надіслав.
Ключова різниця
Polling відкриває новий HTTP-запит на кожному інтервалі, незалежно від того чи змінились дані. Long polling відкриває один запит, який сервер тримає відкритим до появи даних або таймауту, і клієнт одразу надсилає наступний. SSE відкриває одне HTTP-з'єднання з Content-Type: text/event-stream і тримає його постійно. Сервер записує рядки вигляду data: ...\n\n у сокет коли хоче, без повторних хендшейків.
Коли що використовувати
- Оновлення дашборду кожні 30+ секунд: polling підходить, реалізація проста
- Чат без підтримки WebSocket або SSE: long polling забезпечує малу затримку
- Live-сповіщення, котирування акцій, стрімінг логів: SSE
- Двостороння комунікація (ігри, спільні редактори): жоден з трьох, потрібен WebSocket
- Мобільні клієнти: long polling і SSE тримають з'єднання відкритим, це дренує батарею
Таблиця порівняння
| Характеристика | Polling | Long Polling | SSE |
|---|---|---|---|
| Патерн запитів | Багато коротких запитів | Декілька довгих запитів | Одне постійне з'єднання |
| Затримка | Висока (чекає наступного інтервалу) | Низька (спрацьовує на даних) | Найнижча (миттєвий push) |
| Навантаження на сервер | Високе (порожні відповіді) | Середнє (утримувані з'єднання) | Низьке (одне з'єднання на клієнта) |
| Масштабованість | Погана (обсяг запитів) | Погана (ліміти з'єднань) | Добра (до ~10к з'єднань на сервер) |
| Підтримка браузерів | Всі | Всі | 90%+ (IE потребує полізаповнення) |
| Складність | Низька | Середня (логіка перепідключення) | Низька (нативний API EventSource) |
| Коли використовувати | Рідкісні перевірки (cron-дашборди) | Застарілі браузери без SSE | Live-стрічки, сповіщення, логи |
Як це працює всередині
Для polling і long polling браузер використовує fetch або XMLHttpRequest. У Node.js/Express long polling реалізується так: об'єкт res зберігається в пам'яті, а res.end(data) викликається лише коли з'явились дані або спрацював setTimeout.
SSE використовує chunked transfer encoding з HTTP/1.1. API EventSource у браузері читає рядки зі стріму і шукає префікс data: з двома переносами рядка (\n\n). Якщо з'єднання обривається, EventSource автоматично перепідключається через 3 секунди. У Node потрібно викликати res.flushHeaders() (Node 18+), інакше відповідь буферизується і клієнт нічого не отримує до закриття з'єднання.
З HTTP/2 ліміт у 6 з'єднань на домен фактично знімається: потоки мультиплексуються через одне TCP-з'єднання.
Типові помилки
1. Немає обробника помилок у EventSource
// Неправильно: з'єднання падає без сигналу
const es = new EventSource('/events');
es.onmessage = e => console.log(e.data);
// Правильно: явно обробляємо розрив
es.onerror = () => console.log('З\'єднання SSE втрачено, браузер повторить спробу...');Браузер повторює підключення 3 рази, потім зупиняється. Без onerror UI просто зависає без жодного сигналу.
2. Немає таймауту в long poll запиті
// Неправильно: зависає назавжди при краші сервера
const res = await fetch('/long-data');
// Правильно: AbortController з 35-секундним таймаутом
const controller = new AbortController();
setTimeout(() => controller.abort(), 35000);
const res = await fetch('/long-data', { signal: controller.signal });3. Занадто агресивний інтервал polling
// Неправильно: 1с = 86 400 запитів на день з одного клієнта
setInterval(() => fetch('/stocks'), 1000);
// Правильно: починати від 5с, додати exponential backoff4. Забули викликати flushHeaders у Node 18+
// Без цього Node буферизує відповідь і клієнт нічого не бачить
res.flushHeaders();5. SSE для двосторонньої передачі даних
Надсилати дані від клієнта через query params SSE - це не те, для чого протокол призначений, це руйнує потік. При потребі в обох напрямках використовуй WebSocket.
Де зустрічається в реальних проектах
- GitHub: SSE для live-сповіщень у веб-інтерфейсі
- Twitter/X: SSE для стрімінгу стрічки твітів у веб-застосунку
- Socket.io v4: автоматично переходить на long polling, якщо WebSocket недоступний
- React Query та SWR: polling для фонової інвалідації кешу (за замовчуванням 5 хвилин)
- Firebase Realtime Database: long polling як запасний варіант для старих браузерів
Питання на співбесіді
Q: Як SSE відрізняється від WebSocket на рівні протоколу?
A: SSE працює через звичайний HTTP/1.1 з chunked transfer encoding і є одностороннім (сервер до клієнта). WebSocket виконує HTTP upgrade handshake, потім переключається на сире TCP-з'єднання, яке є двостороннім без HTTP-накладних витрат на кожне повідомлення.
Q: Який ліміт з'єднань SSE у HTTP/1.1?
A: Приблизно 6 на домен, як і для будь-яких HTTP/1.1 з'єднань. З HTTP/2 потоки мультиплексуються через одне TCP-з'єднання, тому це обмеження практично зникає.
Q: Чому б не замінити polling на SSE скрізь?
A: SSE є одностороннім. Деякі файрволи та проксі закривають довготривалі з'єднання. SSE не підтримує бінарні дані нативно. Для простих рідкісних перевірок на кшталт оновлення дашборду кожні 30 секунд polling простіший і працює всюди.
Q: Яких затримок очікувати від кожного підходу?
A: Затримка polling дорівнює приблизно половині інтервалу (в середньому чекаєш пів циклу). Long polling додає ~100 мс накладних витрат. SSE доставляє події менш ніж за 50 мс після встановлення з'єднання.
Q: Продакшн-сценарій: 10 000 користувачів на long polling піднімають CPU Node.js до 100%. Як виправити без переходу на WebSocket?
A: Long polling з 10 000 користувачів - це 10 000 активних таймерів в event loop Node, він просто захлинається. Рішення: Redis pub/sub. Замість того щоб кожен запит тримав свій таймер, він підписується на Redis-канал. Один publish повідомляє всі очікуючі відповіді одночасно. Перехід на SSE також допомагає - одне постійне з'єднання на клієнта масштабується краще ніж 10 000 повторних циклів запит-відповідь.
Приклади
Polling: базовий таймерний запит
// Перевіряє оновлення кожні 5 секунд
// _t запобігає кешуванню відповіді
function startPolling(url, interval = 5000) {
setInterval(async () => {
const res = await fetch(`${url}?_t=${Date.now()}`);
const data = await res.json();
console.log('Свіжі дані:', data);
}, interval);
}
startPolling('/api/dashboard');Просто написати, але кожен запит іде на сервер незалежно від змін. При 1000 клієнтах і інтервалі 5 секунд - це 12 000 запитів на хвилину, більшість з яких повертають порожній результат.
SSE: Express сервер і React компонент
Подібно до того, як GitHub доставляє live-сповіщення.
Сервер (Express.js):
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.flushHeaders(); // обов'язково для Node 18+
const interval = setInterval(() => {
res.write(`data: ${JSON.stringify({ msg: 'Нове сповіщення', ts: Date.now() })}\n\n`);
}, 5000);
// Очищаємо при відключенні клієнта
req.on('close', () => clearInterval(interval));
});React клієнт:
function Notifications() {
const [msgs, setMsgs] = useState([]);
useEffect(() => {
const es = new EventSource('/events');
es.onmessage = e => setMsgs(m => [...m, JSON.parse(e.data)]);
es.onerror = () => console.log('З\'єднання втрачено, перепідключення...');
return () => es.close(); // очищення при розмонтуванні
}, []);
return <ul>{msgs.map((m, i) => <li key={i}>{m.msg}</li>)}</ul>;
}Список оновлюється в реальному часі без перезавантаження сторінки. req.on('close') легко забути - без нього при відключенні клієнта інтервал продовжує працювати і з'їдає пам'ять.
Long polling з таймаутом і повторними спробами
Продакшн-код має витримувати мережеві збої та перезапуск сервера.
async function robustLongPoll(url, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 35000);
try {
const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
clearTimeout(timeout);
console.log('Отримано:', data);
return data;
} catch (err) {
clearTimeout(timeout);
console.log(`Спроба ${attempt + 1} невдала: ${err.message}`);
// Чекаємо довше між кожною повторною спробою
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}
console.log('Перехід на polling');
}
// Перезапускаємо після кожної успішної відповіді
async function keepPolling(url) {
while (true) {
await robustLongPoll(url);
}
}35-секундний таймаут навмисно довший за 30-секундне утримання на сервері. Нормальний таймаут сервера завершується коректно, а реальний збій перериває AbortController і вмикає exponential backoff.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.