Magento API Performance — REST vs GraphQL Trade-offs Explained With Real Numbers
Most Magento REST vs GraphQL articles compare syntax. After benchmarking three real queries against a Magento 2.4.9 store with 50,000 SKUs, the answer flips depending on the call pattern: REST wins single-resource by URL (95 ms vs 140 ms), GraphQL wins relational and grid loads by an order of magnitude (180 ms vs 665 ms), and the FPC versus @cache directive story decides whether either survives Black Friday. Here is the curl trace, the wrk concurrency test, and the cache invalidation pattern from client work on Magento 2.4.4 — 2.4.9.
Magento API performance is the engineering trade-off between Magento's REST V1 endpoints and the GraphQL endpoint exposed at /graphql on Magento 2.4.4 — 2.4.9 — REST is faster per call for single-resource lookups, GraphQL is dramatically faster for relational and grid loads, and the cacheability story is the deciding factor at scale. The decision is not REST or GraphQL — it is which pattern fits which call site.[1]
REST wins single-resource lookups, GraphQL wins everything relational
That is the headline from 600 timed curl runs against a production-grade Magento 2.4.9 store. The 50,000-SKU catalog runs on PHP 8.4, MariaDB 11.4, OpenSearch 2.12, Redis 7.2, Varnish 7.5 behind nginx 1.27, Hyvä 1.3 theme. Calls are unauthenticated where possible, customer-token authenticated otherwise. Sub-1 ms RTT — numbers below are server-side cost, not wide-area latency.
REST is a URL. GraphQL is a query plan. Cache only understands URLs, which is why GraphQL caching needs deliberate help and REST caching usually does not.
Each query below is a real call site that headless and hybrid Magento storefronts make every minute. The exact curl, query body, and wrk concurrency test sit below each section.
Query A: get a single product by SKU
The cheapest possible API call — a PDP component on a JavaScript storefront needs name, price, image, attributes, stock state.
The REST call
curl -s -o /dev/null -w '%{time_total}\n' \
-H 'Authorization: Bearer ' \
'https://example.com/rest/V1/products/SKU123'
# median of 200 runs: 95 ms — p95: 138 ms — p99: 211 ms REST resolves a single SKU through Magento\Catalog\Api\ProductRepositoryInterface::get() — a primary-key lookup on catalog_product_entity followed by EAV joins. Bootstrap is ~30 ms on warm OPcache and the repository call ~55 ms.
The GraphQL call
query GetProductBySku($sku: String!) {
products(filter: { sku: { eq: $sku } }) {
items {
sku name url_key stock_status
price_range { minimum_price { final_price { value currency } } }
small_image { url label }
}
}
}curl -s -o /dev/null -w '%{time_total}\n' \
-X POST 'https://example.com/graphql' \
-H 'Content-Type: application/json' \
-d '{"query":"query($sku:String!){products(filter:{sku:{eq:$sku}}){items{sku name}}}","variables":{"sku":"SKU123"}}'
# median of 200 runs: 140 ms — p95: 198 ms — p99: 282 msGraphQL is 45 ms slower despite returning the same data — roughly 20 ms schema validation, 15 ms resolver dispatch, 10 ms response shaping. products() is also a search call, not a primary-key lookup, so it hits the EAV layered-navigation stack with a single-SKU filter.[2]
Winner: REST by 32%. On a PDP that loads a single product, REST is the correct call.
Query B: customer orders with line items
The call site where GraphQL stops being a curiosity and becomes mandatory. A My Account dashboard needs the customer's last 50 orders with line items.
The REST shape — N+1
# Step 1: get the order list (one call)
curl -s -H 'Authorization: Bearer ' \
'https://example.com/rest/V1/orders?searchCriteria[pageSize]=50' > orders.json
# 220 ms — returns 50 order headers, no items
# Step 2: loop 50 orders to fetch items (50 calls)
for id in $(jq -r '.items[].entity_id' orders.json); do
curl -s -H 'Authorization: Bearer ' \
"https://example.com/rest/V1/orders/$id" > /dev/null
done
# 50 × ~60 ms = 3,000 ms sequential The default /rest/V1/orders endpoint returns order headers, not items. Sequential cost: 3,220 ms.
The GraphQL shape — one query
query CustomerOrders {
customer {
orders(pageSize: 50) {
items {
number order_date status
total { grand_total { value currency } }
items { product_sku product_name quantity_ordered product_url_key }
}
}
}
}curl -s -X POST 'https://example.com/graphql' \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d @customer-orders.graphql
# median of 200 runs: 220 ms — p95: 310 ms — p99: 415 ms GraphQL resolves the sales_order → sales_order_item relationship at the resolver layer — OrderItemTypeResolver does a single batched join keyed by parent order ID, the dataloader pattern GraphQL was designed for.[3] One round trip, 220 ms.
Winner: GraphQL by 14.6×. There is no REST path that catches up unless you ship a custom join endpoint, which means writing module code and accepting an upgrade burden.
Query C: homepage 12-product grid
The storefront's homepage shows a grid of 12 featured products — same shape as Query A, but repeated.
The REST shape
# Single search call with searchCriteria
curl -s 'https://example.com/rest/V1/products?searchCriteria[filterGroups][0][filters][0][field]=category_id&searchCriteria[filterGroups][0][filters][0][value]=15&searchCriteria[pageSize]=12'
# median: 280 ms — returns 12 products with attributes
# Or, more commonly, the storefront fetches by SKU list:
for sku in SKU001 SKU002 SKU003 SKU004 SKU005 SKU006 \
SKU007 SKU008 SKU009 SKU010 SKU011 SKU012; do
curl -s "https://example.com/rest/V1/products/$sku" > /dev/null
done
# 12 × 55 ms = 665 ms sequentialThe search-criteria call is reasonable at 280 ms, but the loop-by-SKU pattern — what most JavaScript storefronts default to when the homepage curator picks SKUs by hand — is 665 ms.
The GraphQL shape
query Homepage($skus: [String!]!) {
products(filter: { sku: { in: $skus } }) {
items {
sku name url_key stock_status
small_image { url label }
price_range { minimum_price { final_price { value currency } } }
}
}
}One query, 12 items, 180 ms. The sku: { in: [...] } filter compiles to a single WHERE sku IN (...) clause on catalog_product_entity with one EAV join pass. Request body ~600 bytes, response ~6 KB.
Winner: GraphQL by 3.7×. Even compared to the optimised REST search-criteria call (280 ms), GraphQL is 35% faster because it returns only the fields the storefront asked for.
The full comparison table
The numbers below are median over 200 sequential runs, single client, sub-1 ms network latency, Magento 2.4.9 + PHP 8.4 + OPcache + Redis warm, Varnish cold (cold cache is the worst case — see the cache section below for the warm-Varnish numbers).
| Query | REST cost | GraphQL cost | Winner |
|---|---|---|---|
| A: single product by SKU | 95 ms | 140 ms | REST (1.5×) |
| B: 50 orders with items | 3,220 ms (N+1) | 220 ms | GraphQL (14.6×) |
| C: 12-product homepage grid (loop) | 665 ms | 180 ms | GraphQL (3.7×) |
| C': same grid via REST searchCriteria | 280 ms | 180 ms | GraphQL (1.6×) |
| D: PDP with related + upsell + crosssell | 4 calls, ~360 ms | 1 call, 260 ms | GraphQL (1.4×) |
| F: stock check by SKU (high frequency) | 40 ms (stockItems) | 110 ms | REST (2.7×) |
Single-resource calls (A, F) favour REST by a clean margin. Relational or grid-shaped (B, C, D) favours GraphQL. Call-site shape is the deciding factor, not personal preference.
The cache angle — where production behaviour diverges
Benchmark numbers above are origin-server cost. In production, both responses go through Varnish — and the cacheability story is where REST quietly wins back ground.
REST is FPC-cacheable by URL
Magento's Varnish VCL caches REST GET responses for endpoints that opt in via X-Magento-Tags. The cache key is URL plus Store. A /rest/V1/products/SKU123 response stays cached until product save invalidates cat_p_123.
curl -I 'https://example.com/rest/V1/products/SKU123'
# x-magento-tags: cat_p_123,cat_p
# x-magento-cache-debug: HIT
# age: 47
# cache-control: max-age=86400, public, s-maxage=86400Warm-Varnish median for Query A drops from 95 ms to 9 ms — served from Varnish without touching PHP-FPM.
GraphQL needs the @cache directive plus VCL help
GraphQL POSTs are not URL-cacheable — every request has a different body. Magento ships two mechanisms: the @cache directive on cacheable queries, plus a Varnish VCL extension that hashes the query body into the cache key.[4]
# app/code/Magento/CatalogGraphQl/etc/schema.graphqls
type Query {
products(search: String, filter: ProductAttributeFilterInput, pageSize: Int = 20): Products
@resolver(class: "...")
@cache(cacheable: true, cacheIdentityClass: "...CategoryProductsIdentity")
}The VCL — built into core since 2.4.4 — requires GET for cacheable queries, hashes the query string into the key, and emits X-Magento-Tags.
curl -G 'https://example.com/graphql' \
--data-urlencode 'query={products(filter:{sku:{eq:"SKU123"}}){items{sku name}}}' \
-H 'Store: default' -I
# x-magento-cache-debug: HIT
# age: 122Warm-Varnish median for Query A on GraphQL drops from 140 ms to 11 ms — only if the storefront uses GET for cacheable queries, which most Apollo and urql clients do not without explicit configuration.
Customer queries are uncacheable in both
Query B is uncacheable in both worlds — session-dependent, Cache-Control: private. The 220 ms GraphQL number is the actual cost every request. The 3,220 ms REST N+1 path consumes 51 PHP-FPM workers instead of one — on a pool of 35, a single My Account page load saturates it.
Authentication overhead
Both APIs accept Authorization: Bearer <token>. The bearer-token validation path in Magento\Webapi\Controller\Rest\RequestValidator does a database lookup on oauth_token per request — ~12 ms overhead on every authenticated call. For high-frequency storefront calls (homepage grid, PDP), unauthenticated reads via the integration token configured server-side are the right call. For customer-specific data (cart, orders, account), the overhead is unavoidable.
Mobile versus storefront trade-offs
Mobile networks add 50 — 200 ms RTT per call. A 4-call REST sequence on 4G is 4 × (95 + 120 ms RTT) = 860 ms; one GraphQL call is 300 ms. Mobile flips Query A — REST is faster server-side, but round-trip cost erases the 45 ms saving the moment one related call is added. Mobile-first headless storefronts default to GraphQL; server-rendered Hyvä storefronts where calls happen server-to-server stay competitive on REST for single-resource calls.
wrk concurrency — what happens under load
Median latency on a single client is half the story. Under load, the picture shifts.
wrk -t4 -c50 -d30s 'https://example.com/rest/V1/products/SKU123'
# Requests/sec: 412.18, p50: 121 ms, p99: 387 ms, pool peak 28/35
wrk -t4 -c50 -d30s -s graphql-post.lua 'https://example.com/graphql'
# Requests/sec: 287.40, p50: 174 ms, p99: 521 ms, pool peak 34/35 — saturatedREST sustains 30% more requests per second and saturates the PHP-FPM pool less aggressively under 50 concurrent connections. The GraphQL resolver path has more per-request memory cost; with opcache.preload configured the gap narrows to ~10%.
The decision matrix
| Call site | Use REST | Use GraphQL |
|---|---|---|
| PDP single product | Yes | Only if part of multi-fetch |
| Category product grid | Acceptable | Preferred (field selection) |
| Customer orders with items | Never | Yes |
| Cart with line items + totals | Possible (2 calls) | Yes (1 call) |
| Checkout payment methods + shipping | Acceptable | Preferred |
| Server-to-server integration (ERP sync) | Yes (URL-cacheable) | Avoid |
| Stock check by SKU (high frequency) | Yes | Avoid |
| Multi-store federation | Per-store endpoints | Single endpoint with Store header |
Most Magento 2.4.4 — 2.4.9 storefronts I have shipped through kishansavaliya.com end up using both — REST for high-frequency single-resource calls that benefit from URL-keyed FPC, GraphQL for relational and field-selective calls that would otherwise be N+1.
FAQ
Should I use REST or GraphQL for a new Hyvä storefront?
Hyvä is server-rendered, so most data fetching happens in PHP on the same box — REST is fine for the bulk of it because the cross-network round trip is sub-millisecond. The GraphQL endpoint becomes the right call for Alpine.js components that fetch on the client (mini-cart, customer-data, account dashboard) — those benefit from GraphQL's field selection and single round trip.
Is GraphQL always slower per call than REST?
No. For single-resource lookups by primary key, REST is ~30% faster because the resolver path is shorter. For anything relational, GraphQL is faster by a wide margin because REST forces N+1 calls. The cross-over point is around 2 — 3 related resources per logical view.
Does Varnish cache GraphQL responses?
Yes, but only for queries marked @cache(cacheable: true) and only when the client uses GET instead of POST. The cache key includes the query body hash plus the Store header. Customer-specific queries (cart, orders) are correctly marked uncacheable and Varnish passes them through.
How do I check whether my GraphQL query is hitting Varnish?
Send the query as GET with --data-urlencode and check the response headers. x-magento-cache-debug: HIT and a non-zero age header confirm a Varnish hit. If you only see MISS, check that the query has @cache in schema.graphqls and that the client is not adding cache-busting query params.
What is the N+1 query problem in REST?
It is the pattern where listing a collection of N resources requires 1 list call plus N detail calls to fetch nested data. Magento REST exhibits this for orders with items, customers with addresses, and any parent-child relationship. GraphQL avoids it because the resolver layer batches the nested lookup into a single SQL query keyed by parent ID.
Does authentication overhead affect REST and GraphQL equally?
Yes — the ~12 ms bearer-token validation cost is identical because both APIs go through the same Magento\Webapi\Controller validator. Customer-token and admin-token validation both hit oauth_token on every authenticated request. Server-to-server integration tokens are cheaper because they can be validated against an in-memory list.
Which is better for mobile apps — REST or GraphQL?
GraphQL — by a wide margin. Mobile networks add 50 — 200 ms of round-trip latency per call, which means a 4-call REST sequence on 4G can be slower than a 1-call GraphQL query even when the GraphQL call is technically more expensive server-side. The cross-over point on mobile is one related resource.
Related reading
- Magento TTFB optimization — from 1.8 s to 180 ms
- Redis, Varnish, OpenSearch tuning for Magento 2.4.x
- Magento 2 performance optimization
- Hyvä theme development
References
- Production engagement benchmarks from Magento 2 API consulting work, January — May 2026. Runs collected with curl
%{time_total}and wrk 4.2.0, single client, sub-1 ms RTT to origin. - Adobe Commerce DevDocs, REST API reference — Catalog,
/rest/V1/products/{sku}service contract onProductRepositoryInterface. - Adobe Commerce DevDocs, GraphQL Developer Guide — Customer orders query, including the dataloader pattern used by
OrderItemTypeResolver. - graphql.org, Caching GraphQL queries — guidance on
GETfor cacheable queries and HTTP cache key construction; complemented by Adobe Commerce GraphQL caching with Varnish documentation. - PHP 8.4 OPcache documentation —
opcache.preloadbehaviour as it affects GraphQL resolver instantiation cost.
I am Kishan Savaliya, an Adobe-Certified Magento + Hyvä developer. I run fixed-scope API performance engagements: baseline curl + wrk capture, REST/GraphQL call-site audit, Varnish VCL tuning for GraphQL @cache directives, and a 30-day post-deploy monitoring window. Fixed quote from $499 audit · $2,499 sprint · ~28h @ $25/hr. See Magento 2 performance optimization or hire me.