Artillery Load Testing: Test Node.js and HTTP APIs at Scale
Your Node.js API handles 10 concurrent users without breaking a sweat. Your staging environment runs clean. Then you ship, traffic picks up, and at 300 simultaneous users the response times double, the queue backs up, and your database starts dropping connections. Nobody saw it coming because nobody tested it at load.
That's the gap Artillery fills.
Artillery is a Node.js-native load testing tool that lets you describe traffic patterns in YAML and extend them with JavaScript when you need custom logic. It supports HTTP, WebSocket, and Socket.io out of the box. You run it from the CLI, integrate it into CI, and get a readable report at the end. No JVM, no XML, no GUI required.
Installing Artillery
Artillery runs on Node.js 18 or later. Install it globally:
npm install -g artillery@latestVerify the install:
artillery versionYou should see something like Artillery: 2.x.x. That's it — no configuration wizard, no daemon to start.
For CI environments and team projects, install it as a dev dependency instead:
npm install --save-dev artilleryThen run it via npx artillery or add scripts to your package.json.
The Core Config Structure
Artillery tests are YAML files. A minimal test looks like this:
config:
target: "https://api.example.com"
phases:
- duration: 60
arrivalRate: 10
scenarios:
- name: "Browse products"
flow:
- get:
url: "/products"
- get:
url: "/products/{{ productId }}"The config block sets the target URL and defines the load phases. The scenarios block describes what virtual users actually do. Each virtual user works through the flow steps in order, from top to bottom.
Run it:
artillery run test.yamlArtillery prints a live summary while the test runs and a full report at the end.
Phases: Shaping the Load Curve
Phases are where you model realistic traffic patterns. A single flat rate rarely matches production — you want warmup, ramp-up, sustained peak, and sometimes a spike.
config:
target: "https://api.example.com"
phases:
- name: "Warm up"
duration: 30
arrivalRate: 5
- name: "Ramp up"
duration: 120
arrivalRate: 5
rampTo: 50
- name: "Sustained load"
duration: 300
arrivalRate: 50
- name: "Spike"
duration: 60
arrivalRate: 150
- name: "Recovery"
duration: 60
arrivalRate: 10arrivalRate is new virtual users per second. rampTo linearly increases the rate across the phase duration. The warmup phase matters — hitting a cold API at full rate skews your numbers because caches aren't warm and connection pools aren't filled.
For most HTTP APIs, the phases to care about are ramp-up (does latency stay flat as load increases?) and sustained (does memory grow over time, does the DB pool saturate?).
Scenarios and Variables
A scenario is a user journey. You can define multiple scenarios and control how often each runs:
scenarios:
- name: "Read-only user"
weight: 70
flow:
- get:
url: "/products"
- get:
url: "/products/{{ productId }}"
- name: "Checkout user"
weight: 30
flow:
- post:
url: "/cart"
json:
productId: "{{ productId }}"
quantity: 1
- post:
url: "/checkout"
json:
cartId: "{{ cartId }}"The weight values are relative — here 70% of virtual users run the read-only flow, 30% go through checkout.
Variables like {{ productId }} get substituted per-request. You can define them inline or load them from a CSV payload file.
Payloads from CSV
When you need realistic data — user IDs, product SKUs, search terms — pull them from a CSV:
config:
target: "https://api.example.com"
payload:
path: "./data/products.csv"
fields:
- productId
- productName
order: randomproducts.csv:
productId,productName
sku-001,Wireless Headphones
sku-002,Mechanical Keyboard
sku-003,USB-C HubArtillery picks a random row for each virtual user and makes the fields available as template variables throughout the scenario. This prevents cache-hit skew where every user hits the same cached resource and your API looks faster than it actually is.
Checking Responses
By default Artillery fires requests and measures timing, but it doesn't fail the test on bad responses. Add expect blocks to assert on status codes and response bodies:
scenarios:
- name: "Product detail"
flow:
- get:
url: "/products/{{ productId }}"
expect:
- statusCode: 200
- hasProperty: "id"
- hasProperty: "price"Failed expectations are counted separately in the report. If you're getting 200 OK responses with empty bodies during load, you'll catch it here.
For more complex assertions — checking JSON paths, validating business logic — use capture to extract response values and pass them to subsequent requests:
scenarios:
- name: "Create and fetch order"
flow:
- post:
url: "/orders"
json:
productId: "{{ productId }}"
capture:
- json: "$.orderId"
as: orderId
- get:
url: "/orders/{{ orderId }}"
expect:
- statusCode: 200This creates an order in the first step, captures the returned orderId, then fetches that specific order to verify it exists. This is how you test write-then-read consistency under load.
WebSocket Testing
Artillery supports WebSocket and Socket.io natively — no plugins needed. Define an engine in the scenario:
config:
target: "wss://api.example.com"
scenarios:
- name: "Live updates subscriber"
engine: ws
flow:
- send: '{"type": "subscribe", "channel": "prices"}'
- think: 5
- send: '{"type": "unsubscribe", "channel": "prices"}'think adds a pause in seconds — use it to simulate realistic user behavior between actions. Without think time, virtual users hammer your server as fast as network latency allows, which is rarely what production traffic looks like.
For Socket.io:
scenarios:
- name: "Chat room"
engine: socketio
flow:
- emit:
channel: "join"
data: "room-42"
- think: 3
- emit:
channel: "message"
data: "Hello from Artillery"Custom Metrics with JavaScript
When the built-in metrics aren't enough, write a JavaScript plugin. Create a processor file:
// processor.js
module.exports = { trackCheckoutTime, measureSearchRelevance };
function trackCheckoutTime(requestParams, response, context, ee, next) {
const duration = response.timings.phases.total;
if (duration > 2000) {
ee.emit("counter", "checkout.slow", 1);
} else {
ee.emit("counter", "checkout.fast", 1);
}
// Custom histogram metric
ee.emit("histogram", "checkout.duration_ms", duration);
return next();
}
function measureSearchRelevance(requestParams, response, context, ee, next) {
const body = JSON.parse(response.body);
ee.emit("counter", `search.results_count.${Math.floor(body.total / 10) * 10}`, 1);
return next();
}Reference it in your YAML:
config:
target: "https://api.example.com"
processor: "./processor.js"
scenarios:
- name: "Checkout flow"
flow:
- post:
url: "/checkout"
json:
cartId: "{{ cartId }}"
afterResponse: trackCheckoutTimeCustom counters and histograms appear in the Artillery report alongside the default latency percentiles. This is how you correlate load with business metrics — not just "p99 was 800ms" but "800ms at 50 rps coincided with 40% of checkouts being slow".
Generating a JSON Report
The default terminal output is useful for quick checks. For CI and historical tracking, output a JSON report:
artillery run test.yaml --output report.json
artillery report report.jsonThe second command converts the JSON into an HTML report with charts. Commit the JSON to your repo or push it to S3 to track performance regressions over time.
Artillery vs k6
Both are solid load testing tools with active development and cloud execution options. The difference is mostly about workflow:
Artillery is YAML-first. You describe test structure in YAML and drop into JavaScript only when you need custom logic. Node.js teams get fast onboarding because the ecosystem is familiar — same runtime, same npm packages, same debugging tools.
k6 is JavaScript-first. You write test scripts in JS (with some Go-flavored APIs). It has excellent documentation and a strong open-source community. If your team lives in JS already, k6 scripts can feel more natural than YAML.
For Node.js teams testing Node.js APIs, Artillery tends to fit naturally. The YAML config is easy to diff in code review, the JavaScript extension API is straightforward, and the output format integrates well with existing Node tooling.
Running Artillery in CI
Add load tests to your pipeline to catch regressions before they reach production. A GitHub Actions example:
# .github/workflows/load-test.yml
name: Load Test
on:
push:
branches: [main]
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Artillery
run: npm install -g artillery@latest
- name: Run load test
run: artillery run tests/load/api.yaml --output load-report.json
env:
API_URL: ${{ secrets.STAGING_API_URL }}
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: load-test-report
path: load-report.jsonSet thresholds to fail the pipeline on regressions. Artillery supports ensure in the config block:
config:
target: "{{ $processEnvironment.API_URL }}"
ensure:
thresholds:
- http.response_time.p95: 500
- http.response_time.p99: 1000
conditions:
- expression: "return stats['http.request_rate'] > 40"
strict: trueIf p95 response time exceeds 500ms or p99 exceeds 1 second, Artillery exits with a non-zero code and the CI job fails.
Using $processEnvironment lets you pull the target URL from environment variables, so the same test file works against staging and production without modification.
What Load Testing Doesn't Cover
Artillery tells you how your API behaves under synthetic load. It won't tell you what happens at 3am when a specific database query starts returning stale results, or when a third-party dependency starts timing out intermittently.
After you've confirmed your API handles load — 200 rps sustained, p99 under 1 second, no error rate creep — you still need continuous monitoring in production. Load tests run at deploy time. Production runs all the time.
That's where HelpMeTest fits in. After Artillery confirms the API handles scale, set up 24/7 health checks that verify your critical endpoints are responding correctly — not just returning 200, but actually returning correct data. HelpMeTest runs health checks continuously, sends alerts when something breaks, and lets you write checks in plain English rather than scripting infrastructure. At $100/mo, it's the production monitoring layer that your load test suite can't replace.
Write the load test with Artillery. Monitor production with something that never sleeps.
Summary
Artillery load testing comes down to a few key pieces: define your target and phases in YAML, write scenarios that model real user behavior, use CSV payloads to avoid cache skew, assert on response content with expect blocks, and add custom metrics via JavaScript when you need them.
Start with a simple test against your most critical endpoint. Ramp to the load level you expect on a busy day, then push to twice that. Watch where latency climbs, where errors appear, and where memory grows. Fix those problems before users find them for you.
# Quick start
npm install -g artillery
artillery quick --count 50 --num 10 https://api.example.com/healthThat one-liner sends 10 requests from each of 50 virtual users and prints a latency summary. Not a full test, but a fast sanity check before you write the full YAML config.