Testing

Performance Testing Node.js Applications

A practical guide to performance testing Node.js applications with autocannon, k6, and Artillery, covering load testing, stress testing, CI integration, and performance budgets.

Performance Testing Node.js Applications

Overview

Performance testing is how you prove your application survives contact with real users -- hundreds or thousands of them hitting your API simultaneously. Unit tests tell you the code is correct. Performance tests tell you the code is correct at scale. This article walks through the full landscape of performance testing for Node.js: load testing with autocannon, scripted scenarios with k6, Artillery for complex user flows, function-level benchmarking, memory leak detection under sustained load, and wiring all of it into your CI pipeline so regressions never reach production.

Prerequisites

  • Node.js 18+ installed
  • An Express.js application to test (we will build one)
  • autocannon installed globally (npm install -g autocannon)
  • k6 installed (k6.io/docs/getting-started/installation)
  • Artillery installed globally (npm install -g artillery)
  • Basic familiarity with HTTP, REST APIs, and async/await
  • Docker installed (optional, for isolated test environments)

Types of Performance Tests

Before you start firing requests, understand what you are testing for. Each type of performance test answers a different question about your system.

Load Testing

Load testing applies a realistic level of concurrent users to your application and measures response times, throughput, and error rates. The question: "Does my application perform acceptably under expected traffic?"

A typical load test ramps up to your expected peak traffic (say 200 concurrent users), sustains it for 5-10 minutes, and measures latency percentiles. If your p99 latency stays under 500ms, you pass.

Stress Testing

Stress testing pushes past normal load to find the breaking point. The question: "Where does my application start failing, and how does it fail?"

You ramp up to 2x, 5x, 10x your expected traffic. Does the app start returning 503s? Does it crash? Does it degrade gracefully? Stress tests reveal whether your error handling and back-pressure mechanisms actually work.

Soak Testing

Soak testing (endurance testing) runs at moderate load for an extended period -- hours, sometimes days. The question: "Does my application leak memory, accumulate connections, or degrade over time?"

Node.js applications are particularly susceptible to slow memory leaks that only manifest after 6-8 hours of sustained traffic. Soak tests catch them before production does.

Spike Testing

Spike testing hits your application with a sudden burst of traffic. The question: "What happens when traffic jumps 10x in 30 seconds?"

This tests auto-scaling, connection pool recovery, and how your load balancer handles sudden shifts. In Node.js, spike tests often reveal event loop blocking under sudden concurrency.


Autocannon: HTTP Benchmarking for Node.js

Autocannon is the fastest way to throw HTTP traffic at a Node.js API. It is written in Node.js itself, runs from the command line, and gives you p50/p99/p999 latencies plus throughput in seconds.

Quick Command-Line Usage

autocannon -c 100 -d 30 -p 10 http://localhost:3000/api/users

This sends requests from 100 concurrent connections for 30 seconds with 10 pipelined requests per connection.

Running 30s test @ http://localhost:3000/api/users
100 connections with 10 pipelining factor

Stat    2.5%   50%    97.5%  99%    Avg      Stdev   Max
Latency 2 ms   5 ms   18 ms  34 ms  6.42 ms  8.12 ms 312 ms

Stat      1%      2.5%    50%     97.5%   Avg      Stdev   Min
Req/Sec   12,431  13,200  15,847  16,900  15,623   1,243   12,430
Bytes/Sec 2.89 MB 3.07 MB 3.68 MB 3.93 MB 3.63 MB 289 kB  2.89 MB

471k requests in 30.03s, 109 MB read

The numbers that matter: p99 latency (34ms here) and Req/Sec at the 2.5th percentile (13,200 -- your worst-case throughput). The average is nearly useless for capacity planning because it hides tail latency.

Programmatic Usage

Autocannon shines when used programmatically in test scripts. This lets you run benchmarks as part of your test suite and enforce performance budgets.

var autocannon = require('autocannon');

function runBenchmark(callback) {
    var instance = autocannon({
        url: 'http://localhost:3000/api/users',
        connections: 100,
        duration: 30,
        pipelining: 10,
        headers: {
            'Authorization': 'Bearer test-token-123',
            'Content-Type': 'application/json'
        }
    }, function(err, result) {
        if (err) {
            callback(err);
            return;
        }
        callback(null, result);
    });

    autocannon.track(instance, { renderProgressBar: true });
}

runBenchmark(function(err, result) {
    if (err) {
        console.error('Benchmark failed:', err);
        process.exit(1);
    }

    console.log('Requests/sec (avg):', result.requests.average);
    console.log('Latency p99:', result.latency.p99, 'ms');
    console.log('Errors:', result.errors);

    // Enforce performance budget
    if (result.latency.p99 > 100) {
        console.error('FAIL: p99 latency exceeded 100ms budget');
        process.exit(1);
    }

    if (result.requests.average < 5000) {
        console.error('FAIL: throughput below 5000 req/sec minimum');
        process.exit(1);
    }

    console.log('PASS: performance budget met');
});

Testing Different Endpoints

Real APIs have more than one endpoint. Test them separately because performance characteristics vary wildly between a simple health check and a paginated database query.

var autocannon = require('autocannon');

var endpoints = [
    { url: '/api/health', method: 'GET', expectedP99: 10 },
    { url: '/api/users', method: 'GET', expectedP99: 50 },
    { url: '/api/users/search?q=john', method: 'GET', expectedP99: 200 },
    { url: '/api/users', method: 'POST', expectedP99: 100, body: JSON.stringify({ name: 'Test', email: '[email protected]' }) }
];

