Appearance
Performance benchmarks
We benchmark real Magento workloads so you can size your store with confidence — no guesswork, no vague "blazing fast" claims. This page shows the numbers, the exact conditions, and the method, so you can judge how your own store compares.
All benchmarks on this page were performed on the Business plan.
Business plan — at a glance
| Business plan | |
|---|---|
| Shoppers on your store at the same time | ≈ 175–180, with pages in about half a second |
| Orders per minute at that traffic | ≈ 9 on a normal day, ≈ 21 on a sale day |
| Checkout capacity (orders only) | ≈ 6.7 orders per second (≈ 400 a minute, ≈ 24,000 an hour) |
| Order placement time | under 0.5 s for 95% of orders below the checkout limit |
| Full reindex of a 250,000-product catalogue | ≈ 13.5 minutes |
Plan resources: 8 vCPU, 32 GB RAM, 75 GB storage.
On a small catalogue (800 products) the same plan serves ≈ 570 shoppers at once and ≈ 13.7 orders per second — see Business plan — small shop.
The store we tested
- Magento Open Source 2.4.9, production mode, full-page cache on, indexers on "Update by Schedule", a message broker enabled.
- A large catalogue: 252,440 products (250,000 generated with the Magento Performance Toolkit plus the Luma sample catalogue), 1,040 categories, one store view, 200 customer accounts.
- Our Sales Assistant extension installed, as on a typical store.
- Another store running on the same server — like real hosting, not an empty machine.
- Traffic sent over the internet to the store's public address, through the same edge network as real shoppers, from a separate load-generating machine that was never the bottleneck.
- Measured outside the hourly backup window. While a backup runs (a few minutes each hour), peak checkout capacity is up to about 20% lower.
Catalogue size matters most for reindexing and for pages that aren't cached yet. With 250,000 products, most product pages a shopper opens are rarely visited, so many page views are rendered fresh by Magento rather than served from cache — a much harder test than a small catalogue where nearly everything is cached.
1. Shoppers at the same time (mixed traffic)
This is the number that answers "how busy can my store get?". Shopping sessions arrive at a steady rate, and each one behaves like a real visitor:
- opens the home page and a category, views 1–3 products, pausing 3–10 seconds between clicks;
- 20% of shoppers also search;
- some add items to the cart and leave; some check out.
A browser also makes small background requests on every page (the mini cart, the customer's name in the header); the test makes those too.
Normal day — 3% of sessions buy, 7% abandon a cart:
| New shoppers per second | Shoppers on the store at once | Orders / minute | Page (95%) | Order placed (95%) | Errors |
|---|---|---|---|---|---|
| 3 | ≈ 86 | ≈ 5 | 0.33 s | 0.38 s | 0 |
| 4 | ≈ 116 | ≈ 7 | 0.36 s | 0.46 s | 0 |
| 5 | ≈ 145 | ≈ 8 | 0.40 s | 0.81 s | 0 |
| 6 | ≈ 177 | ≈ 9 | 0.54 s | 0.98 s | < 1% |
| 8 | ≈ 243 | ≈ 10 | 0.94 s | 1.14 s | 2% — over capacity |
Sale day — 8% of sessions buy, 10% abandon a cart:
| New shoppers per second | Shoppers on the store at once | Orders / minute | Page (95%) | Order placed (95%) | Errors |
|---|---|---|---|---|---|
| 4 | ≈ 118 | ≈ 14 | 0.32 s | 0.44 s | 0 |
| 5 | ≈ 147 | ≈ 17 | 0.36 s | 0.55 s | 0 |
| 6 | ≈ 180 | ≈ 21 | 0.50 s | 1.01 s | 0 |
| 8 | ≈ 251 | ≈ 23 | 1.10 s | 1.31 s | 6% — over capacity |
"Shoppers on the store at once" is the arrival rate times the average session length (about 30 seconds of clicking and reading). A level passes when pages stay under 1 second, background requests and orders under 3 seconds, and errors under 1%.
What happens past capacity: instead of letting every request slow down, the store answers a small share of requests with a short "busy, please retry" — so shoppers already checking out keep a fast store. That is the error percentage above 6 new shoppers per second.
2. Checkout capacity (orders only)
The maximum rate of order placement, with every simulated shopper doing nothing but placing orders back to back — no browsing, no pauses. Each order picks a random product from the 250,000, the way real orders spread across a catalogue.
| Shoppers checking out simultaneously | Orders / second | Order placed (95%) | Errors |
|---|---|---|---|
| 4 | 4.2 | 0.38 s | 0 |
| 6 | 5.6 | 0.43 s | 0 |
| 8 | 6.8 | 0.46 s | 0 |
| 10 | 6.8 | 0.61 s | 0 |
Checkout tops out at ≈ 6.7 orders per second (≈ 400 a minute). More simultaneous checkouts beyond that don't add orders — they only make each one a little slower.
3. Browsing and search on their own
The same back-to-back, no-pause load for browsing pages only and for searches only — useful to see where each limit sits. A "visitor" here never pauses, so these counts are far below the number of real shoppers the store serves (see section 1).
Browsing (home, category and product pages across the whole catalogue, plus each page's background requests):
| Simultaneous visitors (no pauses) | Requests / second | Page (95%) | Errors |
|---|---|---|---|
| 5 | 39 | 0.38 s | 0 |
| 10 | 56 | 0.55 s | 0 |
| 15 | 57 | 0.85 s | 0 |
| 20 | 58 | 1.05 s | 0 |
Search — 80% of searches use popular terms, which Magento caches; 20% are rare terms that are always computed fresh:
| Simultaneous searchers (no pauses) | Searches / second | Search (95%) | Errors |
|---|---|---|---|
| 5 | 43 | 0.37 s | 0 |
| 10 | 59 | 0.55 s | 0 |
| 15 | 60 | 0.88 s | 0 |
| 20 | 59 | 1.22 s | 0 |
4. Maintenance timings on a 250,000-product store
| Task | Time |
|---|---|
| Full reindex, all indexers | ≈ 13.5 minutes |
| …of which the catalogue search index | ≈ 11 minutes |
| Category, price and attribute indexes | ≈ 40 s each or less |
A full reindex is rarely needed — with indexers on "Update by Schedule", product changes are indexed incrementally within a minute or so. Each additional store view adds roughly another catalogue-search pass.
What can make your store slower than these numbers
These figures come from a clean Magento store with a large catalogue. Your store may reach its limits sooner if it has:
- heavy extensions or a complex theme — every extra block or observer adds work to each uncached page;
- calls to outside services during page loads or checkout — payment gateways, tax, shipping-rate or ERP lookups add their own response time to every order;
- many store views or currencies — more pages to cache and more to reindex;
- frequent catalogue or stock updates — each change clears cached pages that then have to be rebuilt.
If your traffic approaches your plan's figures, moving up a plan raises the ceiling, and you can change plans at any time.
The benchmark logic, step by step
Everything above comes from the same procedure, so you can judge — or repeat — exactly what was measured.
1. Prepare the store. Generate the catalogue with the Magento Performance Toolkit (bin/magento setup:performance:generate-fixtures <profile>.xml, a profile with 250,000 simple products and 1,000 categories), set every indexer to "Update by Schedule", run a full reindex, turn order emails off, and export the list of in-stock, visible products and the category URLs (query below). The load scripts only request pages that really exist.
2. Generate load from a separate machine. The load generator runs on its own machine near the store, never on the store's server, and sends traffic to the store's public address. Its own CPU is recorded for every run; a run where the generator itself was busy would be discarded (it never went above a few percent).
3. Behave like a browser, not like a crawler. Every page view requests the page plus the background request a Magento 2.4.8+ browser makes on every page (the customer/mini-cart block), and, for visitors with something in the cart, the cart-section refresh. Images, scripts and styles are not requested — browsers and content networks cache those.
4. Spread visits across the whole catalogue. Products and categories are picked with a realistic skew (a Zipf-like distribution): a few popular items get most views and a long tail gets the rest. With 250,000 products many visited pages are not in the cache yet, so Magento renders them — a small test catalogue would measure only the cache.
5. Search like real shoppers. 80% of searches use 50 popular terms (Magento caches results for popular searches), 20% are rare one-off terms that are always computed fresh.
6. Four workloads, each measured on its own run:
| Workload | What each simulated user does | Load model |
|---|---|---|
| Mixed shoppers | home → category → (search) → 1–3 products, pausing 3–10 s between clicks; some add to cart and leave, some check out | arrival rate: new sessions start every second at a fixed rate, however slow the store gets |
| Checkout | create cart → add a random in-stock product → set shipping → place order, back to back | fixed number of simultaneous users, no pauses |
| Browsing | home → category → product → product (with background requests), back to back | fixed number of simultaneous users, no pauses |
| Search | one search after another | fixed number of simultaneous users, no pauses |
The mixed test uses an arrival-rate model on purpose: with a fixed number of users, a slow store receives fewer requests and hides its slowness; with arrivals on a schedule, overload shows up as slow pages and errors.
Session mix in the mixed test:
| Profile | Browse only | Browse + search | Add to cart, then leave | Check out |
|---|---|---|---|---|
| Normal day | 70% | 20% | 7% | 3% |
| Sale day | 62% | 20% | 10% | 8% |
7. Run each level the same way. A 30-second warm-up at the level, then the measurement — 2 minutes for the isolated tests, a 1-minute ramp plus 3 minutes for mixed shoppers — repeated 3 times; the published number is the median. Levels were chosen after a short scouting pass that found where the plan saturates.
8. Count shoppers honestly. "Shoppers on the store at once" = new sessions per second × average session length (Little's law). With ~30-second sessions, 6 new shoppers per second means about 180 people mid-visit at any moment.
9. Decide pass or fail. A level passes only if all hold: cached pages under 1 s for 95% of views; background requests, searches and order placement under 3 s for 95%; fewer than 1% errors. The headline is the highest level that passes.
10. Keep the conditions clean. Results are taken outside the store's hourly backup window (a repetition that overlapped a backup was re-run), and with order emails off.
Business plan — small shop
The same plan, the same method and the same kind of server as the large-catalogue test above, on a small catalogue: 800 products in 30 categories (the Magento Performance Toolkit's "small" profile). This time no other store was running on the server. Results are the median of three repetitions per level.
With so few products, every page is in the full-page cache (pages answer in about 0.04 s at every level). What limits the store is the work that can't be cached: the background requests every page makes (mini cart, the customer's name), searches for rare terms, and placing orders.
Shoppers at the same time — normal day (3% of sessions buy, 7% abandon a cart):
| New shoppers per second | Shoppers on the store at once | Orders / minute | Background requests (95%) | Order placed (95%) | Errors |
|---|---|---|---|---|---|
| 12 | ≈ 340 | ≈ 19 | 0.09 s | 0.24 s | 0 |
| 16 | ≈ 454 | ≈ 22 | 0.13 s | 0.30 s | 0 |
| 20 | ≈ 570 | ≈ 28 | 0.23 s | 0.57 s | < 1% |
| 24 | ≈ 695 | ≈ 28 | 0.33 s | 0.68 s | 2% — over capacity |
Shoppers at the same time — sale day (8% of sessions buy, 10% abandon a cart):
| New shoppers per second | Shoppers on the store at once | Orders / minute | Background requests (95%) | Order placed (95%) | Errors |
|---|---|---|---|---|---|
| 12 | ≈ 346 | ≈ 44 | 0.10 s | 0.26 s | 0 |
| 16 | ≈ 463 | ≈ 55 | 0.16 s | 0.39 s | < 1% |
| 20 | ≈ 591 | ≈ 62 | 0.34 s | 0.66 s | 2% — over capacity |
| 24 | ≈ 709 | ≈ 57 | 0.35 s | 0.67 s | 6% — over capacity |
A sale day reaches the limit sooner because more shoppers place orders, and placing an order is never cached.
Checkout capacity (orders only):
| Shoppers checking out simultaneously | Orders / second | Order placed (95%) | Errors |
|---|---|---|---|
| 5 | 9.3 | 0.19 s | 0 |
| 10 | 13.7 | 0.28 s | 0 |
| 15 | 12.7 | 0.48 s | 0 |
| 20 | 12.7 | 0.63 s | 0 |
Checkout tops out at ≈ 13.7 orders per second (≈ 820 a minute) — about twice the large catalogue's rate, because each order touches far less catalogue data.
Browsing and search on their own (back-to-back, no pauses):
| Simultaneous visitors | Requests / second | Background requests (95%) | Errors |
|---|---|---|---|
| 10 | 161 | 0.10 s | 0 |
| 15 | 205 | 0.14 s | 0 |
| 20 | 235 | 0.17 s | 0 |
| 30 | 236 | 0.27 s | 0 |
| Simultaneous searchers | Searches / second | Search (95%) | Errors |
|---|---|---|---|
| 10 | 55 | 0.28 s | 0 |
| 15 | 57 | 0.42 s | 0 |
| 20 | 55 | 0.61 s | 0 |
| 30 | 56 | 0.86 s | 0 |
Search levels off at about 56 per second: popular terms come from the cache, and the rare terms that must be computed fresh set the limit.
Small vs large catalogue, same plan: about 570 vs 177 shoppers at once, and 13.7 vs 6.7 orders per second. Most real stores sit somewhere in between — the more of your catalogue shoppers actually visit, the closer you are to the large-catalogue figures.
Run it on your own store — the complete scripts
These are the exact scripts behind the numbers on this page. Save them in one folder, install k6, and:
- Turn order emails off (warning below).
- Export your catalogue to
catalog.jsonwith the query below (mysql -N -B -r <database> -e "<query>" > catalog.json—-rkeeps the JSON unescaped). The query takes in-stock products with a quantity above 1,000, so the test never sells a product out; on a real store, lower that number to fit your stock (or test on a staging copy). - Run one workload at a time (
TAGnames the results file), for example:
export BASE_URL=https://your-store CATALOG=catalog.json TAG=my-store
./run.sh mixed 2 4 6
./run.sh checkout 4 6 8
MODE=browse ./run.sh browse 5 10 20
MODE=search ./run.sh browse 5 10 20run.sh (needs python3) runs a warm-up and three repetitions per level, writes every run to results/, and prints the medians. Start low: a load test against a live store affects real shoppers, so test a staging copy or a quiet hour.
Turn off order emails before load-testing checkout
Every order placed sends an order-confirmation email. Before running the checkout or mixed-shopper test, turn off the sales emails (Stores → Configuration → Sales → Sales Emails) or set EMAIL to a test inbox you control. Otherwise you'll send one email per order — potentially thousands — which can damage your store's email deliverability.
The checkout and mixed tests need guest checkout, a flat-rate shipping method and the "Check / Money order" payment method enabled (all Magento defaults). Adjust REGION, COUNTRY and POSTCODE in checkout.js if your store doesn't ship to the default US address.
Catalogue export query (catalog.json)
sql
SET SESSION group_concat_max_len = 67108864;
SELECT JSON_OBJECT(
'products', (SELECT JSON_ARRAYAGG(JSON_OBJECT('id', p.id, 'sku', p.sku, 'url', p.url)) FROM (
SELECT DISTINCT e.entity_id id, e.sku, u.request_path url FROM catalog_product_entity e
JOIN url_rewrite u ON u.entity_type='product' AND u.entity_id=e.entity_id AND u.metadata IS NULL AND u.redirect_type=0
JOIN cataloginventory_stock_item s ON s.product_id=e.entity_id AND s.is_in_stock=1 AND s.qty>1000
JOIN catalog_product_entity_int vis ON vis.entity_id=e.entity_id AND vis.store_id=0 AND vis.value IN (2,4)
AND vis.attribute_id=(SELECT attribute_id FROM eav_attribute WHERE attribute_code='visibility' AND entity_type_id=4)
JOIN catalog_product_entity_int st ON st.entity_id=e.entity_id AND st.store_id=0 AND st.value=1
AND st.attribute_id=(SELECT attribute_id FROM eav_attribute WHERE attribute_code='status' AND entity_type_id=4)
WHERE e.type_id='simple' AND u.store_id=1 ORDER BY e.entity_id) p),
'categories', (SELECT JSON_ARRAYAGG(JSON_OBJECT('id', c.id, 'url', c.url)) FROM (
SELECT DISTINCT entity_id id, request_path url FROM url_rewrite
WHERE entity_type='category' AND redirect_type=0 AND store_id=1 ORDER BY entity_id) c))run.sh — step runner (warm-up, 3 repetitions, medians)
bash
#!/bin/bash
# Step runner: for each load level,
# one warm-up run, then REPS repetitions; the median of each metric is reported.
# The generator's own CPU is recorded per run (a saturated generator invalidates
# the step).
#
# BASE_URL=https://<store> CATALOG=catalog.json TAG=my-store \
# ./run.sh browse 10 25 50 100 # levels = VUs (MODE=browse|search|mix)
# ./run.sh checkout 5 10 20 40 # levels = VUs
# ./run.sh mixed 1 2 4 8 # levels = sessions/s (PROFILE=normal|sale)
# Env: REPS (3), DURATION (2m; mixed: 3m), WARMUP (30s), OUTDIR (results/).
set -euo pipefail
W=$1; shift
: "${BASE_URL:?}" "${CATALOG:?}" "${TAG:?}"
REPS=${REPS:-3}; WARMUP=${WARMUP:-30s}; OUTDIR=${OUTDIR:-results}
mkdir -p "$OUTDIR"
case $W in browse|checkout) LV=VUS; DUR=${DURATION:-2m} ;; mixed) LV=RATE; DUR=${DURATION:-3m} ;; *) echo "workload: browse|checkout|mixed" >&2; exit 2 ;; esac
OUT="$OUTDIR/${TAG}_${W}${MODE:+_$MODE}${PROFILE:+_$PROFILE}.jsonl"
cpu() { if [ -r /proc/stat ]; then awk '/^cpu /{print $2+$3+$4, $5}' /proc/stat; else echo "0 0"; fi; }
for level in "$@"; do
echo "== $W level=$level warm-up $WARMUP" >&2
env "$LV=$level" DURATION="$WARMUP" k6 run -q "$W.js" >/dev/null 2>&1 || true
for rep in $(seq 1 "$REPS"); do
read -r b1 i1 < <(cpu)
tmp=$(mktemp)
env "$LV=$level" DURATION="$DUR" OUT="$tmp" k6 run -q "$W.js" >/dev/null 2>&1 || true
read -r b2 i2 < <(cpu)
gen_cpu=$(awk -v b=$((b2-b1)) -v i=$((i2-i1)) 'BEGIN{ if (b+i>0) printf "%.0f", 100*b/(b+i); else print "null" }')
python3 -c "import json,sys;d=json.load(open(sys.argv[1]));d.update(level=int(sys.argv[2]),rep=int(sys.argv[3]),generator_cpu_pct=None if sys.argv[4]=='null' else int(sys.argv[4]));print(json.dumps(d))" \
"$tmp" "$level" "$rep" "$gen_cpu" | tee -a "$OUT"
rm -f "$tmp"
done
done
python3 - "$OUT" <<'PY'
import json, sys, statistics
rows = [json.loads(l) for l in open(sys.argv[1]) if l.strip()]
by = {}
for r in rows: by.setdefault(r['level'], []).append(r)
print(f"\nmedians ({sys.argv[1]}):")
for lvl in sorted(by):
rs = by[lvl]; keys = [k for k, v in rs[0].items() if isinstance(v, (int, float)) and not isinstance(v, bool) and k not in ('level', 'rep')]
med = {k: statistics.median([r[k] for r in rs if r.get(k) is not None]) for k in keys if any(r.get(k) is not None for r in rs)}
if 'pass' in rs[0]: med['pass_all_reps'] = all(r['pass'] for r in rs)
print(json.dumps({'level': lvl, 'reps': len(rs), **med}))
PYlib.js — shared: catalogue sampling, browser-equivalent page view, search terms
javascript
// Shared pieces for the benchmark scripts:
// realistic catalogue sampling, browser-equivalent page views, head/tail search.
//
// CATALOG=<path to catalog.json> is required (see the export query on this page).
import http from 'k6/http';
import { check } from 'k6';
import { Trend, Counter, Rate } from 'k6/metrics';
import { SharedArray } from 'k6/data';
export const BASE = (__ENV.BASE_URL || '').replace(/\/+$/, '');
// One SharedArray per list: k6 hands each virtual user a copy of every element it
// reads, so the catalogue must not be one big element (a large catalogue would be
// copied into every virtual user).
export const products = new SharedArray('products', () => JSON.parse(open(__ENV.CATALOG)).products); // [{id, sku, url}]
export const categories = new SharedArray('categories', () => JSON.parse(open(__ENV.CATALOG)).categories); // [{id, url}]
// Zipf-like skew (s≈1): a few items get most traffic, a long tail gets the rest.
// Index i is drawn with weight ≈1/(i+1) via the closed-form inverse of the
// continuous 1/x distribution (no per-VU weight table); the order is the
// catalogue order, so the "popular" items are fixed for the whole run.
function zipfPick(arr) { const n = arr.length; const i = Math.floor(Math.exp(Math.random() * Math.log(n + 1))) - 1; return arr[Math.min(Math.max(i, 0), n - 1)]; }
export const pickProduct = () => zipfPick(products);
export const pickCategory = () => zipfPick(categories);
export const pickUniform = (a) => a[Math.floor(Math.random() * a.length)];
// Search: 50 popular terms carry ~80 % of searches (they become Magento
// "popular" queries → cacheable), the rest are rare generated terms (never cached).
const HEAD = ['shirt', 'pants', 'jacket', 'bag', 'watch', 'shoes', 'hoodie', 'tee', 'shorts', 'bra',
'yoga', 'running', 'gear', 'fitness', 'women', 'men', 'sale', 'new', 'black', 'blue',
'red', 'green', 'white', 'gray', 'small', 'large', 'cotton', 'sport', 'training', 'outdoor',
'product', 'simple', 'dynamic', 'item', 'set', 'pack', 'kit', 'bottle', 'mat', 'ball',
'top', 'tank', 'jogger', 'legging', 'sweater', 'coat', 'cap', 'sock', 'glove', 'belt'];
export function searchTerm() {
if (Math.random() < 0.8) return HEAD[Math.floor(Math.random() * HEAD.length)];
return `q${Math.random().toString(36).slice(2, 9)}`;
}
export const metrics = {
page: new Trend('page_ms', true), // cacheable HTML pages
search: new Trend('search_ms', true),
block: new Trend('block_render_ms', true), // /page_cache/block/render (uncacheable)
section: new Trend('section_load_ms', true), // /customer/section/load (uncacheable)
hit: new Rate('cache_hit'), // X-Magento-Cache-Debug: HIT among cacheable pages
errors: new Counter('page_errors'),
};
// The two private-content calls are jQuery AJAX in a real browser — Magento 404s
// /page_cache/block/render without X-Requested-With.
const XHR = { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json, text/javascript, */*; q=0.01' };
function get(url, trend, tag, jar, headers) {
const r = http.get(url, { tags: { name: tag }, timeout: '20s', jar, headers });
trend.add(r.timings.duration);
if (!check(r, { [`${tag} 200`]: (x) => x.status === 200 })) metrics.errors.add(1);
return r;
}
function cacheDebug(r) {
const h = r.headers['X-Magento-Cache-Debug'] || r.headers['x-magento-cache-debug'];
if (h === 'HIT' || h === 'MISS') metrics.hit.add(h === 'HIT');
}
// One page view the way a browser does it on 2.4.8+: the HTML, then the private
// customer block render (magento/magento2#40846), plus customer/section/load when
// the session holds a cart. Static assets are not fetched (CDN/browser cache).
export function pageView(kind, item, opts = {}) {
let path, handles, req, idPart = '';
if (kind === 'home') {
path = '/'; handles = ['default', 'cms_index_index', 'cms_page_view', 'cms_index_index_id_home'];
req = { route: 'cms', controller: 'index', action: 'index', uri: '/' };
} else if (kind === 'category') {
path = `/${item.url}`; idPart = `id/${item.id}/`;
handles = ['default', 'catalog_category_view', 'catalog_category_view_type_default',
'catalog_category_view_displaymode_page', `catalog_category_view_id_${item.id}`];
req = { route: 'catalog', controller: 'category', action: 'view', uri: path };
} else {
path = `/${item.url}`; idPart = `id/${item.id}/`;
handles = ['default', 'catalog_product_view', 'catalog_product_view_type_simple',
`catalog_product_view_id_${item.id}`, `catalog_product_view_sku_${item.sku}`];
req = { route: 'catalog', controller: 'product', action: 'view', uri: path };
}
const r = get(`${BASE}${path}`, metrics.page, kind, opts.jar);
cacheDebug(r);
const q = `blocks=${encodeURIComponent('["customer","customer"]')}&handles=${encodeURIComponent(JSON.stringify(handles))}`
+ `&originalRequest=${encodeURIComponent(JSON.stringify(req))}&version=${Date.now()}`;
get(`${BASE}/page_cache/block/render/${idPart}?${q}`, metrics.block, 'block_render', opts.jar, XHR);
if (opts.hasCart) {
get(`${BASE}/customer/section/load/?sections=cart%2Cmessages&force_new_section_timestamp=false&_=${Date.now()}`,
metrics.section, 'section_load', opts.jar, XHR);
}
return r;
}
export function search(opts = {}) {
const r = get(`${BASE}/catalogsearch/result/?q=${encodeURIComponent(searchTerm())}`, metrics.search, 'search', opts.jar);
cacheDebug(r);
return r;
}
export const round = (v) => (v == null ? null : Math.round(v * 100) / 100);
export function val(data, n, s) { const m = data.metrics[n]; return m && m.values && m.values[s] != null ? round(m.values[s]) : null; }mixed.js — mixed shoppers (arrival rate, sessions, pass criteria)
javascript
// Mixed-shopper benchmark.
// OPEN model: shopping SESSIONS arrive at a set rate regardless of how slow the
// store gets (ramping-arrival-rate — closed-loop VUs self-throttle and hide
// saturation). Each session is a scripted journey with 3–10 s think time:
// PROFILE=normal ~70 % browse · ~20 % browse+search · ~7 % cart→abandon · ~3 % checkout
// PROFILE=sale ~62 % browse · ~20 % browse+search · ~10 % cart→abandon · ~8 % checkout
// Concurrent shoppers = arrival rate × mean session duration (Little's law; the
// summary reports both). Pass = cached page p95 < 1 s, uncached (search / block
// render / section load) p95 < 3 s, place-order p95 < 3 s, errors < 1 %.
//
// BASE_URL=https://<store> CATALOG=catalog.json RATE=2 DURATION=3m PROFILE=normal k6 run mixed.js
// (RATE = new sessions per second, held for DURATION after a 1-minute ramp)
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';
import { BASE, products, pageView, search, pickProduct, pickCategory, val, metrics } from './lib.js';
const RATE = Number(__ENV.RATE || 1);
const PROFILE = __ENV.PROFILE || 'normal';
const MIX = PROFILE === 'sale' ? [0.62, 0.20, 0.10, 0.08] : [0.70, 0.20, 0.07, 0.03];
const EMAIL = __ENV.EMAIL || '[email protected]'; // turn sales emails off first
const placeOrderMs = new Trend('place_order_ms', true);
const sessionS = new Trend('session_s');
const orders = new Counter('orders_placed');
const orderFail = new Counter('orders_failed');
export const options = {
scenarios: {
shoppers: {
executor: 'ramping-arrival-rate', startRate: 0, timeUnit: '1s',
preAllocatedVUs: Math.max(50, RATE * 60), maxVUs: Math.max(200, RATE * 200),
stages: [{ duration: '1m', target: RATE }, { duration: __ENV.DURATION || '3m', target: RATE }],
},
},
thresholds: {
'page_ms': ['p(95)<1000'], 'search_ms': ['p(95)<3000'], 'block_render_ms': ['p(95)<3000'],
'section_load_ms': ['p(95)<3000'], 'place_order_ms': ['p(95)<3000'], http_req_failed: ['rate<0.01'],
},
};
const think = () => sleep(3 + Math.random() * 7);
const J = { headers: { 'Content-Type': 'application/json', Accept: 'application/json' } };
function guestCart(n) {
let r = http.post(`${BASE}/rest/V1/guest-carts`, null, J);
if (r.status !== 200) return null;
const cart = JSON.parse(r.body);
for (let i = 0; i < n; i++) {
r = http.post(`${BASE}/rest/V1/guest-carts/${cart}/items`,
JSON.stringify({ cartItem: { sku: pickProduct().sku, qty: 1, quote_id: cart } }), J);
if (r.status !== 200) return null;
}
return cart;
}
function placeOrder(cart) {
const addr = { email: EMAIL, firstname: 'Load', lastname: 'Test', street: ['1 Benchmark Way'], city: 'Testville',
region: 'California', region_id: 12, postcode: '90001', country_id: 'US', telephone: '5555550100' };
let r = http.post(`${BASE}/rest/V1/guest-carts/${cart}/shipping-information`, JSON.stringify({ addressInformation: {
shipping_address: addr, billing_address: addr, shipping_method_code: 'flatrate', shipping_carrier_code: 'flatrate' } }), J);
if (r.status !== 200) { orderFail.add(1); return; }
r = http.post(`${BASE}/rest/V1/guest-carts/${cart}/payment-information`,
JSON.stringify({ email: EMAIL, paymentMethod: { method: 'checkmo' }, billingAddress: addr }), J);
placeOrderMs.add(r.timings.duration);
if (check(r, { 'order placed': (x) => x.status === 200 })) orders.add(1); else orderFail.add(1);
}
export default function () {
const t0 = Date.now();
const x = Math.random();
const kind = x < MIX[0] ? 'browse' : x < MIX[0] + MIX[1] ? 'search' : x < MIX[0] + MIX[1] + MIX[2] ? 'abandon' : 'checkout';
const jar = http.cookieJar();
pageView('home', null, { jar }); think();
pageView('category', pickCategory(), { jar }); think();
if (kind === 'search') { search({ jar }); think(); }
const views = 1 + Math.floor(Math.random() * 3);
for (let i = 0; i < views; i++) { pageView('product', pickProduct(), { jar }); think(); }
if (kind === 'abandon' || kind === 'checkout') {
const cart = guestCart(1 + Math.floor(Math.random() * 2));
if (!cart) { metrics.errors.add(1); return; }
pageView('product', pickProduct(), { jar, hasCart: true }); think();
if (kind === 'checkout') placeOrder(cart);
}
sessionS.add((Date.now() - t0) / 1000);
}
export function handleSummary(data) {
const meanSession = val(data, 'session_s', 'avg');
const out = {
profile: PROFILE, sessions_per_s: RATE, mean_session_s: meanSession,
concurrent_shoppers: meanSession == null ? null : Math.round(RATE * meanSession),
orders_placed: val(data, 'orders_placed', 'count'), orders_failed: val(data, 'orders_failed', 'count'),
orders_per_min: val(data, 'orders_placed', 'rate') == null ? null : Math.round(val(data, 'orders_placed', 'rate') * 60 * 10) / 10,
page_p95: val(data, 'page_ms', 'p(95)'), search_p95: val(data, 'search_ms', 'p(95)'),
block_render_p95: val(data, 'block_render_ms', 'p(95)'), section_load_p95: val(data, 'section_load_ms', 'p(95)'),
place_order_p95: val(data, 'place_order_ms', 'p(95)'), err_rate: val(data, 'http_req_failed', 'rate'),
cache_hit: val(data, 'cache_hit', 'rate'),
pass: Object.values(data.metrics).every((m) => !m.thresholds || Object.values(m.thresholds).every((t) => t.ok)),
};
const res = { stdout: '\n=> ' + JSON.stringify(out) + '\n' };
if (__ENV.OUT) res[__ENV.OUT] = JSON.stringify(out) + '\n';
return res;
}checkout.js — order placement
javascript
// Concurrent-checkout benchmark for a Magento 2.4.9 CE store (guest-cart REST).
// create cart -> add item -> set shipping -> place order. Isolates order placement.
//
// Stepped/constant mode (for a capacity curve):
// BASE_URL=... CATALOG=catalog.json VUS=40 DURATION=2m THINK=0 OUT=/path/res.jsonl k6 run checkout.js
// Ramp mode (find the knee):
// BASE_URL=... CATALOG=catalog.json k6 run checkout.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';
import { SharedArray } from 'k6/data';
const BASE = (__ENV.BASE_URL || '').replace(/\/+$/, '');
// SKU: fixed via SKU=…, else a random in-stock simple per order from CATALOG
// (one fixed SKU = one hot stock row → measures row-lock contention, not checkout).
// SharedArray: parsed once and shared, not copied into every virtual user.
const SKUS = new SharedArray('skus', () => (__ENV.CATALOG ? JSON.parse(open(__ENV.CATALOG)).products.map((p) => p.sku) : []));
const pickSku = () => __ENV.SKU || (SKUS.length ? SKUS[Math.floor(Math.random() * SKUS.length)] : 'product_dynamic_1');
// SAFETY: every placed order sends an order-confirmation email — turn sales emails
// off before testing (see the warning on this page), or set EMAIL to an inbox you own.
const EMAIL = __ENV.EMAIL || '[email protected]';
const REGION = __ENV.REGION || 'California';
const REGION_ID = Number(__ENV.REGION_ID || 12);
const COUNTRY = __ENV.COUNTRY || 'US';
const POSTCODE = __ENV.POSTCODE || '90001';
const THINK = Number(__ENV.THINK || 0);
const VUS = __ENV.VUS ? Number(__ENV.VUS) : 0;
const ordersPlaced = new Counter('orders_placed');
const ordersFailed = new Counter('orders_failed');
const placeOrderMs = new Trend('place_order_ms', true);
const fullFlowMs = new Trend('full_flow_ms', true);
const thresholds = {
http_req_failed: ['rate<0.01'],
place_order_ms: ['p(95)<3000'],
};
export const options = VUS
? { scenarios: { checkout: { executor: 'constant-vus', vus: VUS, duration: __ENV.DURATION || '2m' } }, thresholds }
: {
scenarios: {
checkout: {
executor: 'ramping-vus', startVUs: 0,
stages: __ENV.STAGES ? JSON.parse(__ENV.STAGES) : [
{ duration: '30s', target: 20 },
{ duration: '45s', target: 50 },
{ duration: '45s', target: 100 },
{ duration: '45s', target: 200 },
{ duration: '20s', target: 0 },
],
gracefulRampDown: '15s',
},
},
thresholds,
};
function parse(res) { try { return JSON.parse(res.body); } catch (e) { return null; } }
const H = { headers: Object.assign(
{ 'Content-Type': 'application/json', Accept: 'application/json' },
__ENV.HOST_HEADER ? { Host: __ENV.HOST_HEADER } : {}) };
export default function () {
const flowStart = Date.now();
let r = http.post(`${BASE}/rest/V1/guest-carts`, null, H);
if (!check(r, { 'cart created': (x) => x.status === 200 })) { ordersFailed.add(1); return; }
const cartId = parse(r);
if (typeof cartId !== 'string') { ordersFailed.add(1); return; }
const email = EMAIL;
const addr = {
email, firstname: 'Load', lastname: 'Test',
street: ['1 Benchmark Way'], city: 'Testville',
region: REGION, region_id: REGION_ID,
postcode: POSTCODE, country_id: COUNTRY, telephone: '5555550100',
};
r = http.post(`${BASE}/rest/V1/guest-carts/${cartId}/items`,
JSON.stringify({ cartItem: { sku: pickSku(), qty: 1, quote_id: cartId } }), H);
if (!check(r, { 'item added': (x) => x.status === 200 })) { ordersFailed.add(1); return; }
r = http.post(`${BASE}/rest/V1/guest-carts/${cartId}/shipping-information`,
JSON.stringify({ addressInformation: {
shipping_address: addr, billing_address: addr,
shipping_method_code: 'flatrate', shipping_carrier_code: 'flatrate' } }), H);
if (!check(r, { 'shipping set': (x) => x.status === 200 })) { ordersFailed.add(1); return; }
const t0 = Date.now();
r = http.post(`${BASE}/rest/V1/guest-carts/${cartId}/payment-information`,
JSON.stringify({ email, paymentMethod: { method: 'checkmo' }, billingAddress: addr }), H);
placeOrderMs.add(Date.now() - t0);
if (check(r, { 'order placed': (x) => x.status === 200 })) {
ordersPlaced.add(1);
fullFlowMs.add(Date.now() - flowStart);
} else {
ordersFailed.add(1);
}
if (THINK) sleep(THINK);
}
export function handleSummary(data) {
const m = data.metrics;
const g = (n, s) => (m[n] && m[n].values && m[n].values[s] != null) ? Math.round(m[n].values[s] * 100) / 100 : null;
const out = {
vus: VUS || 'ramp', think: THINK,
orders_placed: g('orders_placed', 'count'),
orders_failed: g('orders_failed', 'count'),
orders_per_s: g('orders_placed', 'rate'),
http_req_failed_rate: g('http_req_failed', 'rate'),
place_avg_ms: g('place_order_ms', 'avg'),
place_p95_ms: g('place_order_ms', 'p(95)'),
flow_p95_ms: g('full_flow_ms', 'p(95)'),
http_p95_ms: g('http_req_duration', 'p(95)'),
};
const res = { stdout: '\n=> ' + JSON.stringify(out) + '\n' };
if (__ENV.OUT) res[__ENV.OUT] = JSON.stringify(out) + '\n';
return res;
}browse.js — browsing / search on their own
javascript
// Read benchmark (isolated workload):
// concurrent visitors browsing the FULL catalogue with a Zipf skew, every page
// view browser-equivalent (HTML + /page_cache/block/render), searches drawn from
// a popular head + rare tail. Reports the cache hit ratio.
//
// BASE_URL=https://<store> CATALOG=catalog.json VUS=100 DURATION=2m MODE=browse k6 run browse.js
//
// MODE=browse (pages only) | search (search only) | mix (default: home + category
// + 2 products + 1 search per iteration).
import { pageView, search, pickProduct, pickCategory, val } from './lib.js';
const VUS = Number(__ENV.VUS || 50);
export const options = {
scenarios: { browse: { executor: 'constant-vus', vus: VUS, duration: __ENV.DURATION || '2m' } },
thresholds: { http_req_failed: ['rate<0.01'] },
};
export default function () {
const mode = __ENV.MODE || 'mix';
if (mode === 'search') { search(); return; }
pageView('home');
pageView('category', pickCategory());
pageView('product', pickProduct());
pageView('product', pickProduct());
if (mode === 'mix') search();
}
export function handleSummary(data) {
const out = {
mode: __ENV.MODE || 'mix', vus: VUS,
iters: val(data, 'iterations', 'count'), req_per_s: val(data, 'http_reqs', 'rate'),
err_rate: val(data, 'http_req_failed', 'rate'),
page_p95: val(data, 'page_ms', 'p(95)'), block_render_p95: val(data, 'block_render_ms', 'p(95)'),
search_p95: val(data, 'search_ms', 'p(95)'), cache_hit: val(data, 'cache_hit', 'rate'),
overall_p95: val(data, 'http_req_duration', 'p(95)'),
};
const res = { stdout: '\n=> ' + JSON.stringify(out) + '\n' };
if (__ENV.OUT) res[__ENV.OUT] = JSON.stringify(out) + '\n';
return res;
}