DEV Community

Cover image for Shopify GraphQL Query Cost Optimization: Stop Burning API Points
Muhammad Masad Ashraf
Muhammad Masad Ashraf

Posted on • Originally published at kolachitech.com

Shopify GraphQL Query Cost Optimization: Stop Burning API Points

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
              }
            }
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
            }
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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 first value
  • [ ] Nested connections flattened or split where possible
  • [ ] extensions.cost logged 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)