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.