Reducing ws dependency in inter-server event communication with Node.js 24 native WebSocket
Every time I open package.json, the same thought crosses my mind: "Do I really need all these dependencies?" ws, node-fetch, axios... things I installed without a second thought early in the project eventually become the source of CVE alerts and version conflicts. Starting with Node.js 24, one of those concerns disappears — the WebSocket client is now globally built into the runtime.
At first, I thought, "Why bother when ws already works fine?" But the moment I install ws again in a new project, I'm back to dealing with a different event model than the browser, separate type definitions, and maintenance overhead. This post looks at how far you can take the native WebSocket — focusing on real-time event delivery between microservices — from the perspective of reducing that friction.
The Journey of Node.js's WebSocket Client
A Brief Timeline
Browsers have long had WebSocket as a global object, but Node.js didn't ship a WebSocket client out of the box for a long time. The ws package filled that gap — a de facto standard library with tens of millions of npm downloads (exact figures vary widely by time period; check npm trends for current numbers).
- v21: Experimental WebSocket client introduced (
--experimental-websocket) - v22.4.0: Promoted to stable, available globally without a flag
- v24.0.0: Released April 24, 2025 (official release notes), bundling Undici 7
One commonly confused point worth clarifying: the global WebSocket in Node.js follows the WHATWG WebSocket Living Standard, a browser JS API spec, while the underlying wire protocol uses RFC 6455. These two documents operate at different layers and are separate specs — one is not an alias for the other. WebSocket over HTTP/3 is defined separately in RFC 9220; as of August 2026, no official documentation confirms that Node.js/Undici serves WebSocket via that path.
The Same API Surface as the Browser
The most intuitive change is that code written for the browser can now be used as-is on the server. Use it globally — no import needed.
const ws = new WebSocket('ws://other-service:8080/events');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'subscribe', topic: 'orders' }));
});
ws.addEventListener('message', ({ data }) => {
console.log('Received:', JSON.parse(data));
});
ws.addEventListener('close', ({ code, reason }) => {
console.log(`Connection closed: ${code} - ${reason}`);
});
ws.addEventListener('error', (err) => {
console.error('WebSocket error:', err);
});The decisive difference from ws is the event model. ws uses the Node.js EventEmitter style (ws.on('message', ...)), while the native API uses the same EventTarget style as browsers (addEventListener). If you want to unify code patterns with your frontend team, this difference turns out to matter more than you'd expect.
Service-to-Service Event Delivery Scenarios
Scenario 1: Order Service → Notification Service
When an order status changes, the notification service needs to know immediately — but polling via HTTP every second drives up both latency and cost. A persistent WebSocket connection lets you push events between services.
The reality of passing auth headers: The WHATWG standard WebSocket constructor only defines the new WebSocket(url[, protocols]) signature. There is no way to pass custom headers like Authorization: Bearer ... through the standard API. To handle service-to-service authentication, your options are:
- Use the
WebSocketclass from theundicipackage directly (supports headers as a constructor option) - Pass the auth token via the subprotocol (
Sec-WebSocket-Protocol) - Pass a signed short-lived token in the query string (note that this is exposed in paths and logs)
Below is an approach that passes authentication using only the standard signature, via subprotocols.
// notification-service/src/order-events.js
const TOKEN = process.env.ORDER_SERVICE_TOKEN;
const ws = new WebSocket(
'ws://order-service:3000/events',
['v1.orders', `bearer.${TOKEN}`]
);
ws.addEventListener('message', ({ data }) => {
const event = JSON.parse(data);
if (event.type === 'ORDER_PLACED') {
sendPushNotification(event.userId, `Order ${event.orderId} received`);
} else if (event.type === 'ORDER_SHIPPED') {
sendPushNotification(event.userId, `Order ${event.orderId} shipped`);
}
});If you absolutely need headers, explicitly import the bundled undici. No separate installation required.
import { WebSocket as UndiciWebSocket } from 'undici';
const ws = new UndiciWebSocket('ws://order-service:3000/events', {
headers: { Authorization: `Bearer ${TOKEN}` }
});Scenario 2: Auto-Reconnect (Roll Your Own)
Honestly, this is the biggest challenge when taking the native WebSocket to production. The native WebSocket has no built-in auto-reconnect. That said, this is less a "drawback" of native and more "the same level of application responsibility as before" — reconnection was always something you wired up in application code with ws too. If auto-reconnect and polling fallbacks are genuinely critical, Socket.IO is still the closer-to-right answer.
Three things you must get right when implementing it yourself:
- Exponential backoff
- Jitter to prevent thundering herd — if multiple instances disconnect simultaneously and all attempt reconnection at exactly the same time, the server gets a sudden spike
- Clear distinction between clean closure (code 1000) and unclean closure
// utils/resilient-ws.js
function createResilientWebSocket(url, options = {}) {
const {
protocols,
maxRetries = 10,
baseDelay = 500,
maxDelay = 30_000,
onMessage,
} = options;
let retries = 0;
let ws = null;
let stopped = false;
function nextDelay() {
const exp = Math.min(maxDelay, baseDelay * 2 ** retries);
return Math.random() * exp;
}
function connect() {
ws = new WebSocket(url, protocols);
ws.addEventListener('open', () => {
retries = 0;
console.log(`[WS] Connected: ${url}`);
});
ws.addEventListener('message', ({ data }) => onMessage?.(data));
ws.addEventListener('close', ({ code }) => {
if (stopped || code === 1000) return;
if (retries >= maxRetries) {
console.error('[WS] Max reconnect attempts exceeded');
return;
}
const delay = nextDelay();
retries++;
console.log(`[WS] Reconnecting in ${Math.round(delay)}ms (${retries}/${maxRetries})`);
setTimeout(connect, delay);
});
ws.addEventListener('error', (err) => {
console.error('[WS] Error:', err.message ?? err);
});
}
connect();
return {
close: () => { stopped = true; ws?.close(1000); },
};
}Scenario 3: Good to Know — WebSocketStream (Experimental)
For streams where backpressure matters — large logs or telemetry — the WebSocketStream API is a natural fit. However, this API is a draft at the WICG proposal stage, and as of August 2026, implementation status varies across browsers and runtimes. In Node.js, it exists experimentally on the undici side, but the export name and signature may change between releases, so it's safer to limit its use to experimental spikes rather than production code.
Conceptual example (check the docs for the actual version of undici you're using before relying on this).
// Conceptual example — actual export/signature may differ by version
import { WebSocketStream } from 'undici';
const wss = new WebSocketStream('ws://build-agent:9000/logs');
const { readable } = await wss.opened;
const decoder = new TextDecoder();
for await (const chunk of readable) {
// Text frames arrive as string, binary as Uint8Array
const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk);
process.stdout.write(text);
}The reason to keep an eye on this API isn't raw performance — it's that it composes naturally with the Streams API, making pipelines much cleaner. If you need backpressure in production right now, ws with manual flow control is still the safe bet.
Why It's Not a Silver Bullet — A Fair Comparison with Redis Pub/Sub
It's easy to say "Redis Pub/Sub requires additional infrastructure," but in most microservice environments Redis is already deployed for caching, sessions, rate limiting, and more. Here are the trade-offs to keep in mind when using persistent WebSocket connections as a service-to-service message bus.
| Aspect | Persistent WebSocket | Redis Pub/Sub |
|---|---|---|
| Latency | Very low (push as long as connection is alive) | Low (routed through broker) |
| Messages during disconnection | Lost (requires separate offset/buffer) | Lost (same by nature of Pub/Sub; Redis Streams is a different story) |
| Horizontal scaling | Routing needed depending on which instance the client connected to | Broker handles fan-out |
| Message ordering | Guaranteed only within a single connection | Guaranteed per channel |
| Additional infrastructure | None (direct communication) | Requires Redis |
If event loss is acceptable and the target services form a small, fixed topology, direct WebSocket connections are simple and fast. For wide fan-out or when you can't afford to lose events, Kafka/NATS/Redis Streams is the right call.
What You Gain and What You Accept
Benefits of Going Native
| Item | Detail |
|---|---|
| Dependency removal | Dropping ws reduces attack surface and node_modules size |
| Unified API | Same EventTarget-based model as browsers — share code patterns across frontend and backend |
| Standard compliance | Follows the WHATWG WebSocket JS API directly (wire protocol is RFC 6455) |
| Bundled Undici | HTTP client and WebSocket on the same networking stack |
| Built-in TLS | No separate package needed for wss:// |
This post does not cite specific performance improvement numbers. Benchmark ws against the native WebSocket in your own workload.
Current Limitations
| Item | Description |
|---|---|
| No auto-reconnect | Must implement backoff and jitter yourself |
| Custom header restriction | No header option in the standard constructor; use undici.WebSocket or work around with subprotocol/query string |
| No rooms or namespaces | No high-level abstractions like Socket.IO's rooms and namespaces |
| No STOMP/MQTT support | Application protocols on top of the subprotocol must be handled manually |
| WebSocket server is a separate concern | This post focuses on the client. Going dependency-free on the server side means handling the handshake and framing yourself in the upgrade event of node:http — keeping ws for the server is the realistic choice |
| Node.js version requirement | Stable API requires v22.4.0 or higher; keep ws if supporting older runtimes |
When to Use What
| Situation | Choice |
|---|---|
| Node.js 24, small number of event streams between services, client only | Native WebSocket |
| Building a WebSocket server directly in Node.js | ws (more mature, better documented) |
| Rooms, auto-reconnect, and polling fallback are genuinely needed | Socket.IO |
| Legacy runtimes below Node.js v22 | ws |
| Ultra-high-performance server (C++ level) | uWebSockets.js |
| Wide fan-out or zero message loss required | Kafka/NATS/Redis Streams |
Conclusion — When to Use Native WebSocket for Service-to-Service Communication
The decision comes down to three questions:
- Do you only need a client? Going dependency-free on the server side takes significant effort. If your goal is to slim down the client, now is a good time.
- How will you pass authentication? If you're limited to the standard signature, subprotocol or signed URL are available options; if you need headers, the bundled
undici.WebSocketis the answer. - Is your application prepared to own the reconnection policy? Making jitter-aware backoff a team-standard utility should be your first step.
If you can answer "yes" to all three, you can drop ws from newly written clients. If not, ws remains an excellent choice — not because it's a bad library that needs replacing, but because the runtime has started absorbing that role, giving you one more option to choose from depending on the situation.