Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 090 / 100

Load testing WebSockets: real-world lessons and examples

Know your limits before your users find them.

Expona Admin8 min read

When your WebSocket server handles 100 concurrent users smoothly in development, you assume it'll handle 10,000 in production. Then reality strikes at 2 AM when connection timeouts spike across your dashboards. Load testing WebSockets: real-world lessons and examples isn't optional anymore—it's the difference between confident deployments and catastrophic rollbacks. We've learned this the hard way across ride-hailing, trading, and live-notification platforms, and we're sharing what actually works.

Why WebSocket load testing is different

HTTP request-response cycles are stateless and ephemeral. WebSockets are persistent, stateful connections that hold memory, consume file descriptors, and tie up event loop resources for minutes or hours. A load test that works for REST endpoints will give you dangerously incomplete data for WebSockets. You need to simulate real connection lifecycles—not just spike traffic and measure response times.

The gap between theory and production is wider than most teams expect. A server that feels responsive with 50 concurrent connections can become unresponsive at 500 because of memory leaks, event loop blocking, or upstream service saturation. Kafka brokers, Redis connections, and reverse proxies often become bottlenecks before your application code does. Load testing forces you to find these limits before your users do.

Tools: k6 and Artillery for WebSocket scenarios

Two tools dominate the ecosystem: k6 and Artillery. Both support WebSocket protocols and script realistic scenarios without requiring you to write custom load-generation code.

k6 is purpose-built for performance testing and excels at high-throughput scenarios. It's written in Go, runs a thin JavaScript runtime, and scales to thousands of virtual users on modest hardware. The API is clean and the documentation is thorough.

Artillery is more intuitive for teams new to load testing. Its YAML-based configuration is easier to read than scripts, and it integrates well with CI/CD pipelines. It performs slightly worse than k6 at extreme scale, but handles most real-world WebSocket workloads comfortably.

Here's a basic k6 script that connects to a WebSocket, subscribes to a channel, and holds the connection open:

typescript
import ws from 'k6/ws';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '30s', target: 100 },  // Ramp to 100 users
    { duration: '2m', target: 100 },   // Hold for 2 minutes
    { duration: '30s', target: 0 },    // Ramp down
  ],
  thresholds: {
    'ws_connecting': ['p(95)<500'],
    'ws_session_duration': ['p(99)<30000'],
  },
};

export default function () {
  const url = 'ws://localhost:8080/ws';
  const params = {
    tags: { name: 'WebSocketTest' },
  };

  const res = ws.connect(url, params, function (socket) {
    socket.on('open', () => {
      console.log('Connected');
      socket.send(JSON.stringify({
        type: 'subscribe',
        channel: 'updates',
      }));
    });

    socket.on('message', (data) => {
      check(data, {
        'message received': (d) => d.length > 0,
      });
    });

    socket.on('close', () => {
      console.log('Disconnected');
    });

    socket.setTimeout(() => {
      socket.close();
    }, 30000); // Hold connection for 30 seconds
  });

  check(res, {
    'connection established': (r) => r && r.status === 101,
  });
}

Run this with k6 run script.js. The output shows connection success rates, message throughput, and latency percentiles at each stage.

Realistic behavior: Connect, subscribe, receive and idle

A load test that simply opens connections and closes them is useless. Real users connect, perform setup steps like subscribing to channels, stay idle while consuming messages, and eventually disconnect. Your test must reflect this.

Consider a trading platform. When a user connects, they:

  1. Open the WebSocket
  2. Authenticate with a token
  3. Subscribe to 3-5 market data channels
  4. Sit idle for 5-30 minutes, receiving price updates every 100-500ms
  5. Send an occasional trade instruction
  6. Disconnect

A test that skips steps 2-4 won't expose the real bottlenecks. Here's a more realistic scenario:

typescript
import ws from 'k6/ws';
import { check, sleep } from 'k6';
import { randomString } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';

export const options = {
  stages: [
    { duration: '1m', target: 500 },
    { duration: '5m', target: 500 },
    { duration: '1m', target: 0 },
  ],
};

export default function () {
  const userId = randomString(16);
  const url = `ws://localhost:8080/ws?user_id=${userId}`;

  ws.connect(url, {}, function (socket) {
    // Step 1: Authenticate
    socket.on('open', () => {
      socket.send(JSON.stringify({
        type: 'auth',
        token: 'test-token-' + userId,
      }));
    });

    let authReceived = false;

    socket.on('message', (data) => {
      const msg = JSON.parse(data);

      // Step 2: After auth, subscribe to channels
      if (msg.type === 'auth_ok' && !authReceived) {
        authReceived = true;
        socket.send(JSON.stringify({
          type: 'subscribe',
          channels: ['AAPL', 'GOOGL', 'TSLA'],
        }));
      }

      // Step 3: Track message latency
      if (msg.type === 'price_update') {
        const latency = Date.now() - msg.timestamp;
        check(latency, {
          'price update latency < 100ms': (l) => l < 100,
          'price update latency < 500ms': (l) => l < 500,
        });
      }
    });

    // Step 4: Idle, then send occasional trades
    sleep(2);
    for (let i = 0; i < 5; i++) {
      socket.send(JSON.stringify({
        type: 'trade',
        symbol: 'AAPL',
        quantity: 10,
      }));
      sleep(10 + Math.random() * 20);
    }

    socket.close();
  });
}

