🎯 Load Testing with Artillery
In the last lesson you learned the vocabulary of performance testing. Now you'll pick up one tool and use it end to end. Artillery lets you describe a realistic user journey in a few lines of YAML, generate hundreds of virtual users from it, and get back a percentile report that tells you whether your API is ready for a crowd.
Week 12 · Thursday: Performance Testing · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Install Artillery and confirm it runs
- Write a test script with a
configblock (phases) andscenariosblock (flows) - Shape traffic with
arrivalRateandrampToto model load, ramp, and spike patterns - Feed scenarios dynamic data — random values, captured response values, and CSV payloads
- Define SLA thresholds that fail the run when latency or errors cross the line
- Run a test, generate a report, and interpret p95/p99 and error rate
Estimated Time: 65 minutes
Practice: Build a two-step browse-and-buy scenario with thresholds and dynamic data.
In This Lesson
Why Artillery?
Artillery is a Node.js load-testing tool. Its signature idea is that a load test should read almost like plain English: you describe who the virtual users are and what they do in YAML, and Artillery turns that description into thousands of coordinated requests. When you need real logic — a random address, a computed token — you drop into ordinary JavaScript.
Think of Artillery as the conductor of an orchestra. Your YAML script is the score; each virtual user is a musician. The conductor decides when new players enter (the phases), what they play (the flows), and afterward hands you a review of the performance (the metrics). Your job is to write a score that sounds like real traffic.
📖 Artillery vs. k6
Both are excellent, modern, code-first load testers. Artillery leans on declarative YAML — lovely for readable multi-step journeys and quick to hand to a non-specialist. k6 is pure JavaScript on a faster Go engine — better when you want programmatic control or maximum load per machine. Learn Artillery here; the concepts (phases, thresholds, percentiles) transfer directly to k6.
Installing & First Run
Artillery is an npm package. Installing it as a project dev-dependency (rather than globally) keeps the version pinned in package.json, so every teammate and your CI runner use exactly the same one.
# Recommended: install into the project (version pinned for the team + CI)
mkdir load-testing && cd load-testing
npm init -y
npm install --save-dev artillery
# Confirm it works (npx runs the local copy)
npx artillery --version
Before writing a full script, you can fire a one-off smoke test straight from the command line — handy for confirming a target is reachable:
# 10 virtual users, each making 5 requests to the URL
npx artillery quick --count 10 --num 5 https://api.example.com/health
💡 Pin it, don't globalise it
A globally installed tool drifts: your laptop has 2.0.x, the CI box has 2.1.x, and a test passes in one place and fails in the other. A dev-dependency in package.json makes the version part of your repo, reproducible everywhere.
Anatomy of a Test Script
Every Artillery script has two top-level sections. Learn these two words and the rest is detail:
config— the setup: which server, and how the load ramps over time (the phases).scenarios— the behavior: the step-by-step journeys your virtual users perform (the flows).
# basic-test.yml
config:
target: "https://api.example.com" # base URL — flow paths are relative to it
phases:
- duration: 60 # for 60 seconds...
arrivalRate: 5 # ...5 brand-new virtual users arrive each second
name: "Warm up"
scenarios:
- name: "Browse the catalog"
flow:
- get:
url: "/products" # step 1: list products
- think: 2 # step 2: pause 2s, like a human reading
- get:
url: "/products/42" # step 3: view one product
Why think matters: real users pause between clicks. Without think time, your virtual users hammer the server in a tight loop that no real traffic resembles — you'd be measuring an artificial worst case. A second or two of think time between steps produces far more realistic numbers.
config decides the pressure; scenarios decide the behavior. Together they define the whole test.Phases: Shaping the Load
A phase controls how many virtual users arrive and when. Two keys do most of the work: arrivalRate (new users per second) and the optional rampTo (climb smoothly to this rate over the phase's duration). Chain phases together to build any traffic shape from the last lesson.
config:
target: "https://api.example.com"
phases:
# 1) Warm up: gently reach the base load so caches and pools fill
- duration: 30
arrivalRate: 5
name: "Warm up"
# 2) Ramp: climb from 10 to 50 users/sec over two minutes (a load test)
- duration: 120
arrivalRate: 10
rampTo: 50
name: "Ramp to peak"
# 3) Sustain: hold peak load to see steady-state behavior
- duration: 300
arrivalRate: 50
name: "Sustained peak"
Want a spike test instead? Sandwich a brief, high-rate phase between two calm ones:
phases:
- { duration: 60, arrivalRate: 5, name: "Calm before" }
- { duration: 30, arrivalRate: 100, name: "Spike!" }
- { duration: 120, arrivalRate: 5, name: "Recovery" }
✅ Always warm up first
Cold caches, empty connection pools, and un-JIT'd code make the first seconds of any test artificially slow. A short warm-up phase lets the system reach steady state before you start measuring the numbers that count — otherwise your p95 is polluted by startup costs, not real behavior.
Dynamic Data
If every virtual user requests /products/42, you're really testing your cache, not your database. Realistic load needs variety. Artillery offers three levels of it.
1. Built-in random helpers
Inline template functions generate values on the fly — no setup required.
scenarios:
- name: "Varied browsing"
flow:
# A different product id each time avoids cache-only hits
- get:
url: "/products/{{ $randomNumber(1, 500) }}"
- post:
url: "/users"
json:
username: "user_{{ $randomString(8) }}"
email: "user_{{ $randomString(8) }}@example.com"
2. Capture a value, reuse it
Real journeys are stateful: list products, grab an id from the response, then act on that id. The capture key pulls a value out of one response into a variable for later steps.
flow:
- get:
url: "/products"
capture:
- json: "$.products[0].id" # JSONPath into the response body
as: "productId" # save it under this name
- think: 2
# Reuse the captured id in the next request
- get:
url: "/products/{{ productId }}/reviews"
3. Drive users from a CSV file
To log in as many different real accounts, point Artillery at a CSV. Each virtual user picks up a row and its columns become variables.
config:
target: "https://api.example.com"
phases:
- { duration: 60, arrivalRate: 10 }
payload:
path: "users.csv" # file with a header row
fields:
- "username"
- "password"
scenarios:
- name: "Log in as many users"
flow:
- post:
url: "/login"
json:
username: "{{ username }}"
password: "{{ password }}"
⚠️ Never load-test with real credentials or real cards
Test data must be exactly that — test data. Point tests at a staging environment seeded with disposable accounts. Do not put production passwords, real customer emails, or genuine payment details into a CSV that lives in your repo. This is a security and privacy line, not just a style preference.
Thresholds & SLAs
A test that only prints numbers is a report. A test that fails the build when numbers cross a line is a guardrail. This is where load testing earns its place in CI: encode your Service Level Agreement as thresholds, and Artillery exits non-zero the moment reality misses the target.
config:
target: "https://api.example.com"
phases:
- { duration: 120, arrivalRate: 20, name: "Load" }
ensure:
thresholds:
# 95% of requests must complete under 300 ms
- http.response_time.p95: 300
# 99% of requests must complete under 800 ms
- http.response_time.p99: 800
# And the overall error rate must stay tiny
conditions:
- expression: "http.request_rate > 0"
Pair thresholds with the expect plugin to assert on correctness under load too — a fast 500 error is still a failure:
config:
plugins:
expect: {}
scenarios:
- name: "Validated request"
flow:
- get:
url: "/users/1"
expect:
- statusCode: 200
- contentType: "application/json"
💡 Where do SLA numbers come from?
Don't invent them. Base thresholds on product reality: what response time keeps users from bouncing, what your current production p95 is, what a competitor delivers. A common starting point for an API is "p95 under 300 ms, p99 under 1 s, error rate under 1%," then tighten as you improve. The point is to make regressions loud — if a change pushes p99 from 800 ms to 1500 ms, the build should turn red before it merges.
Running & Reading Results
Run a script, and optionally save a machine-readable report you can turn into HTML or feed to a dashboard.
# Run the test
npx artillery run ecommerce-test.yml
# Run and save a JSON report, then render an HTML view of it
npx artillery run --output report.json ecommerce-test.yml
npx artillery report report.json
Artillery streams a summary as it runs. The numbers to read first are exactly the four from the previous lesson:
Output — a run summary (trimmed)
http.requests ............................ 12000 ← total requests sent
http.request_rate ........................ 198/sec ← throughput (RPS)
http.response_time:
min .................................... 12
median (p50) ........................... 47
p95 .................................... 260
p99 .................................... 910 ← the tail — watch this
http.codes.200 ........................... 11940
http.codes.500 ........................... 60 ← 0.5% error rate
vusers.failed ............................ 60
How to read it: throughput of ~198 RPS is your capacity at this load. The median (47 ms) says the typical user is happy. The p99 of 910 ms is the real story — one in a hundred requests is nearly a second, and 60 requests returned 500s. If your SLA said "p99 under 800 ms," this run fails, and the 500s point at something breaking under pressure (an exhausted connection pool? a slow query surfacing under concurrency?). That failing number is your cue for the next lesson: optimization.
⚠️ A rising error rate usually precedes total collapse
Watch how metrics move across phases, not just the final totals. If errors are near zero during warm-up but climb steadily as load ramps, you've found your capacity ceiling — the traffic level where the system stops coping. That inflection point is often more valuable than any single average.
Practice & Quiz
🏋️ Exercise 1: Build a browse-and-buy scenario
Goal: Write an Artillery script with a warm-up phase and a load phase, plus one scenario that lists products, captures the first product's id, and posts it to the cart. Add a p95 threshold of 400 ms.
💡 Hint
You'll need two phases entries under config, a capture with a JSONPath like $.products[0].id, a {{ }} reference to reuse it, and an ensure.thresholds block.
✅ Solution
config:
target: "https://api.example.com"
phases:
- { duration: 30, arrivalRate: 5, name: "Warm up" }
- { duration: 120, arrivalRate: 20, name: "Load" }
ensure:
thresholds:
- http.response_time.p95: 400
scenarios:
- name: "Browse and add to cart"
flow:
- get:
url: "/products"
capture:
- json: "$.products[0].id"
as: "productId"
- think: 2
- post:
url: "/cart"
json:
productId: "{{ productId }}"
quantity: 1
🏋️ Exercise 2: Turn a load test into a spike test
Goal: Given a steady phase of arrivalRate: 10 for 300 seconds, rewrite the phases to model a spike: calm, sudden surge, recovery.
✅ Solution
phases:
- { duration: 60, arrivalRate: 10, name: "Calm" }
- { duration: 20, arrivalRate: 120, name: "Spike" }
- { duration: 120, arrivalRate: 10, name: "Recovery" }
The short high-arrivalRate middle phase is the surge; the trailing calm phase reveals whether the system recovers or stays degraded.
🎯 Quick Quiz
Question 1: In an Artillery config phase, what does arrivalRate: 20 mean?
Question 2: Why add think steps to a flow?
Question 3: What does an ensure.thresholds block let a load test do in CI?
Best Practices & Pitfalls
✅ Do
- Start with a warm-up phase so cold-start costs don't pollute your metrics
- Use dynamic data (random values, captures, CSVs) so you test more than the cache
- Include realistic think time between steps
- Encode your SLA as thresholds so regressions fail the build
- Keep scripts in the repo and run them in CI against a staging environment
❌ Don't
- Load-test production without explicit permission — you can cause a real outage
- Put real credentials or payment details in test files
- Hit the same URL every time and mistake cache hits for real performance
- Trust results when your own laptop (the load generator) is maxed out
📖 From metrics to a fix
A failing threshold isn't the end — it's a lead. A blown p99 with a cluster of 500s under load usually points at a specific bottleneck: an unindexed query, an N+1 pattern, a missing cache, or a saturated connection pool. Chasing those down is exactly what the next lesson is about.
Summary
🎉 Key Takeaways
- An Artillery script has two halves:
config(phases = the load shape) andscenarios(flows = user behavior) arrivalRateandrampTobuild load, ramp, and spike patterns;thinkmakes traffic realistic- Feed variety with random helpers, response captures, and CSV payloads — never test only the cache
- Thresholds turn a test into a CI guardrail that fails the build when the SLA is missed
- Read results as throughput, p95, p99, and error rate — and watch how they move across phases
📚 Additional Resources
- Artillery — Official documentation
- Artillery — Test script reference
- k6 — Docs (the JavaScript alternative)
- MDN — Web Performance guide
🚀 What's Next?
You can now generate load and read the report that comes back. When that report shows a slow tail or rising errors, what do you actually do about it? Next: Performance Optimization — measuring first, finding the real bottleneck, and fixing it with caching, indexes, pagination, and non-blocking code.
🎉 Nicely conducted!
You've written a load test from scratch — phases, flows, dynamic data, and an SLA. That's a skill teams pay real money for.