Every Shopify app developer hits the same wall eventually.
You build a feature, push it to production, and watch your API calls start failing at scale. The culprit is almost always the same: inefficient GraphQL queries burning through your rate limit bucket faster than it refills.
Shopify's GraphQL Admin API uses cost-based throttling, not request-per-second counting. A single poorly structured query can cost 1,000 points. A well-optimized one may cost under 50.
This post covers how the system works, where developers waste points, and what to do about it.
How the Leaky Bucket Works
Shopify assigns a calculated cost to every query before executing it. That cost gets deducted from your bucket instantly.
| Parameter | Value |
|---|---|
| Bucket size (standard) | 1,000 points |
| Bucket size (Shopify Plus) | 2,000 points |
| Refill rate | 50 points/second |
| Max single query cost | 1,000 points |
| Throttle error | THROTTLED |
Every API response includes cost metadata under extensions.cost:
{
"extensions": {
"cost": {
"requestedQueryCost": 412,
"actualQueryCost": 412,
"throttleStatus": {
"maximumAvailable": 1000,
"currentlyAvailable": 588,
"restoreRate": 50
}
}
}
}
Parse this on every single call. Most developers ignore it until production breaks.
How Cost Gets Calculated
Cost is driven by two things: the number of objects you request and the depth of connections.
The problem with nested connections
The first argument multiplies cost across every level of nesting. Here is what an expensive query looks like:
{
products(first: 250) {
edges {
node {
id
title
variants(first: 100) {
edges {
node {
id
price
inventoryItem {
id
}
}
}
}
}
}
}
}
This query easily hits a calculated cost of 25,000+. Shopify rejects it before execution.
The optimized version
{
products(first: 50) {
edges {
node {
id
title
variants(first: 10) {
edges {
node {
id
price
}
}
}
}
}
}
}
Smaller page sizes, fewer fields, no 3-level nesting. Cost drops by 90%+.
7 Strategies to Reduce GraphQL Points
1. Request Only the Fields You Need
GraphQL gives you field-level control. Use it.
Never pull an entire product object when you only need id and title. Unused fields carry cost weight inside nested connections.
Audit every query in your codebase. Remove what your app does not consume.
2. Reduce Connection Page Sizes
first: 250 is the single most common reason for runaway costs.
Start with first: 50. Paginate across multiple requests. Three paginated calls at first: 50 are almost always cheaper than one call at first: 250 with deep nesting.
Build pagination into your data layer from day one.
3. Split Deeply Nested Queries
Instead of fetching products, variants, and inventory in one query, split them.
# Step 1: Fetch products
{
products(first: 50) {
edges {
node {
id
title
}
}
}
}
# Step 2: Fetch variants by product ID
{
product(id: "gid://shopify/Product/123456789") {
variants(first: 10) {
edges {
node {
id
price
inventoryQuantity
}
}
}
}
}
Two focused queries consistently beat one deeply nested monster query.
4. Use Query Cost Extensions for Real-Time Monitoring
Log extensions.cost on every API call. Build an alerting rule that fires when actualQueryCost exceeds a threshold you define.
Here is a minimal Node.js wrapper pattern:
async function shopifyQuery(query, variables = {}) {
const response = await fetch(SHOPIFY_GRAPHQL_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': ACCESS_TOKEN,
},
body: JSON.stringify({ query, variables }),
});
const data = await response.json();
// Always log cost data
if (data.extensions?.cost) {
const { actualQueryCost, throttleStatus } = data.extensions.cost;
console.log(`Query cost: ${actualQueryCost} | Available: ${throttleStatus.currentlyAvailable}`);
// Alert threshold
if (actualQueryCost > 200) {
console.warn(`High-cost query detected: ${actualQueryCost} points`);
}
}
return data;
}
5. Build Leaky Bucket Awareness Into Your Client
Check throttleStatus.currentlyAvailable before firing a query. If available points are below your expected cost, wait.
async function throttleAwareQuery(query, estimatedCost = 100) {
const status = await getCurrentBucketStatus();
if (status.currentlyAvailable < estimatedCost) {
const waitSeconds = Math.ceil(
(estimatedCost - status.currentlyAvailable) / status.restoreRate
);
console.log(`Waiting ${waitSeconds}s for bucket to refill...`);
await sleep(waitSeconds * 1000);
}
return shopifyQuery(query);
}
This prevents burst failures without requiring exponential backoff from the start.
6. Cache Infrequently-Changing Data
Not every query needs a fresh Shopify API hit. Product catalog data, collection structures, and metafield definitions rarely change.
Use Redis with a short TTL:
async function getCachedProducts(cacheKey, ttlSeconds = 300) {
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const fresh = await shopifyQuery(PRODUCTS_QUERY);
await redis.setex(cacheKey, ttlSeconds, JSON.stringify(fresh));
return fresh;
}
You reduce Shopify GraphQL query cost by not making the call at all when cached data is still valid.
7. Use Direct GID Lookups Over List Queries
List queries scan potentially hundreds of records. Direct lookups target exactly what you need.
# Expensive: scans all products
{
products(first: 250) {
edges {
node { id title }
}
}
}
# Cheap: direct lookup by known GID
{
product(id: "gid://shopify/Product/123456789") {
id
title
variants(first: 5) {
edges {
node { id price }
}
}
}
}
Store Shopify GIDs in your local database. Retrieve them with targeted queries instead of scanning lists.
Use Bulk Operations for Large Data Sets
Stop using paginated GraphQL queries for bulk data jobs. Shopify's Bulk Operations API handles this better in every way.
Bulk operations:
- Run asynchronously in the background
- Do not consume your standard rate limit bucket
- Return results as a downloadable JSONL file
mutation {
bulkOperationRunQuery(
query: """
{
products {
edges {
node {
id
title
variants {
edges {
node {
id
price
}
}
}
}
}
}
}
"""
) {
bulkOperation {
id
status
}
userErrors {
field
message
}
}
}
Use bulk operations for: full catalog exports, historical order analysis, large-scale metafield reads, and inventory reconciliation across locations.
Handling Throttle Errors
When you exhaust your bucket, Shopify returns HTTP 200 with an error body:
{
"errors": [
{
"message": "Throttled",
"extensions": {
"code": "THROTTLED",
"retryAfter": 4.5
}
}
]
}
Your retry handler:
async function queryWithRetry(query, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const data = await shopifyQuery(query);
const throttleError = data.errors?.find(
(e) => e.extensions?.code === 'THROTTLED'
);
if (throttleError) {
const waitMs = (throttleError.extensions.retryAfter + 0.5) * 1000;
console.log(`Throttled. Retrying in ${waitMs}ms...`);
await sleep(waitMs);
continue;
}
return data;
}
throw new Error('Max retries exceeded after throttle errors');
}
Always add a small buffer (0.5s) to the retryAfter value. Network latency means hitting the retry exactly at the boundary often fails again.
Common Mistakes at a Glance
| Mistake | Cost Impact | Fix |
|---|---|---|
| Fetching all fields by default | 3x to 10x waste | Select only needed fields |
first: 250 everywhere |
Exponential multiplier | Start with first: 50, paginate |
Ignoring extensions.cost
|
Zero visibility | Log cost on every call |
| Nested connections 3+ levels | Cost explodes | Split into separate queries |
| No bucket awareness | Burst failures | Read currentlyAvailable before querying |
| Polling instead of webhooks | Continuous drain | Replace polling with event-driven webhooks |
Pre-Ship Checklist
Before any new GraphQL query goes to production:
- [ ] Only requesting fields the app actually uses
- [ ] Connection page sizes set to minimum viable
firstvalue - [ ] Nested connections flattened or split where possible
- [ ]
extensions.costlogged and monitored - [ ] Throttle error handler reads
retryAfter - [ ] Frequently-read data cached with TTL
- [ ] Bulk Operations used for large data jobs
- [ ] Known IDs queried directly by GID
Wrapping Up
Shopify GraphQL query cost is not a niche concern. It directly determines whether your app stays reliable at scale or becomes a 2am debugging session.
The fix is straightforward: read your cost data, reduce page sizes, split nested queries, cache where you can, and handle throttle errors properly. None of this is complex. It just has to be built intentionally.
For the full breakdown including the GraphQL vs REST comparison table and Hydrogen-specific patterns, read the original post on the KolachiTech blog.
Originally published at kolachitech.com
Top comments (0)