This test now measures what actually matters: authentication latency, subscription overhead, message delivery latency under idle load, and the system's ability to handle interleaved trades. The results are directly applicable to production behavior.

Ramp gradually: Find the point where latency starts rising

Don't jump from 10 users to 5,000. Gradual ramps reveal the exact concurrency where performance degrades. Most systems have a "knee point"—the point where latency begins rising non-linearly. Find it, understand it, and ensure production peaks stay safely below it.

Use stages in your load test:

typescript
export const options = {
  stages: [
    { duration: '2m', target: 100 },
    { duration: '2m', target: 200 },
    { duration: '2m', target: 500 },
    { duration: '2m', target: 1000 },
    { duration: '2m', target: 2000 },
    { duration: '5m', target: 2000 },
    { duration: '2m', target: 0 },
  ],
  thresholds: {
    'ws_connecting': ['p(95)<1000'],
    'ws_data_received': ['p(95)<500'],
  },
};

Monitor the ws_connecting and ws_data_received metrics as load increases. When p95 latency for message delivery jumps from 50ms to 300ms between stages, you've found your boundary. That's the concurrency where something upstream—often a database connection pool, Kafka broker, or Redis instance—is exhausted.

In one e-commerce platform, we discovered that 800 concurrent WebSocket connections caused Redis connection pool exhaustion. At 800 users, subscriptions started timing out. At 801, cascading failures began. Without a gradual ramp, we would've missed this by testing at 5,000 and seeing global failure, not knowing where the actual limit was.

Watch the whole system: Proxies, Redis and Kafka often fail first

Your WebSocket application code is rarely the bottleneck. Nginx or HAProxy reverse proxies often max out at 1,000-2,000 concurrent connections per worker process. Redis connection pools empty before your app runs out of memory. Kafka brokers become latent under high message throughput. Load test the entire stack, not just your service.

Set up monitoring dashboards before you start testing:

  • Application: CPU, memory, event loop lag, active WebSocket connections, message latency
  • Reverse proxy: Active connections, error rates, connection rejection counts
  • Redis: Connection count, command latency, eviction rates, memory usage
  • Kafka: Producer latency, broker CPU, disk I/O, replication lag
  • Database: Connection pool saturation, slow query logs, lock contention

During a load test of a ride-hailing platform, we ramped to 2,000 concurrent users. The WebSocket app metrics looked healthy—CPU at 40%, memory stable. But Kafka producer latency shot to 5 seconds. Turns out the Kafka brokers were receiving 100,000 GPS updates per second and the replication group couldn't keep up. The bottleneck was never our code; it was the broker configuration. Without watching the whole system, we would've unnecessarily scaled up our application tier.

Use a tool like Prometheus + Grafana to centralize these metrics. Add them to your load test—k6 can export metrics to Prometheus natively.

Common mistakes

  • Testing with small payloads only: A 50-byte message behaves very differently from a 50KB one. Test with realistic payload sizes. Memory usage and bandwidth saturation scale non-linearly.
  • Ignoring connection churn: Real users connect and disconnect throughout the day. A test that ramps to 5,000 and holds is incomplete. Include realistic connect/disconnect patterns.
  • Running tests from a single machine: A single load generator machine may become CPU-bound before your target system does, giving false results. Distribute load across multiple machines or use a managed service like k6 Cloud.
  • Not testing reconnection logic: Network partitions, proxy restarts, and upstream failures force reconnections. Test how your server handles a thundering herd of reconnections. If 2,000 clients all reconnect simultaneously, do they succeed, or does the storm crash your authentication service?
  • Measuring only success rates: A test showing "99% of messages delivered" is misleading if 1% are delivered after a 30-second delay. Always measure latency percentiles—p50, p95, p99, p99.9. These tell the real story.

Key takeaways

  • Load test WebSockets with persistent connections, realistic subscription workflows, and gradual ramps—not one-off request bursts.
  • Use k6 or Artillery to script multi-step scenarios that reflect real user behavior: connect, authenticate, subscribe, idle, interact.
  • Ramp load gradually in stages to find the exact concurrency where latency begins rising. That's your system's true limit.
  • Monitor the entire system: your app, reverse proxy, Redis, Kafka, and database. The WebSocket app is rarely the first thing to fail.
  • Set thresholds for latency percentiles—especially p95 and p99—and fail the test if those thresholds are breached. One outlier-free test isn't enough.
  • Run load tests in an environment as close to production as possible. Development databases and single-box Redis will hide real bottlenecks.
  • Schedule load tests before major deployments and after infrastructure changes. What passed 6 months ago may fail now.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles