Elküldtem 500 megabájtot egy WebSocketen úgy, hogy közben egyetlen bájt sem hagyta el a folyamatot. A send() mind az ötszázszor rendben visszatért, hibaesemény nem volt, a readyState végig OPEN, a kapcsolat az összes létező mérőszám szerint makkegészséges. Közben a processz memóriája 568 MiB-ra hízott, mert mind az 500 megabájt ott ült a heapen. És ez nem bug, hanem szabvány szerinti viselkedés.
A WebSocket send() ugyanis nem azt csinálja, amit a neve ígér. Nem küld, hanem sorba tesz: az üzenet bekerül egy user-space pufferbe, és a hívás azonnal visszatér. Hogy a sor valaha leürül-e a hálózat felé, az kizárólag a túloldalon múlik. Ha a kliens lassú mobilneten lóg, ha épp GC-szünetet tart, ha a felhasználó háttérbe tette a fület – az üzenetek a te memóriádban várakoznak. Korlát nélkül.

Egy szám van, esemény nincs
Az egyetlen visszajelzés, amit az API ad, a bufferedAmount property: azoknak a bájtoknak a száma, amelyeket send()-del sorba tettél, de még nem mentek ki a hálózatra. Esemény nem tartozik hozzá. A Node streamjeinél a write() legalább false-t ad vissza, ha tele a puffer, és 'drain' esemény szól, amikor mehet a következő adag – a WebSocket API-ban ennek semmilyen megfelelője nincs. Egy számláló van, amit akkor olvasol le, amikor eszedbe jut.
A mérési elrendezés egyszerű: egy ws szerver, amely kapcsolódás után pause()-olja a TCP socketet – ő játssza a lassú fogyasztót –, és egy Node 22-es natív WebSocket kliens, amely 1 MiB-os darabokat küld.
const ws = new WebSocket('ws://localhost:8081');
ws.onopen = () => {
const chunk = new Uint8Array(1024 * 1024); // 1 MiB
for (let i = 0; i < 500; i++) ws.send(chunk); // mind "sikeres"
console.log(ws.bufferedAmount); // 524 288 000 – mind az 500 MiB a memóriában
};
A szoros ciklus alatt az event loop sem pörög, úgyhogy a console.log pillanatában szó szerint nulla bájt hagyta el a folyamatot. Ha időzítve, adagokban küldjük ugyanezt, lassított felvételen nézheted végig, ahogy a processz elhízik:
sent: 260 MiB | bufferedAmount: 258 MiB | readyState: 1 | rss: 326 MiB
sent: 380 MiB | bufferedAmount: 378 MiB | readyState: 1 | rss: 446 MiB
sent: 500 MiB | bufferedAmount: 498 MiB | readyState: 1 | rss: 566 MiB
A kernel TCP-puffere pár megabájt után megtelt, onnantól minden a JavaScript oldalán áll sorban. Hiba nincs, esemény nincs, a readyState 1. A kapcsolat hivatalosan remekül van.
A spec szerint itt kapcsolatbontás jönne
A meglepő az, hogy a WHATWG WebSockets szabvány elvben gondolt erre az esetre. A send() leírásában ez áll:
„If the data cannot be sent, e.g. because it would need to be buffered but the buffer is full, the user agent must flag the WebSocket as full and then close the WebSocket connection.”
Must, nem may: teli puffernél a kapcsolatot le kellene zárni. Csakhogy azt, hogy mekkora a „teli”, senki nem definiálja – így a gyakorlatban egyik implementáció sem húz határt. A Node undici-alapú kliense 500 MiB-nál sem rezzent, és a böngészők sem dokumentálnak limitet; a Chrome fejlesztői anyaga is egyszerűen rád bízza a bufferedAmount figyelését. A puffer addig „nem teli”, amíg van szabad memóriád. Utána meg már úgyis mindegy.
A close() sem vészfék
Az első ötlet ilyenkor: ha elszállt a sor, zárjuk le a kapcsolatot, és kezdjünk tiszta lappal. Rossz hír, a spec erre is gondolt:
„The close() method does not discard previously sent messages before starting the WebSocket closing handshake — even if, in practice, the user agent is still busy sending those messages, the handshake will only start after the messages are sent.”
Le is mértem: 50 MiB-bal a sorban hívott close() után a readyState CLOSING-ra vált, a bufferedAmount viszont nem ürül, és a close esemény csak akkor futott le, amikor a szerver végre hajlandó volt kiolvasni a maradékot. Ha a túloldal soha nem olvas, a CLOSING állapotban ücsörögsz a teljes pufferrel együtt. Olyan metódus pedig, ami eldobná a sorba tett üzeneteket – egy abort() – a klasszikus API-ban egyszerűen nem létezik.
Hol harap ez élesben
- Böngészőben: canvas- vagy audiostream, kollaboratív szerkesztő, játékállapot-szinkron lassú hálózat felett. A fül memóriája nő, amíg a böngésző meg nem unja, és le nem lövi.
- Szerveren – és ez a súlyosabb: broadcast loop ezer kliensre, amiből egy nem olvas. A ws-ben a szerveroldali socketnek is van
bufferedAmount-ja, és asend()ott is azonnal visszatér: a lassú kliens üzenetei a te heapeden gyűlnek. Egyetlen rossz mobilkapcsolat OOM-ba viheti a processzt, és akkor a másik 999 kliensnek is annyi. - Proxy mögött: egy közbeiktatott nginx a saját puffereivel még késlelteti is, hogy a torlódás visszaérjen hozzád – mire nálad látszik, a lánc már mindenhol tele van.
Ami ma működik: küszöb és polling
const LIMIT = 4 * 1024 * 1024; // ennyit tűrünk meg a sorban
async function sendWithBackpressure(ws, chunks) {
for (const chunk of chunks) {
while (ws.bufferedAmount >= LIMIT) {
await new Promise((r) => setTimeout(r, 50)); // nincs drain esemény, polling van
}
ws.send(chunk);
}
}
Ugyanazzal a szándékosan lassú fogyasztóval 100 MiB ment át úgy, hogy a bufferedAmount csúcsa 4,0 MiB volt, az RSS pedig 109 MiB az 566 helyett. Az 50 ms-os polling nem szép, de nincs jobb: esemény hiányában valaminek időnként rá kell néznie a számlálóra. A ws csomagban szerveroldalon legalább van send(data, callback), ami a kiírás után hívódik meg – böngészőben ennyink sincs.
Élő állapotadatnál – kurzorpozíció, telemetria, árfolyam – nem várni érdemes, hanem dobni: ha a bufferedAmount nem nulla, a még sorban álló régi üzenet helyett menjen majd a legfrissebb állapot. A túloldal úgyis csak az utolsót akarja látni; a többit csak a saját memóriádban tárolnád neki.
WebSocketStream: a megoldás, ami két éve Chrome-only
A rendes megoldás létezik, WebSocketStream a neve: a WebSocketet a Streams API-ra ülteti, a writer.write() promise-t ad vissza, és csak akkor enged tovább, ha a küldés belefér – olvasói oldalon pedig addig nem jön újabb üzenet, amíg az előzőt fel nem dolgoztad. Backpressure mindkét irányban, ingyen.
const wss = new WebSocketStream('wss://example.com/feed');
const { writable } = await wss.opened;
const writer = writable.getWriter();
for (const chunk of chunks) {
await writer.write(chunk); // vár, ha a túloldal nem bírja
}
A bökkenő: Chrome és Edge 124 óta (2024 tavasza) tudja, Firefox és Safari egyáltalán nem, és az egész nem szabvány – a WHATWG-nél egy pull requestben él a szabványosítása. A probléma nagyjából egyidős a WebSockettel – az RFC 6455 2011 decemberi –, a megoldás meg tizenöt évvel később egy gyártónál parkol.
Használnám?
Kis JSON-üzenetes chatnél, notification-csatornánál felejtsd el az egészet: pár száz bájtos üzeneteknél a sor gyakorlatilag mindig üres, a bufferedAmount-ot az életben nem kell leolvasnod.
Nagy vagy sűrű üzeneteknél – bármi binárisnál, streamelésnél, broadcastnál – a küszöbös guard viszont nem extra, hanem kötelező kör, kliens- és szerveroldalon egyaránt. Szerveren szigorúbban venném: ott egy idegen ember hálózati kapcsolatának minősége dönti el, mennyi memóriát eszik a processzed, és ezt ő szabályozza, nem te.
A WebSocketStreamet productionben egyelőre nem használnám: feature detection és klasszikus fallback kellene mellé, vagyis ugyanazt kétszer írnám meg. Amint a Firefox és a Safari lép, ez lesz az alap – addig marad a polling, meg a tudat, hogy a send()-nek visszatérési értéke sincs. Nem szerénységből hallgat: tényleg nem tud semmit.