500 Concurrent Users on WooCommerce: A Store API vs Classic Checkout Load Test
Two identical WooCommerce installs on the same Hetzner box. Same products, same PHP, same Redis.
One uses classic wc-ajax endpoints for cart and the shortcode checkout. The other uses the Store API REST layer for cart, coupons, and the block-based checkout. I hit both with k6 across the full purchase flow: add to cart, coupon operations, checkout, and frontend page loads under up to 500 concurrent users.
Classic wins on raw checkout speed. Store API wins on consistency under load. Neither won outright.
Why This Test Exists
This started with a client project. A high-traffic store was planning a rebuild, and the team split on whether to go all-in on blocks and the Store API or stay with the classic wc-ajax/shortcode stack.
WooCommerce has been pushing block checkout hard since version 8.3. It's the default for new installs in WC 10.x, and the classic shortcode may eventually be deprecated. Meanwhile, 87% of stores still run classic checkout. The migration pitch is better extensibility and a "modern" architecture, but nobody was publishing real load test numbers comparing the two approaches on the same hardware.
I rented a Hetzner CCX33 (~€48/month), stood up two identical WordPress instances with the same catalog and plugins, and ran the same k6 scripts against both. Every test ran at least 3 times. The numbers below are medians across runs.
WooCommerce 10.5+ ships several performance features disabled by default. I turned them all on: HPOS datastore caching, REST API caching, product instance caching, order attribution off. Without them, Store API was 9% slower across the board. With them, the gap narrowed to single digits.
Cart Operations: Store API Handles the Crowd Better
At one user, both handle add-to-cart in 80ms. At 50 concurrent users, still neck and neck. Past 200 VUs, Store API pulls ahead. Not by much on the median, but the tail tells a different story.
Both approaches load the WordPress stack. The difference is what happens after:
Place Order: Classic's Server Response Is Faster Under Load
Classic's Place Order endpoint responds faster at every load level I tested. By 22% at one user. By 127% at 500. This is the server-side response time for the final "submit" click only, not the full checkout experience (which favors Store API by ~8% due to faster update waits).
Classic reads POST fields from a form and returns a JSON redirect with the order ID. One function call wrapped in validation. Store API parses typed address objects, validates them against a JSON schema, runs payment through a structured pipeline, serializes the complete order response, and returns the full cart state. More work per request. Better API design. Slower response.
| Load | Classic | Store API | Gap | Timeouts (C / SA) |
|---|---|---|---|---|
| 1 user | 185ms | 225ms | +22% | 0% / 0% |
| 50 users | 1,642ms | 2,673ms | +63% | 0% / 0% |
| 200 users | 2,107ms | 4,706ms | +123% | 1.6% / 5.3% |
| 500 users | 2,643ms | 5,986ms | +127% | 4.4% / 16.1% |
The Fidgety Shopper: Where Store API Earns Its Keep
I wrote a k6 script that does exactly this: ~12 cart operations per session. Adds, removes, quantity changes, coupon swaps. Then 200 of these fidgety shoppers hit the server at once.
Frontend Performance: Lighthouse TTI Is Misleading
I ran Lighthouse CI, then measured Real TTI with performance.now() across three CPU throttle levels. The gap between the two metrics is significant.
| Throttle | Metric | Classic | Store API | Gap |
|---|---|---|---|---|
| 1x (desktop) | Real TTI | 384ms | 968ms | 2.5x |
| 1x | Lighthouse TTI | 2,248ms | 2,222ms | Tied |
| 2x (mid-range phone) | Real TTI | 400ms | 3,885ms | 9.7x |
| 4x (low-end phone) | Real TTI | 2,486ms | 4,243ms | 1.7x |
| 4x | Lighthouse TTI | 4,465ms | 7,804ms | 1.75x |
Warm cache eliminates the difference. At every throttle level, both hit Real TTI under 700ms. Zero long tasks at 1x and 2x. The architecture gap vanishes.
| Throttle | Classic (warm) | Store API (warm) | Gap |
|---|---|---|---|
| 1x (desktop) | ~310ms | ~320ms | Tied |
| 2x (mid-range) | ~400ms | ~450ms | Tied |
| 4x (low-end) | 565ms | 673ms | +19% |
| First-time (4x throttle) | Returning (4x throttle) | |||
|---|---|---|---|---|
| Metric | Classic | Store API | Classic | Store API |
| Real TTI | 2,486ms | 4,243ms | 565ms | 673ms |
| Lighthouse TTI | 4,465ms | 7,804ms | ~1,000ms | ~1,000ms |
| TBT | 2ms | 83ms | 3ms | 66ms |
| Long tasks | 0 | 2 | 1 | 1 |
| Size | 75 KB | 681 KB | 38 KB | 63 KB |
Mobile Networks: It Depends on Your Customers' Connections
Store API downloads 2x more data on first visit, but its assets split into ~120 small chunks fetchable in parallel over HTTP/2. On fast connections, parallelism beats payload size. On 3G, the extra bytes win.
| Network | Classic | Store API | Gap |
|---|---|---|---|
| No throttle | 1,095ms | 1,120ms | Tied |
| 4G | 1,657ms | 2,288ms | +38% |
| 3G | 3,498ms | 4,810ms | +37% |
Both transfer ~40-65KB from the server. The rest loads from cache. The 3G penalty drops from +37% to +11%.
| Network | Classic | Store API | Gap |
|---|---|---|---|
| No throttle | 870ms | 832ms | -4% (SA wins) |
| 4G | 911ms | 908ms | Tied |
| 3G | 1,147ms | 1,274ms | +11% |
| First-time visitor | Returning visitor | |||
|---|---|---|---|---|
| Network | Classic | Store API | Classic | Store API |
| No throttle | 1,095ms | 1,120ms | 870ms | 832ms |
| 4G | 1,657ms | 2,288ms | 911ms | 908ms |
| 3G | 3,498ms | 4,810ms | 1,147ms | 1,274ms |
| Cache benefit (no throttle) | -21% | -26% | ||
The Actual Checkout Experience: Store API Feels Faster
I used Puppeteer to simulate what a customer actually does: navigate to checkout, fill every billing field, wait for the totals to recalculate. End-to-end, from page load to "ready to place order."
| Metric | Classic | Store API |
|---|---|---|
| Checkout page load | 1,032ms | 1,103ms |
| Update wait after fields | 2,004ms | 1,005ms |
| Total interaction time | 6,881ms | 6,377ms |
Field fill, update wait, and server calls are server-side work, unchanged by caching. Only page load benefits from warm cache.
| Metric | Classic | Store API |
|---|---|---|
| Checkout page load | 1,169ms | 819ms |
| Update wait after fields | 2,007ms | 1,004ms |
| Total interaction time | 6,925ms | 6,331ms |
| First-time visitor | Returning visitor | |||
|---|---|---|---|---|
| Metric | Classic | Store API | Classic | Store API |
| Page load | 1,032ms | 1,103ms | 1,169ms | 819ms |
| Update wait | 2,004ms | 1,005ms | 2,007ms | 1,004ms |
| Total interaction | 6,881ms | 6,377ms | 6,925ms | 6,331ms |
| Server calls | 1 | 5 | 1 | 5 |
Caching: Redis Narrows the Gap from 13% to 3.4%
The checkout gap between Classic and Store API shifts depending on your caching stack. I tested four configurations at 50 concurrent users.
wc_get_product(), session reads, and schema validation all hit the object cache. Classic uses fewer of these calls, so it benefits less. With Redis + LSCache combined, the gap drops from 13% to 3.4%.
With Real Stripe Payments: The Gap Nearly Disappears
All the checkout data above used Cash on Delivery to isolate WooCommerce's processing from payment gateway latency. But real stores use Stripe. I ran the full checkout flow with Stripe's Payment Element on both instances.
| Step | Classic | Store API | Gap |
|---|---|---|---|
| Page load | 3,218ms | 2,695ms | SA 16% faster (Stripe iframe loads in parallel with block chunks) |
| Time to interactive | 8ms | 1,509ms | React hydration |
| Fill billing fields | 3,428ms | 3,837ms | Similar |
| Fill Stripe card | 2,747ms | 2,746ms | Identical |
| Submit to confirm | 5,034ms | 5,293ms | Classic 5% faster |
| Total | 16,435ms | 17,180ms | +4.7% |
Heavy Data: 500K Orders Change the Math
All previous tests ran against a clean store with 9 products and minimal order history. Real stores have large wp_woocommerce_order_itemmeta, wp_usermeta, and coupon tables that change the performance picture. I loaded 500K orders, 100K users, 2M usermeta rows, 200 products, and up to 10K coupons with 500K _used_by entries.
| Load | Classic (empty) | Classic (heavy) | Impact | SA (empty) | SA (heavy) | Impact |
|---|---|---|---|---|---|---|
| 1 VU | 185ms | 219ms | +18% | 225ms | 266ms | +18% |
| 50 VU | 1,642ms | 1,986ms | +21% | 2,673ms | 2,559ms | -4% |
| 200 VU | 2,107ms | 2,356ms | +12% | 4,706ms | 3,303ms | -30% |
| 500 VU | 2,643ms | 5,424ms | +105% | 5,986ms | 7,128ms | +19% |
wc_get_product() and schema validation calls.
| Load | Classic (empty) | Classic (heavy) | Impact | SA (empty) | SA (heavy) | Impact |
|---|---|---|---|---|---|---|
| 1 VU | 80ms | 88ms | +10% | 80ms | 93ms | +16% |
| 50 VU | 243ms | 320ms | +32% | 252ms | 374ms | +48% |
| 500 VU | 610ms | 5,275ms | +765% | 578ms | 1,856ms | +221% |
wp_posts table at 500K rows + session table locks destroy performance. Store API's stateless approach and wc_product_meta_lookup table handle the load.
Why Heavy Data Changes the Picture
Coupons: A Non-Issue
Going from 50 to 10,000 coupons with 500K _used_by postmeta entries produced zero measurable performance difference. WooCommerce's coupon validation uses (post_id, meta_key) indexed lookups that are O(1) regardless of total table size.
Multisite: Fine Until 500 VU
I set up two WordPress multisites (subdirectory mode, 8 WooCommerce stores each) on the same server. Same Redis, same OLS, same catalog. Then ran the full k6 suite at 1-500 VU.
| Load | Classic (single) | Classic (multi) | Overhead | SA (single) | SA (multi) | Overhead |
|---|---|---|---|---|---|---|
| 1 VU | 185ms | 190ms | +3% | 225ms | 232ms | +3% |
| 50 VU | 1,642ms | 1,855ms | +13% | 2,673ms | 2,761ms | +3% |
| 200 VU | 2,107ms | 3,179ms | +51% | 4,706ms | 4,950ms | +5% |
| 500 VU | 2,643ms | FAIL | 100% errors | 5,986ms | 30,001ms | 100% errors |
The Verdict
Neither approach won outright. Classic is faster where it's simple (placing orders, first-visit page load). Store API is more consistent where it's complex (cart operations under load, returning visitor experience, heavy data stores). The six numbers below capture the trade-off.
So Which One Should You Actually Use?
The right answer depends on your store, not these charts.
Go with Store API
High traffic (200+ concurrent users)
Cart P95: 268ms vs Classic's 10,220ms. The 38x tail latency gap is the most significant result in this benchmark. With heavy data (500K orders), Classic cart collapses at 500 VU (5.2s median) while Store API stays at 1.8s.
Large store with heavy order history
The checkout gap halves with 500K orders: from +123% to +40% at 200 VU. Store API benefits more from warm object cache. Classic's session table and wp_posts queries become bottlenecks at scale.
Heavy cart usage before checkout
Update wait is 2x faster (cache-independent). Total interaction time is 8% better on both cold and warm visits.
Returning visitors on 4G/WiFi
Real TTI under 700ms for both on return visits (even at 4x CPU throttle). Page load: Store API 832ms vs Classic 870ms on no-throttle, tied on 4G. The first-visit JS penalty disappears from cache.
Headless or multi-channel
Proper REST interface. Classic's wc-ajax is tied to the WordPress frontend.
New store, starting fresh
Block checkout is the default in WC 10.x. Classic shortcode is on a deprecation path.
Stick with Classic
Server capacity is the bottleneck
Place Order is 22-127% faster (server-side, unchanged by caching). Fewer PHP workers held per request. Note: full checkout UX favors Store API by ~8%.
Mostly new visitors (ads, social)
Real TTI is 9.7x faster on a mid-range phone (400ms vs 3,885ms). Store API's React hydration creates a 3-second gap where the page is visible but unresponsive. On return visits both are under 700ms.
Significant 3G traffic
3G gap: +37% (first-time), +11% (returning). Payload size still matters on bandwidth-constrained connections.
Third-party checkout plugins
87% of stores still use classic. The plugin ecosystem hasn't caught up with blocks. Verify support before migrating.
Under 50 concurrent users
Performance difference is negligible. Classic gives you a perfect first-visit Lighthouse score and simpler everything.
Extensibility: The Gap That Still Matters
Performance is one axis. The other is how your team customizes the checkout day to day.
Classic is easier to customize today. Add a field: one PHP filter (woocommerce_checkout_fields). Validate it: one action (woocommerce_checkout_process). Save to the order: one hook. 15 years of plugins and Stack Overflow answers back you up. The cost: filter soup. Hooks conflict, ordering matters, debugging cascading filters is miserable.
Block checkout has better architecture and a steeper learning curve. Adding a field requires PHP (ExtendSchema, register_endpoint_data) and JavaScript (Slots & Fills, setExtensionData). Two languages, two steps. But contracts are typed, namespacing prevents conflicts, and the block editor lets non-developers rearrange sections without code. The main compatibility tracking issue is still open, with the WooCommerce team actively working on closing the gap.
The gap is real and documented. Classic hooks like woocommerce_cart_item_visible have no block equivalent yet. Custom fields in block checkout don't apply until the customer selects a country, and complex visibility rules broke in 10.1.0-rc.1 (since fixed). Developers trying custom validation have hit walls that don't exist on classic.
Payment gateways work on both. Stripe, PayPal, Apple Pay, Google Pay, Klarna, Afterpay. Stripe deprecated the old payment form UI in July 2025; the new form works on shortcode pages too.
What I'd Test Next
A v2 of this test would cover:
If you want to run any of these yourself (or reproduce what's already here), the full test suite is open source: github.com/Jorgu5/woocommerce-performance-benchmarks — scripts, Hetzner setup guide, data generators, and every raw result.
Caveats & Findings
One server configuration
Nginx vs LiteSpeed vs Apache, MySQL vs MariaDB, PHP-FPM vs LSPHP will produce different absolute numbers. The relative comparisons should hold.
WC Payments Sift bug
Classic loads checkout.js (including Sift) via woocommerce_after_checkout_form without checking if the gateway is enabled. Block checkout correctly checks is_active() and skips disabled gateways. If you have WC Payments installed but use a different provider, Classic still loads Sift.
PHP 8.4 + JIT is slower
10-18% slower than 8.3. Requests finish in under 100ms, too fast for JIT's tracing profiler. Stick with 8.3.
Hidden performance switches
WooCommerce ships features off by default. Without them, Store API tested 9% slower. See the Flags & Perf Switches tab in the terminal at the top.
Comments