How many WebSocket connections can one Node server hold?

File descriptors, memory, the event loop or the link: one of them stops you first. Measured memory per idle connection, and a calculator for your own box.

Ask how many WebSocket connections a single Node server can hold and you will get answers between one thousand and one million, all of them delivered with confidence. Both ends of that range are true. The number depends almost entirely on what those connections are doing, and the thing that stops you is rarely the thing people worry about.

I have been on the inside of this twice. At TRIBE I was the founding engineer on a system that pushed live prices over Socket.IO backed by Redis pub/sub, from zero to more than 100,000 users. At Acefone, Interaction Hub uses Socket.IO for live updates and call signalling, with Redis holding agent presence. In both, the connection count was never the interesting number. The interesting number was how many messages those connections caused.

So rather than repeat a figure from someone's blog post, I measured the parts that can be measured, on my own machine, with the method written down so you can disagree with it.

The four ceilings

Every "how many connections" question resolves to whichever of these is smallest for your traffic.

A table of four limits for one Node process with 4 GB, one core and a 1 Gbit/s link. File descriptors 65,435 connections, memory 811,369, event loop 35,000 and network link 151,699. The event loop is the binding limit and memory is 23 times higher.
The same box, four answers. Memory, the number everyone quotes, is more than twenty times higher than the one that actually binds.

Each one is a division, and you can do all four on the back of an envelope:

LimitThe divisionThis example
File descriptorsulimit -n, minus the descriptors the process needs for logs, DNS and the database pool65,435
Memory(RAM − baseline process memory) ÷ memory per connection811,369
Event loop(CPU budget ÷ CPU per delivery) ÷ (messages per connection × fan-out)35,000
Network linklink bytes per second ÷ (messages × fan-out × frame size)151,699

That example is one core, 4 GB and a gigabit link, with each client sending a message every five seconds into a room of twenty. The event loop gives out at about 35,000 connections while memory would have allowed 811,000. Change the fan-out to one and memory becomes the limit instead. Neither number is a property of "Node" or "WebSockets"; both are properties of the traffic.

1. File descriptors

Every socket is a file descriptor, and the kernel caps how many a process may open. The Linux manual defines RLIMIT_NOFILE as "a value one greater than the maximum file descriptor number that can be opened by this process", and attempts to go past it "yield the error EMFILE". There is a soft limit, which the kernel enforces, and a hard limit that acts as its ceiling; an unprivileged process can raise the soft limit only as far as the hard one, and the hard limit itself is bounded by /proc/sys/fs/nr_open.

In practice this is a configuration bug, not a capacity limit. A default of 1,024 or 8,192 means a server that has been comfortably holding connections all day suddenly starts refusing them, with a stack trace that says EMFILE and nothing about WebSockets. Set it deliberately, and remember that the container, the systemd unit and the shell each have their own idea of the limit.

Leave headroom too. Your process also has descriptors for log files, the database pool, DNS sockets and the listening socket itself, which is why the calculator below reserves a hundred.

2. Memory: what an idle connection actually costs

This is the number most often quoted from memory and most rarely measured, so I measured it. The method: a ws server in its own process, clients opened in batches of 100 from a second process, five seconds of settling, two forced garbage collections, then process.memoryUsage().rss. Node v22.23.2, ws 8.21.3, macOS on an M1 Pro with 16 GB.

JavaScript
// server.js - the whole measurement surface
const { WebSocketServer } = require('ws')

const clients = new Set()
const wss = new WebSocketServer({ port: 0, perMessageDeflate: false })

wss.on('connection', (ws) => {
  clients.add(ws)
  ws.on('close', () => clients.delete(ws))
})

// On demand: collect, then report RSS with every connection open and idle.
process.stdin.on('data', () => {
  global.gc(); global.gc()
  const m = process.memoryUsage()
  process.stdout.write(JSON.stringify({ clients: clients.size, rss: m.rss }) + '\n')
})
Bar chart of resident memory for a ws server. Empty server 53.5 MB, 5,000 idle connections 86.5 MB or 6.8 KB each, 10,000 idle connections 103.5 MB or 5.1 KB each, and 10,000 connections with permessage-deflate 114.8 MB or 6.3 KB each.
Measured on my machine, not quoted from anywhere. An empty server costs 53.5 MB; each idle connection adds about five kilobytes.

Ten thousand idle connections took the process from 53.5 MB to 103.5 MB: 5.12 KB each. At 5,000 connections the figure looked worse, 6.81 KB each, because the fixed overhead is spread over half as many sockets. Heap accounted for only about 2.5 KB of it; the rest is buffers and kernel-side structures outside the JavaScript heap.