function testEndpoint(endpoint, callback) {
    var opts = {
        url: 'http://localhost:3000' + endpoint.url,
        method: endpoint.method,
        connections: 50,
        duration: 15,
        headers: { 'Content-Type': 'application/json' }
    };

    if (endpoint.body) {
        opts.body = endpoint.body;
    }

    autocannon(opts, function(err, result) {
        if (err) {
            callback(err);
            return;
        }

        var passed = result.latency.p99 <= endpoint.expectedP99;
        console.log(
            (passed ? 'PASS' : 'FAIL') + ' ' +
            endpoint.method + ' ' + endpoint.url +
            ' p99=' + result.latency.p99 + 'ms' +
            ' (budget: ' + endpoint.expectedP99 + 'ms)'
        );

        callback(null, { endpoint: endpoint, result: result, passed: passed });
    });
}

// Run sequentially to avoid resource contention between tests
var results = [];
var index = 0;

function next() {
    if (index >= endpoints.length) {
        var failures = results.filter(function(r) { return !r.passed; });
        if (failures.length > 0) {
            console.error(failures.length + ' endpoint(s) failed performance budget');
            process.exit(1);
        }
        console.log('All endpoints passed performance budget');
        return;
    }

    testEndpoint(endpoints[index], function(err, result) {
        if (err) {
            console.error(err);
            process.exit(1);
        }
        results.push(result);
        index++;
        next();
    });
}

next();

k6: Scripted Load Tests

While autocannon is excellent for raw HTTP benchmarking, k6 excels at scripted scenarios that simulate realistic user behavior: login, browse, search, add to cart, checkout. k6 scripts are written in JavaScript (with some differences -- k6 uses a Go-based runtime, not Node.js).

Basic k6 Script

// load-test.js (k6 script -- note: k6 uses its own JS runtime, not Node.js)
import http from 'k6/http';
import { check, sleep } from 'k6';

export var options = {
    stages: [
        { duration: '30s', target: 50 },   // ramp up to 50 users
        { duration: '1m', target: 50 },     // hold at 50 users
        { duration: '30s', target: 200 },   // ramp up to 200 users
        { duration: '1m', target: 200 },    // hold at 200 users
        { duration: '30s', target: 0 },     // ramp down
    ],
    thresholds: {
        http_req_duration: ['p(99)<500'],    // 99% of requests under 500ms
        http_req_failed: ['rate<0.01'],      // less than 1% error rate
        http_reqs: ['rate>100'],             // at least 100 req/sec
    },
};

export default function() {
    var res = http.get('http://localhost:3000/api/users');

    check(res, {
        'status is 200': function(r) { return r.status === 200; },
        'response time < 200ms': function(r) { return r.timings.duration < 200; },
        'body has users array': function(r) {
            var body = JSON.parse(r.body);
            return Array.isArray(body.users);
        },
    });

    sleep(1); // simulate user think time
}

Run it:

k6 run load-test.js

Output:

          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  (‾)  |
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: load-test.js
     output: -

  scenarios: (100.00%) 1 scenario, 200 max VUs, 3m30s max duration

     ✓ status is 200
     ✓ response time < 200ms
     ✓ body has users array

     checks.........................: 100.00% ✓ 28947  ✗ 0
     http_req_duration..............: avg=12.34ms  p(90)=23.45ms  p(99)=89.12ms
     ✓ { p(99)<500 }
     http_req_failed................: 0.00%   ✓ 0      ✗ 9649
     ✓ { rate<0.01 }
     http_reqs......................: 9649    53.6/s
     ✓ { rate>100 }

running (3m00.0s), 000/200 VUs, 9649 complete iterations
default ✓ [======================================] 000/200 VUs  3m0s

Multi-Step User Scenario

This is where k6 pulls ahead of simple HTTP benchmarking tools. You can model a complete user journey:

// scenario-test.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

var errorRate = new Rate('errors');
var loginDuration = new Trend('login_duration');
var searchDuration = new Trend('search_duration');

export var options = {
    stages: [
        { duration: '1m', target: 100 },
        { duration: '3m', target: 100 },
        { duration: '1m', target: 0 },
    ],
    thresholds: {
        errors: ['rate<0.05'],
        login_duration: ['p(95)<300'],
        search_duration: ['p(95)<500'],
    },
};

var BASE_URL = 'http://localhost:3000';

export default function() {
    var token;

    group('Login', function() {
        var loginRes = http.post(BASE_URL + '/api/auth/login', JSON.stringify({
            email: '[email protected]',
            password: 'testpassword123'
        }), {
            headers: { 'Content-Type': 'application/json' },
        });

        check(loginRes, {
            'login successful': function(r) { return r.status === 200; },
        }) || errorRate.add(1);

        loginDuration.add(loginRes.timings.duration);

        if (loginRes.status === 200) {
            token = JSON.parse(loginRes.body).token;
        }
    });

    sleep(Math.random() * 3 + 1); // 1-4 second think time

    group('Browse Users', function() {
        var headers = { 'Authorization': 'Bearer ' + token };
        var usersRes = http.get(BASE_URL + '/api/users?page=1&limit=20', { headers: headers });

        check(usersRes, {
            'users loaded': function(r) { return r.status === 200; },
            'has pagination': function(r) {
                var body = JSON.parse(r.body);
                return body.total !== undefined;
            },
        }) || errorRate.add(1);
    });

    sleep(Math.random() * 2 + 1);

    group('Search', function() {
        var headers = { 'Authorization': 'Bearer ' + token };
        var searchRes = http.get(BASE_URL + '/api/users/search?q=john&limit=10', { headers: headers });

        check(searchRes, {
            'search returned results': function(r) { return r.status === 200; },
        }) || errorRate.add(1);

        searchDuration.add(searchRes.timings.duration);
    });

    sleep(1);
}

Artillery: Scenario-Based Testing

Artillery sits between autocannon (pure HTTP bombardment) and k6 (scripted scenarios). Its strength is YAML-based test definitions that non-developers can read and modify. It also has excellent support for WebSocket and Socket.io testing, which matters if your Node.js app uses real-time features.

# artillery-test.yml
config:
  target: "http://localhost:3000"
  phases:
    - duration: 60
      arrivalRate: 10
      name: "Warm up"
    - duration: 120
      arrivalRate: 50
      name: "Sustained load"
    - duration: 60
      arrivalRate: 100
      name: "Peak load"
  defaults:
    headers:
      Content-Type: "application/json"
  ensure:
    p99: 500
    maxErrorRate: 1

scenarios:
  - name: "Browse and Search"
    weight: 70
    flow:
      - get:
          url: "/api/users?page=1&limit=20"
          capture:
            - json: "$.users[0].id"
              as: "userId"
      - think: 2
      - get:
          url: "/api/users/{{ userId }}"
      - think: 1
      - get:
          url: "/api/users/search?q=test"

  - name: "Create User"
    weight: 30
    flow:
      - post:
          url: "/api/users"
          json:
            name: "Load Test User {{ $randomNumber(1, 10000) }}"
            email: "loadtest-{{ $randomNumber(1, 10000) }}@example.com"
          expect:
            - statusCode: 201

Run it:

artillery run artillery-test.yml
All VUs finished. Total time: 4 minutes, 2 seconds

Summary report @ 14:32:18(+0000)
  Scenarios launched:  7200
  Scenarios completed: 7198
  Requests completed:  21594
  Mean response/sec:   89.98
  Response time (msec):
    min: 1
    max: 892
    median: 12
    p95: 45
    p99: 187
  Codes:
    200: 18594
    201: 3000
  Errors:
    ETIMEDOUT: 2

The ensure block is critical. If p99 exceeds 500ms or the error rate exceeds 1%, Artillery exits with a non-zero code -- perfect for CI pipelines.


Benchmarking Individual Functions with Benchmark.js

Sometimes the bottleneck is not your HTTP layer but a specific function -- a JSON serializer, a data transformation, a cryptographic operation. Benchmark.js isolates function-level performance.

npm install --save-dev benchmark
var Benchmark = require('benchmark');
var suite = new Benchmark.Suite();

// Compare two approaches to deep cloning
function cloneWithJSON(obj) {
    return JSON.parse(JSON.stringify(obj));
}

function cloneWithStructured(obj) {
    return structuredClone(obj);
}

function cloneManual(obj) {
    var result = {};
    var keys = Object.keys(obj);
    for (var i = 0; i < keys.length; i++) {
        var key = keys[i];
        var val = obj[key];
        if (typeof val === 'object' && val !== null) {
            result[key] = cloneManual(val);
        } else {
            result[key] = val;
        }
    }
    return result;
}

var testObj = {
    name: 'test',
    nested: { a: 1, b: 2, c: { d: 3, e: 4 } },
    tags: ['one', 'two', 'three'],
    active: true,
    count: 42
};

suite
    .add('JSON.parse/stringify', function() {
        cloneWithJSON(testObj);
    })
    .add('structuredClone', function() {
        cloneWithStructured(testObj);
    })
    .add('Manual recursive clone', function() {
        cloneManual(testObj);
    })
    .on('cycle', function(event) {
        console.log(String(event.target));
    })
    .on('complete', function() {
        console.log('Fastest is ' + this.filter('fastest').map('name'));
    })
    .run({ async: true });

Output:

JSON.parse/stringify x 412,847 ops/sec ±1.23% (91 runs sampled)
structuredClone x 287,123 ops/sec ±2.45% (88 runs sampled)
Manual recursive clone x 1,847,293 ops/sec ±0.89% (94 runs sampled)
Fastest is Manual recursive clone

This is the kind of data that settles arguments in code reviews. Numbers, not opinions.


Profiling Under Load with Clinic.js

Clinic.js is a suite of Node.js profiling tools that generate flame graphs, event loop analysis, and I/O bottleneck detection. The key is running Clinic while your application is under load -- profiling an idle server tells you nothing.

npm install -g clinic

Flame Graphs Under Load

# Terminal 1: Start the app with Clinic flame
clinic flame -- node app.js

# Terminal 2: Generate load
autocannon -c 100 -d 30 http://localhost:3000/api/users

# Terminal 1: Press Ctrl+C to stop -- Clinic opens a flame graph in your browser

The flame graph shows you exactly which functions consumed the most CPU time during the load test. Wide bars at the top of the graph are hot functions. Look for your application code, not V8 internals.

Event Loop Analysis with Clinic Doctor

# Terminal 1: Start with Clinic Doctor
clinic doctor -- node app.js

# Terminal 2: Generate load
autocannon -c 200 -d 60 http://localhost:3000/api/users

# Stop with Ctrl+C

Clinic Doctor flags three things: event loop delays, excessive GC activity, and I/O saturation. If the event loop delay graph shows spikes above 50ms, you have synchronous blocking code somewhere.

Bubbleprof for I/O Bottlenecks

clinic bubbleprof -- node app.js

Bubbleprof visualizes the asynchronous operations in your application as a bubble diagram. Large bubbles indicate I/O operations that take a long time -- slow database queries, external API calls, or file system operations that should be cached.


Setting Performance Baselines and Budgets

A performance test without a baseline is just a number. You need two things: a baseline (how the app currently performs) and a budget (the threshold that triggers a failure).

Establishing a Baseline

Run your benchmark suite against the current production release and save the results:

// perf-baseline.js
var autocannon = require('autocannon');
var fs = require('fs');
var path = require('path');

var BASELINE_FILE = path.join(__dirname, 'perf-baseline.json');

function captureBaseline(callback) {
    autocannon({
        url: 'http://localhost:3000/api/users',
        connections: 100,
        duration: 60,
        pipelining: 1
    }, function(err, result) {
        if (err) {
            callback(err);
            return;
        }

        var baseline = {
            timestamp: new Date().toISOString(),
            commit: process.env.GIT_COMMIT || 'unknown',
            metrics: {
                latency_p50: result.latency.p50,
                latency_p99: result.latency.p99,
                latency_max: result.latency.max,
                requests_avg: result.requests.average,
                requests_min: result.requests.min,
                throughput_avg: result.throughput.average,
                errors: result.errors
            }
        };

        fs.writeFileSync(BASELINE_FILE, JSON.stringify(baseline, null, 2));
        console.log('Baseline saved:', JSON.stringify(baseline.metrics, null, 2));
        callback(null, baseline);
    });
}

captureBaseline(function(err) {
    if (err) {
        console.error('Failed to capture baseline:', err);
        process.exit(1);
    }
});

Enforcing Budgets Against Baseline

// perf-check.js
var autocannon = require('autocannon');
var fs = require('fs');
var path = require('path');

var BASELINE_FILE = path.join(__dirname, 'perf-baseline.json');
var REGRESSION_THRESHOLD = 0.20; // 20% regression triggers failure

function loadBaseline() {
    if (!fs.existsSync(BASELINE_FILE)) {
        console.warn('No baseline file found. Run perf-baseline.js first.');
        return null;
    }
    return JSON.parse(fs.readFileSync(BASELINE_FILE, 'utf8'));
}

function checkPerformance(callback) {
    var baseline = loadBaseline();

    autocannon({
        url: 'http://localhost:3000/api/users',
        connections: 100,
        duration: 60,
        pipelining: 1
    }, function(err, result) {
        if (err) {
            callback(err);
            return;
        }

        if (!baseline) {
            console.log('No baseline to compare against. Skipping regression check.');
            callback(null, true);
            return;
        }

        var checks = [];

        // Check latency regression
        var latencyChange = (result.latency.p99 - baseline.metrics.latency_p99) / baseline.metrics.latency_p99;
        checks.push({
            name: 'p99 latency',
            baseline: baseline.metrics.latency_p99 + 'ms',
            current: result.latency.p99 + 'ms',
            change: (latencyChange * 100).toFixed(1) + '%',
            passed: latencyChange < REGRESSION_THRESHOLD
        });

        // Check throughput regression
        var throughputChange = (baseline.metrics.requests_avg - result.requests.average) / baseline.metrics.requests_avg;
        checks.push({
            name: 'avg throughput',
            baseline: baseline.metrics.requests_avg + ' req/s',
            current: result.requests.average + ' req/s',
            change: (-throughputChange * 100).toFixed(1) + '%',
            passed: throughputChange < REGRESSION_THRESHOLD
        });

        console.log('\nPerformance Regression Check');
        console.log('============================');
        checks.forEach(function(check) {
            console.log(
                (check.passed ? 'PASS' : 'FAIL') + '  ' +
                check.name + ': ' +
                check.baseline + ' -> ' + check.current +
                ' (' + check.change + ')'
            );
        });

        var allPassed = checks.every(function(c) { return c.passed; });
        callback(null, allPassed);
    });
}

checkPerformance(function(err, passed) {
    if (err) {
        console.error(err);
        process.exit(1);
    }
    process.exit(passed ? 0 : 1);
});

CI/CD Integration: Automated Performance Gates

Performance tests are only useful if they run automatically. Here is how to wire them into your pipeline so regressions fail the build.

GitHub Actions

# .github/workflows/performance.yml
name: Performance Tests
on:
  pull_request:
    branches: [main]

jobs:
  perf-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci

      - name: Start application
        run: |
          npm start &
          sleep 5
        env:
          NODE_ENV: test
          DATABASE_URL: postgres://postgres:testpass@localhost:5432/testdb

      - name: Install k6
        run: |
          sudo gpg -k
          sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
          echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
          sudo apt-get update
          sudo apt-get install k6

      - name: Run load tests
        run: k6 run --out json=perf-results.json tests/performance/load-test.js

      - name: Run autocannon benchmarks
        run: node tests/performance/perf-check.js

      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: perf-results
          path: perf-results.json

Azure DevOps Pipeline

# azure-pipelines-perf.yml
trigger: none
pr:
  branches:
    include:
      - main

pool:
  vmImage: 'ubuntu-latest'

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'

  - script: npm ci
    displayName: 'Install dependencies'

  - script: |
      npm start &
      sleep 5
    displayName: 'Start application'
    env:
      NODE_ENV: test

  - script: |
      npm install -g autocannon
      node tests/performance/perf-check.js
    displayName: 'Run performance benchmarks'

  - script: |
      curl -s https://dl.k6.io/key.gpg | sudo apt-key add -
      echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
      sudo apt-get update && sudo apt-get install k6
      k6 run tests/performance/load-test.js
    displayName: 'Run k6 load tests'

Testing Database Performance Under Load

Your API is only as fast as your slowest query. Database performance testing isolates whether the database is the bottleneck.

// db-perf-test.js
var autocannon = require('autocannon');
var pg = require('pg');

var pool = new pg.Pool({
    connectionString: process.env.DATABASE_URL,
    max: 20
});

// Measure raw query performance independent of HTTP
function benchmarkQuery(queryName, queryFn, iterations, callback) {
    var times = [];
    var completed = 0;

    for (var i = 0; i < iterations; i++) {
        var start = process.hrtime.bigint();

        queryFn(function(err) {
            var end = process.hrtime.bigint();
            var durationMs = Number(end - start) / 1e6;
            times.push(durationMs);

            completed++;
            if (completed === iterations) {
                times.sort(function(a, b) { return a - b; });
                var result = {
                    name: queryName,
                    iterations: iterations,
                    p50: times[Math.floor(times.length * 0.50)].toFixed(2),
                    p95: times[Math.floor(times.length * 0.95)].toFixed(2),
                    p99: times[Math.floor(times.length * 0.99)].toFixed(2),
                    avg: (times.reduce(function(a, b) { return a + b; }, 0) / times.length).toFixed(2)
                };
                callback(null, result);
            }
        });
    }
}

// Test: Simple SELECT by primary key
function testPrimaryKeyLookup(callback) {
    pool.query('SELECT * FROM users WHERE id = $1', [1], callback);
}

// Test: Full-text search
function testFullTextSearch(callback) {
    pool.query(
        "SELECT * FROM users WHERE to_tsvector('english', name) @@ plainto_tsquery('english', $1) LIMIT 20",
        ['john'],
        callback
    );
}

// Test: Paginated listing with JOIN
function testPaginatedJoin(callback) {
    pool.query(
        'SELECT u.*, COUNT(o.id) as order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id ORDER BY u.created_at DESC LIMIT 20 OFFSET 0',
        callback
    );
}

// Run all benchmarks
benchmarkQuery('Primary key lookup', testPrimaryKeyLookup, 1000, function(err, result) {
    console.log(result);

    benchmarkQuery('Full-text search', testFullTextSearch, 500, function(err, result) {
        console.log(result);

        benchmarkQuery('Paginated JOIN', testPaginatedJoin, 500, function(err, result) {
            console.log(result);
            pool.end();
        });
    });
});

Output:

{ name: 'Primary key lookup', iterations: 1000, p50: '0.82', p95: '2.14', p99: '4.67', avg: '1.12' }
{ name: 'Full-text search', iterations: 500, p50: '3.45', p95: '8.92', p99: '15.34', avg: '4.23' }
{ name: 'Paginated JOIN', iterations: 500, p50: '12.67', p95: '34.89', p99: '67.12', avg: '15.78' }

If your paginated JOIN is taking 67ms at p99, that is your bottleneck. Add an index, denormalize the count, or cache the result before moving on.


Memory Leak Detection During Load Tests

Node.js memory leaks are insidious. They do not crash your app immediately -- they accumulate over hours until the process runs out of heap space and gets killed by the OS. Soak tests combined with heap monitoring catch them.

// memory-monitor.js
var http = require('http');

// Attach this to your Express app during load tests
function startMemoryMonitor(intervalMs) {
    var samples = [];

    var timer = setInterval(function() {
        var usage = process.memoryUsage();
        var sample = {
            timestamp: Date.now(),
            rss: Math.round(usage.rss / 1024 / 1024),
            heapTotal: Math.round(usage.heapTotal / 1024 / 1024),
            heapUsed: Math.round(usage.heapUsed / 1024 / 1024),
            external: Math.round(usage.external / 1024 / 1024)
        };
        samples.push(sample);

        // Log every 10 samples
        if (samples.length % 10 === 0) {
            var first = samples[0];
            var last = samples[samples.length - 1];
            var heapGrowth = last.heapUsed - first.heapUsed;

            console.log(
                'Memory: heapUsed=' + last.heapUsed + 'MB' +
                ' rss=' + last.rss + 'MB' +
                ' growth=' + heapGrowth + 'MB' +
                ' samples=' + samples.length
            );

            // Alert if heap has grown more than 50MB from start
            if (heapGrowth > 50) {
                console.warn('WARNING: Potential memory leak detected. Heap grew ' + heapGrowth + 'MB');
            }
        }
    }, intervalMs || 5000);

    return {
        stop: function() {
            clearInterval(timer);
            return samples;
        },
        getSamples: function() {
            return samples;
        }
    };
}

// Usage in your test harness
var monitor = startMemoryMonitor(5000);

// After soak test completes (e.g., after 30 minutes of load):
// var samples = monitor.stop();
// Analyze growth trend -- linear growth = leak, stable = healthy

module.exports = { startMemoryMonitor: startMemoryMonitor };

Soak Test with autocannon and Memory Monitoring

# Terminal 1: Start your app with memory monitoring enabled
NODE_ENV=test MONITOR_MEMORY=true node app.js

# Terminal 2: Run a 30-minute soak test at moderate load
autocannon -c 50 -d 1800 http://localhost:3000/api/users

Watch the memory output in Terminal 1. If heapUsed grows steadily over 30 minutes without stabilizing, you have a leak. Common culprand: unclosed event listeners, growing arrays that are never cleared, closures holding references to large objects, and request-scoped data stored in module-level variables.

For deeper leak analysis, trigger a heap snapshot mid-test:

# Send SIGUSR2 to the Node.js process to trigger a heap dump (if you set up the handler)
kill -USR2 $(pgrep -f "node app.js")

Then load the .heapsnapshot file in Chrome DevTools Memory tab and look for retained objects that should have been garbage collected.


Realistic Test Data Generation

Load tests with a single hardcoded user hitting the same endpoint produce misleading results. Caches warm up, database query plans optimize for a single pattern, and connection pools stay underutilized. Realistic test data matters.

// test-data-generator.js
var crypto = require('crypto');

function generateUser(index) {
    var domains = ['gmail.com', 'outlook.com', 'company.com', 'yahoo.com', 'proton.me'];
    var firstNames = ['James', 'Sarah', 'Mike', 'Emily', 'David', 'Lisa', 'Chris', 'Anna', 'Tom', 'Kate'];
    var lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Miller', 'Davis', 'Wilson', 'Moore', 'Taylor'];

    var firstName = firstNames[index % firstNames.length];
    var lastName = lastNames[Math.floor(index / firstNames.length) % lastNames.length];
    var domain = domains[index % domains.length];

    return {
        name: firstName + ' ' + lastName,
        email: firstName.toLowerCase() + '.' + lastName.toLowerCase() + index + '@' + domain,
        age: 20 + (index % 50),
        role: index % 10 === 0 ? 'admin' : 'user',
        department: ['engineering', 'sales', 'marketing', 'support', 'finance'][index % 5]
    };
}

function generateSearchQueries(count) {
    var terms = ['john', 'engineering', 'admin', 'sales', 'smith', 'gmail', 'company', 'manager', 'new york', 'remote'];
    var queries = [];
    for (var i = 0; i < count; i++) {
        queries.push(terms[i % terms.length]);
    }
    return queries;
}

function generatePaginationParams(totalRecords) {
    var pageSizes = [10, 20, 50];
    var params = [];
    for (var i = 0; i < 100; i++) {
        var limit = pageSizes[i % pageSizes.length];
        var maxPage = Math.ceil(totalRecords / limit);
        var page = Math.floor(Math.random() * Math.min(maxPage, 20)) + 1;
        params.push({ page: page, limit: limit });
    }
    return params;
}

module.exports = {
    generateUser: generateUser,
    generateSearchQueries: generateSearchQueries,
    generatePaginationParams: generatePaginationParams
};

Use this data in your k6 scripts via shared arrays or in autocannon with rotating request bodies.


Interpreting Results: What the Numbers Mean

Raw performance numbers are meaningless without context. Here is how to read them.

Latency Percentiles

  • p50 (median): Half your requests are faster than this. Useful for "typical" experience, but hides problems.
  • p90: 90% of requests are faster. This is where you start seeing the slow requests.
  • p95: The "one in twenty" experience. If p95 is 500ms, 5% of your users wait over half a second.
  • p99: One in a hundred. This is the number that matters for SLAs and user experience at scale. If you have 10,000 requests per minute, 100 of them are slower than p99.
  • p999: One in a thousand. Important for financial applications or real-time systems. Usually affected by GC pauses.
  • Max: The single slowest request. Often an outlier caused by cold starts, GC, or container scheduling. Do not optimize for max unless it is consistently high.

The rule: Set your SLA on p99, monitor p95, and investigate when the gap between p50 and p99 grows wider than 10x.

Throughput

Requests per second is straightforward, but watch the minimum (worst second), not just the average. If your average is 15,000 req/s but your min is 200 req/s, something is periodically blocking the event loop.

Error Rates

  • Under 0.1%: Healthy. Transient errors happen.
  • 0.1% - 1%: Investigate. Could be connection pool exhaustion, timeouts, or resource limits.
  • Above 1%: Your application is failing under this load. Fix it before increasing traffic.

Complete Working Example

Here is a complete performance test suite for an Express.js API. It includes the application, autocannon benchmarks, a k6 load test script, and a CI runner that fails builds on regression.

The Express API

// app.js
var express = require('express');
var app = express();
var port = process.env.PORT || 3000;

app.use(express.json());

// Simulated data store
var users = [];
for (var i = 0; i < 1000; i++) {
    users.push({
        id: i + 1,
        name: 'User ' + (i + 1),
        email: 'user' + (i + 1) + '@example.com',
        department: ['engineering', 'sales', 'marketing'][i % 3]
    });
}

app.get('/api/health', function(req, res) {
    res.json({ status: 'ok', uptime: process.uptime() });
});

app.get('/api/users', function(req, res) {
    var page = parseInt(req.query.page) || 1;
    var limit = parseInt(req.query.limit) || 20;
    var start = (page - 1) * limit;
    var slice = users.slice(start, start + limit);

    res.json({
        users: slice,
        total: users.length,
        page: page,
        limit: limit
    });
});

app.get('/api/users/search', function(req, res) {
    var query = (req.query.q || '').toLowerCase();
    var results = users.filter(function(u) {
        return u.name.toLowerCase().indexOf(query) !== -1 ||
               u.email.toLowerCase().indexOf(query) !== -1;
    });

    res.json({ results: results.slice(0, 20), total: results.length });
});

app.get('/api/users/:id', function(req, res) {
    var user = users.find(function(u) { return u.id === parseInt(req.params.id); });
    if (!user) {
        res.status(404).json({ error: 'User not found' });
        return;
    }
    res.json(user);
});

app.post('/api/users', function(req, res) {
    var newUser = {
        id: users.length + 1,
        name: req.body.name,
        email: req.body.email,
        department: req.body.department || 'unassigned'
    };
    users.push(newUser);
    res.status(201).json(newUser);
});

var server = app.listen(port, function() {
    console.log('API server listening on port ' + port);
});

module.exports = { app: app, server: server };

Autocannon Benchmark Suite

// tests/performance/benchmark.js
var autocannon = require('autocannon');

var BASE_URL = 'http://localhost:3000';

var tests = [
    {
        name: 'Health Check',
        url: BASE_URL + '/api/health',
        method: 'GET',
        budget: { p99: 20, minRps: 10000 }
    },
    {
        name: 'List Users (paginated)',
        url: BASE_URL + '/api/users?page=1&limit=20',
        method: 'GET',
        budget: { p99: 50, minRps: 5000 }
    },
    {
        name: 'Search Users',
        url: BASE_URL + '/api/users/search?q=user',
        method: 'GET',
        budget: { p99: 100, minRps: 3000 }
    },
    {
        name: 'Get Single User',
        url: BASE_URL + '/api/users/42',
        method: 'GET',
        budget: { p99: 30, minRps: 8000 }
    },
    {
        name: 'Create User',
        url: BASE_URL + '/api/users',
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: 'Perf Test', email: '[email protected]', department: 'testing' }),
        budget: { p99: 50, minRps: 5000 }
    }
];

function runTest(test, callback) {
    var opts = {
        url: test.url,
        method: test.method,
        connections: 50,
        duration: 15,
        pipelining: 1,
        headers: test.headers || {}
    };

    if (test.body) {
        opts.body = test.body;
    }

    console.log('\nRunning: ' + test.name);
    console.log('  ' + test.method + ' ' + test.url);

    autocannon(opts, function(err, result) {
        if (err) {
            callback(err);
            return;
        }

        var p99Pass = result.latency.p99 <= test.budget.p99;
        var rpsPass = result.requests.average >= test.budget.minRps;

        console.log('  p99 latency: ' + result.latency.p99 + 'ms (budget: ' + test.budget.p99 + 'ms) ' + (p99Pass ? 'PASS' : 'FAIL'));
        console.log('  avg req/sec: ' + result.requests.average + ' (budget: ' + test.budget.minRps + ') ' + (rpsPass ? 'PASS' : 'FAIL'));
        console.log('  errors: ' + result.errors);

        callback(null, {
            name: test.name,
            p99: result.latency.p99,
            avgRps: result.requests.average,
            errors: result.errors,
            passed: p99Pass && rpsPass
        });
    });
}

// Sequential execution
var results = [];
var idx = 0;

function runNext() {
    if (idx >= tests.length) {
        console.log('\n=============================');
        console.log('Performance Test Summary');
        console.log('=============================');

        var failures = 0;
        results.forEach(function(r) {
            var status = r.passed ? 'PASS' : 'FAIL';
            console.log('  ' + status + '  ' + r.name + '  p99=' + r.p99 + 'ms  rps=' + r.avgRps);
            if (!r.passed) failures++;
        });

        if (failures > 0) {
            console.log('\n' + failures + ' test(s) FAILED');
            process.exit(1);
        } else {
            console.log('\nAll tests PASSED');
            process.exit(0);
        }
        return;
    }

    runTest(tests[idx], function(err, result) {
        if (err) {
            console.error('Test error:', err);
            process.exit(1);
        }
        results.push(result);
        idx++;
        runNext();
    });
}

runNext();

k6 Load Test Script

// tests/performance/load-test.js (k6 script)
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

var errorRate = new Rate('errors');
var listDuration = new Trend('list_duration');
var searchDuration = new Trend('search_duration');

export var options = {
    stages: [
        { duration: '30s', target: 50 },
        { duration: '2m', target: 50 },
        { duration: '30s', target: 150 },
        { duration: '2m', target: 150 },
        { duration: '30s', target: 0 },
    ],
    thresholds: {
        http_req_duration: ['p(95)<200', 'p(99)<500'],
        errors: ['rate<0.01'],
        list_duration: ['p(95)<100'],
        search_duration: ['p(95)<300'],
    },
};

var BASE = 'http://localhost:3000';

export default function() {
    group('List and Browse', function() {
        var page = Math.floor(Math.random() * 50) + 1;
        var listRes = http.get(BASE + '/api/users?page=' + page + '&limit=20');

        check(listRes, {
            'list status 200': function(r) { return r.status === 200; },
        }) || errorRate.add(1);

        listDuration.add(listRes.timings.duration);

        if (listRes.status === 200) {
            var body = JSON.parse(listRes.body);
            if (body.users && body.users.length > 0) {
                var userId = body.users[0].id;
                var detailRes = http.get(BASE + '/api/users/' + userId);
                check(detailRes, {
                    'detail status 200': function(r) { return r.status === 200; },
                }) || errorRate.add(1);
            }
        }
    });

    sleep(Math.random() * 2 + 0.5);

    group('Search', function() {
        var terms = ['user', 'engineering', 'sales', 'marketing', '42'];
        var q = terms[Math.floor(Math.random() * terms.length)];
        var searchRes = http.get(BASE + '/api/users/search?q=' + q);

        check(searchRes, {
            'search status 200': function(r) { return r.status === 200; },
        }) || errorRate.add(1);

        searchDuration.add(searchRes.timings.duration);
    });

    sleep(Math.random() + 0.5);
}

CI Runner Script

// tests/performance/ci-runner.js
var http = require('http');
var spawn = require('child_process').spawn;
var execSync = require('child_process').execSync;

var SERVER_PORT = 3000;
var SERVER_START_TIMEOUT = 10000;

function waitForServer(callback) {
    var start = Date.now();

    function check() {
        var req = http.get('http://localhost:' + SERVER_PORT + '/api/health', function(res) {
            if (res.statusCode === 200) {
                callback(null);
            } else {
                retry();
            }
        });

        req.on('error', function() {
            retry();
        });

        req.setTimeout(1000);
    }

    function retry() {
        if (Date.now() - start > SERVER_START_TIMEOUT) {
            callback(new Error('Server did not start within ' + SERVER_START_TIMEOUT + 'ms'));
            return;
        }
        setTimeout(check, 500);
    }

    check();
}

console.log('Starting server...');
var server = spawn('node', ['app.js'], {
    env: Object.assign({}, process.env, { PORT: String(SERVER_PORT), NODE_ENV: 'test' }),
    stdio: 'pipe'
});

server.stdout.on('data', function(data) {
    console.log('[server] ' + data.toString().trim());
});

server.stderr.on('data', function(data) {
    console.error('[server] ' + data.toString().trim());
});

waitForServer(function(err) {
    if (err) {
        console.error('Server failed to start:', err.message);
        server.kill();
        process.exit(1);
    }

    console.log('Server is ready. Running benchmarks...\n');

    try {
        execSync('node tests/performance/benchmark.js', { stdio: 'inherit' });
        console.log('\nBenchmarks passed. Cleaning up...');
    } catch (e) {
        console.error('\nBenchmarks FAILED.');
        server.kill();
        process.exit(1);
    }

    server.kill();
    process.exit(0);
});

package.json Scripts

{
    "scripts": {
        "start": "node app.js",
        "test": "jest",
        "perf": "node tests/performance/ci-runner.js",
        "perf:baseline": "node tests/performance/perf-baseline.js",
        "perf:check": "node tests/performance/perf-check.js",
        "perf:k6": "k6 run tests/performance/load-test.js",
        "perf:soak": "autocannon -c 50 -d 1800 http://localhost:3000/api/users"
    }
}

Run npm run perf in CI. It starts the server, runs all benchmarks, and exits with a non-zero code if any budget is exceeded.


Common Issues & Troubleshooting

1. "ECONNRESET" or "socket hang up" During Load Tests

Error: socket hang up
    at connResetException (node:internal/errors:720:14)
    at Socket.socketOnEnd (node:_http_client:525:23)

This happens when the server closes connections faster than the client expects. Common causes: the Node.js process ran out of file descriptors, or the event loop was blocked and connections timed out.

Fix: Increase the OS file descriptor limit (ulimit -n 65535 on Linux) and check for synchronous operations blocking the event loop. Also verify your server.keepAliveTimeout is longer than your load balancer's idle timeout.

2. "ECONNREFUSED" Partway Through a Test

Error: connect ECONNREFUSED 127.0.0.1:3000

Your Node.js process crashed and restarted (or just crashed). Check stderr output for the actual error -- usually it is an unhandled promise rejection or out-of-memory kill.

Fix: Add process.on('uncaughtException') and process.on('unhandledRejection') handlers during testing to capture the root cause. Monitor RSS memory during the test. If the process is OOM-killed, you need to investigate memory usage patterns.

3. Inconsistent Results Between Runs

You run the same benchmark twice and get 15,000 req/s the first time and 9,000 req/s the second time. This is almost always caused by other processes competing for CPU, thermal throttling on laptops, or background OS operations.

Fix: Run benchmarks on dedicated hardware or isolated containers. Close all other applications. Run at least 3 iterations and take the median. For CI, use dedicated runners with consistent specs. Add a warm-up phase before measuring.

4. k6 "WARN: Request Failed" with No Error Details

WARN[0045] Request Failed    error="Get \"http://localhost:3000/api/users\": dial tcp 127.0.0.1:3000: connect: connection refused"

k6 runs in a separate Go runtime and may saturate your Node.js server's connection backlog. The net.core.somaxconn kernel setting limits the TCP backlog queue.

Fix: Increase the TCP backlog: sysctl -w net.core.somaxconn=4096. In your Node.js app, set server.listen(port, { backlog: 4096 }). Also consider if your connection count exceeds what a single Node.js process can handle -- you may need clustering.

5. Artillery "ETIMEDOUT" Errors Under Moderate Load

Errors:
  ETIMEDOUT: 47

Artillery defaults to relatively short timeouts. Under load, response times increase, and previously fast endpoints start timing out.

Fix: Increase the timeout in your Artillery config:

config:
  http:
    timeout: 30

But do not just increase the timeout and ignore the underlying problem. If requests are genuinely taking 30 seconds, you have a performance issue to fix.

6. Heap Snapshots Crash the Process Under Load

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

Taking a heap snapshot during a load test doubles memory usage momentarily because V8 has to serialize the entire heap graph. If you are already near the heap limit, this will crash the process.

Fix: Increase the heap limit with --max-old-space-size=4096 when running load tests where you plan to capture heap snapshots. Better yet, take snapshots during low-load phases of a soak test rather than during peak traffic.


Best Practices

  • Test in an environment that mirrors production. The same OS, the same Node.js version, the same container memory limits, the same database engine. Performance characteristics change drastically between a MacBook and a Linux container with 512MB RAM.

  • Always measure percentiles, never averages. Averages hide tail latency. A p99 of 2 seconds with an average of 50ms means 1% of your users are having a terrible experience, and the average told you nothing was wrong.

  • Warm up before measuring. V8's JIT compiler optimizes hot functions after they run several times. The first 1,000 requests will be slower than the next 100,000. Include a 10-30 second warm-up phase that you exclude from measurement.

  • Test with realistic concurrency, not maximum concurrency. Running 10,000 concurrent connections against a single Node.js process tells you the theoretical maximum, but tells you nothing about how the app behaves at your actual expected load. Test at 1x, 2x, and 5x your expected peak.

  • Isolate what you are testing. If you are testing API performance, do not also test database seeding speed by starting with an empty database. If you are testing database query performance, use the autocannon results to separate HTTP overhead from query time.

  • Run performance tests on every PR, not just before releases. A 5% regression per commit is invisible. Twenty commits later, you have doubled your latency. Automated CI gates with performance budgets catch regressions when they are introduced, not when they compound.

  • Store and trend results over time. Save benchmark results as JSON artifacts in CI. Plot p99 latency and throughput over the last 50 builds. The trend line is more important than any individual number. A slow upward drift in latency means code is getting incrementally heavier.

  • Do not run load tests against production databases. Use a dedicated test database with production-scale data. Load tests generate thousands of write operations that will pollute your production data and stress your production database. If you must test against production infrastructure, use read-only queries and off-peak hours.

  • Account for connection pool exhaustion. The most common production performance problem I see in Node.js APIs is not CPU or memory -- it is running out of database connections. Set your load test concurrency higher than your connection pool max setting and verify the app queues requests gracefully rather than crashing.


References

Powered by Contentful