Two warnings come with that number. First, it is idle connections holding nothing. The moment you attach a user object, a subscription list and a partially-filled write buffer to each socket, your per-connection cost is whatever you attached, and the socket becomes a rounding error.

Second, compression is not free. Turning on permessage-deflate cost 11.3 MB more for the same 10,000 idle sockets, before a single message moved. The ws documentation is blunt about why you should test it rather than assume: "Node.js has a variety of issues with high-performance compression, where increased concurrency, especially on Linux, can lead to catastrophic memory fragmentation and slow performance". It is disabled by default on the server for a reason.

3. The event loop, which is the one that gets you

Node handles many clients with few threads, and the official guidance puts the consequence plainly: "If the Event Loop spends too long at any point, all current and new clients will not get a turn." Your connection ceiling is therefore a budget in CPU time, not in sockets.

So I measured that too. The same server broadcasting a payload to 1,000 connected clients, 200 times, is 200,000 deliveries. It took 1,011 ms of process CPU time: 5.05 µs per delivery, about 198,000 deliveries per CPU-second. With a 1 KB payload instead of 200 bytes the figure was 5.04 µs, which tells you the cost is per message, not per byte, at these sizes.

Give delivery 70% of one core and you can afford roughly 140,000 deliveries a second. How many connections that buys depends entirely on the next section.

4. Fan-out is the real variable

A connection that receives one message a second is cheap. A connection in a room of 500 people who each type once a minute receives eight messages a second, and every one of those is a delivery your server pays for.

A curve showing connections one process can hold as fan-out rises. At fan-out 10 it is 70,000 connections, at 20 it is 35,000, and at 50 it is 14,000.
The same hardware and the same clients. Only the number of recipients changed, and the ceiling fell by a factor of five.

The arithmetic is: deliveries per second = connections × messages per connection × fan-out. Everything else follows. Ten thousand connections, one message a second each, a room of fifty, and you are asking for 500,000 deliveries a second, which is two and a half CPU-seconds of work every second. One process cannot do it, whatever the RAM says.

This is also where bandwidth quietly arrives. Those 500,000 deliveries at 206 bytes on the wire are about 103 MB/s, comfortably past a gigabit link. Sending whole objects when a delta would do is the most common way to hit this, and the cheapest thing to fix.

Then there is the failure mode underneath. Node's net documentation explains that "socket.write() always works": if the network cannot keep up, "Node.js will internally queue up the data written to a socket", and "the consequence of this internal buffering is that memory may grow". A slow mobile client that cannot drain 8 messages a second becomes a growing buffer in your process. That is a missing backpressure mechanism wearing a WebSocket costume, and it usually shows up as an out-of-memory kill rather than a dropped connection. Check writableLength before you queue more, and drop or coalesce for clients that cannot keep up.

Work out the ceiling for your own box

The defaults are the worked example above, with my measured numbers in the memory and CPU fields. Change the fan-out first and watch which limit takes over.

calculator · one node process, four ceilings

How many WebSocket connections will this box hold?

CONNECTION CEILING
WHAT STOPS YOU
DELIVERIES A SECONDat that ceiling
NEXT LIMIT AFTER IT

where each limit lands, connections

Memory per connection and CPU per delivery are measured on one machine with one library, and both move with your payloads, your framework and your Node version. Measure your own before sizing anything that matters. The event loop ceiling assumes delivery work is the dominant cost and that one process uses one core; the network ceiling adds 6 bytes of frame overhead per message and ignores TLS.

Try three settings. Fan-out of 1, a pure notification service, and memory becomes the binding limit at about 800,000 connections. Fan-out of 50 and the event loop binds at 14,000. Set messages per connection to 5, a chat app during a live event, and you are into the low thousands per process.

quick check

You hold 10,000 connections. Each client sends one message a second, and each message is delivered to 50 connections. At 5 µs of CPU per delivery, how much CPU does delivery alone need?

10,000 × 1 × 50 is 500,000 deliveries a second. At 5 µs each that is 2.5 seconds of CPU per second of wall clock, so two and a half cores doing nothing but writing to sockets.

What these measurements are not

The numbers above are honest but narrow, and it matters where they stop.

They are loopback connections on one laptop, with no TLS, no proxy and no real network. TLS adds per-connection memory and CPU that I did not measure. A load balancer in front adds its own connection limits, and is often the real ceiling in production.

They are also idle or uniformly busy, which no real system is. Real clients arrive in bursts, reconnect together after a network blip, and pick exactly the moment of your deploy to do it. A server that holds 35,000 steady connections may not survive 35,000 simultaneous reconnects, because the handshake cost is concentrated instead of spread.

And the benchmark hit its own limit before the server did. Runs at 15,000 and 20,000 connections failed with EADDRNOTAVAIL, because macOS hands out ephemeral ports in the range 49152 to 65535, about 16,000 of them, and sockets in TIME_WAIT hold them. That is a limit of the machine generating load, not of the server under test, and it is the most common reason a benchmark plateaus at a suspiciously round number. If you test past ten thousand connections from one box, expect to add loopback addresses or more client machines.

Past one process

When the event loop binds, you add processes, and then the problem changes shape: a message arriving at one process must reach connections held by another. That is what Redis pub/sub was doing in the TRIBE price feed, and what it does under Socket.IO's adapter. The cost moves from "can one box hold these sockets" to "how many times does one event get copied", and the answer is once per process, plus once per delivery.

It also changes what "delivered" means. Redis documents that pub/sub has at-most-once semantics: "Once the message is sent by the Redis server, there's no chance of it being sent again. If the subscriber is unable to handle the message (for example, due to an error or a network disconnect) the message is forever lost." For a price tick that is fine, because another one is along in a second. For anything a user must not miss, the fan-out layer needs to be a stream or a queue, not a broadcast.

A few things that follow, and that I would decide before writing any of it:

  • Sticky sessions or a shared store. A connection lives on one process. Anything that process knows and the others do not, like presence, has to live somewhere shared. Interaction Hub keeps agent presence in Redis for exactly this reason.
  • Broadcast is a fan-out amplifier across nodes too. Ten processes each holding 20,000 connections, all subscribed to one channel, means every published message becomes 200,000 deliveries. Sharding rooms across processes beats sharding users.
  • Decide what a slow consumer costs. Per connection, a queue that grows without limit is an out-of-memory error waiting for a bad network; a queue that drops is a correctness decision you should make deliberately rather than discover.
  • Reconnect storms need the same thinking as a queue backlog. Jittered backoff on the client, and a server that sheds load rather than accepting everything and falling over.

The pattern I keep coming back to: count deliveries per second, not connections. It is the only number that makes capacity planning, cost and latency agree with each other.

questions people ask

How many WebSocket connections can one server handle?

For idle connections, hundreds of thousands: measured at about 5 KB each, a 4 GB process fits roughly 800,000. Once messages flow it is far lower, because the limit becomes deliveries per second, not sockets. With a fan-out of twenty, one core binds around 35,000.

How much memory does a WebSocket connection use in Node?

I measured 5.12 KB of resident memory per idle connection with ws 8.21.3 on Node v22.23.2, going from 53.5 MB empty to 103.5 MB at 10,000 connections. Whatever state you attach per connection is usually larger than the socket.

What limits WebSocket connections first?

Usually the event loop, through fan-out. File descriptors bite first if ulimit is left at its default, memory only for very large connection counts, and bandwidth if your payloads are big.

Do I need to raise ulimit for WebSocket servers?

Yes. Each connection is a file descriptor, and exceeding RLIMIT_NOFILE fails with EMFILE. Set the soft limit deliberately for the process, and check the container and service manager agree.

Should I enable permessage-deflate?

Only after testing it with your payloads. It cost 11.3 MB more for 10,000 idle connections in my test, and the ws documentation warns that high-concurrency compression on Node can cause severe memory fragmentation.

Can Node handle a million WebSocket connections?

On memory alone, a large box can. In practice you would need almost no message traffic, a raised file limit, and a client fleet big enough to test it, since one test machine runs out of ephemeral ports at around 16,000 connections.

The short version

Stop counting connections and start counting deliveries per second. Connections are cheap: about five kilobytes each when idle, which almost never decides anything. Deliveries cost about five microseconds of CPU each, and fan-out decides how many of them your clients cause.

Before sizing anything, raise the file limit, measure the memory and CPU of your own payloads rather than trusting mine, and check what happens to a client too slow to drain its socket. Then work out the four ceilings, find the lowest, and fix that one. Adding RAM to a server that is out of event loop does nothing at all.

S

Sanjeev Sharma

Product Engineer at Acefone, building real-time communications at carrier scale: WhatsApp, voice and IVR in one agent inbox. Built and runs PostEngage, a WhatsApp automation SaaS, on his own. Contributor to litellm and the Vercel AI SDK. Takes on a small number of consulting engagements each year.