DEV Community

Cover image for Shopify GraphQL Rate Limits: A Query-Cost Optimization Guide
Lucy
Lucy

Posted on

Shopify GraphQL Rate Limits: A Query-Cost Optimization Guide

Shopify's GraphQL Admin API does not limit you by how many requests you send. It limits you by how much each request costs. A query that asks for ten cheap fields can run far more often than one that asks for three expensive ones, and once you understand the cost formula, most 429 errors stop being a mystery and start being a math problem.

This guide breaks down how the cost model works. You'll learn how to read the numbers Shopify sends back with every response, and which query patterns quietly burn through your rate limit budget. It also covers when to stop optimizing a query and switch to a bulk operation instead.

Why Shopify moved away from counting requests

The REST Admin API charged a flat fee per call. A request for one product field cost the same as a request for fifty. That model was simple, but it rewarded sloppy queries and punished apps that made frequent small calls for good reasons, like polling a webhook queue.

Shopify has been unwinding REST in favor of GraphQL for a few years now. The REST Admin API became a legacy API on October 1, 2024, and starting April 1, 2025, every new public app submitted to the Shopify App Store has to run on GraphQL only, according to Shopify's developer changelog. Existing apps can keep calling REST for now, but new builds do not have that option. If you are scoping a new Shopify app in 2026, the query cost model is not an edge case to learn later. It is the rate limit you will live under from day one.

GraphQL made this different approach possible in the first place. The server can look at a query's shape before it runs and estimate the work involved, something a REST endpoint can't do. Shopify's engineering team wrote about the reasoning behind this shift in a detailed post on calculating GraphQL query complexity. It's worth reading for the full backstory.

How the cost model works


Shopify's GraphQL Admin API uses what it calls a calculated query cost, enforced through a leaky bucket. Picture a bucket that fills up as you make requests and drains at a steady rate every second. Send too many expensive queries too fast and the bucket overflows, and you get throttled until it has room again.

Every field in the schema carries a cost. According to Shopify's official API limits documentation, the defaults break down like this:

Field type Cost
Scalar (string, int, boolean, etc.) 0
Enum 0
Object 1
Interface Maximum of possible selections
Union Maximum of possible selections
Connection (edges/nodes list) Sized by first or last argument
Mutation 10

Two things jump out from that table. First, plain scalar fields are free, so asking for a product's title, handle, and status does not move the needle. Second, connections scale with how many items you request, and mutations carry a flat cost of 10 points regardless of how simple the write is, because a mutation triggers side effects like database writes and webhook dispatches that a read never does.

Your rate limit itself depends on your store's plan. Shopify's limits page lists these figures for the GraphQL Admin API:

Plan tier Points restored per second
Standard (Basic and Shopify plans) 100
Advanced Shopify 200
Shopify Plus 1,000
Shopify for enterprise (Commerce Components) 2,000

No matter which tier you are on, a single query cannot exceed 1,000 points. That cap is enforced before the query runs, based on its requested cost, so a query that would cost 1,200 points gets rejected outright rather than partially executed.

Reading the cost data Shopify sends back

Every GraphQL Admin API response includes an extensions block. It tells you exactly what happened with your query's cost. Most developers skip this part of the response, right up until they're debugging a production incident at 2 a.m.

"extensions": {
  "cost": {
    "requestedQueryCost": 101,
    "actualQueryCost": 46,
    "throttleStatus": {
      "maximumAvailable": 1000,
      "currentlyAvailable": 954,
      "restoreRate": 50
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two numbers matter here. The requestedQueryCost is what Shopify estimated before running your query, based on the fields you asked for and the first/last arguments on any connections. The actualQueryCost is what it really cost once the query finished, and it is often lower, because a connection you asked to return 50 items might have only returned 12. Shopify refunds the difference back to your bucket automatically, so over-requesting a first argument is not free, but it is not as expensive as it looks either.

Want a field-by-field breakdown of where the cost comes from? Add the header Shopify-GraphQL-Cost-Debug: 1 to your request. The response then includes a fields array. It shows the cost each part of the query adds. This is the fastest way to find the one nested connection quietly eating your whole budget.

When a query costs more than your bucket can currently cover, you get an error back. The code is either MAX_COST_EXCEEDED or a general throttle response. Either way, the throttleStatus object tells you how many points you have left and how fast they're coming back. That's everything you need to build a sane retry.

Query patterns that quietly cost more than they should


A few habits are responsible for most of the throttling issues teams run into.

Unbounded or overly generous connections. Every edges { node { ... } } block gets sized by its first or last argument. Asking for first: 250 on a connection you only display 20 items from wastes budget on data you throw away. Request only what the page or job actually needs, and paginate deliberately instead of grabbing the maximum allowed size out of habit.

Deep nesting. Think about a query that pulls a product, then its variants, then each variant's inventory levels, then each location for those levels. Cost multiplies at every layer. Nesting isn't free just because each object only costs 1 point on its own. Flatten what you can. If a nested branch is optional data, split it into a second, shallower query instead.

Fetching fields you don't render. It's tempting to grab a wide product fragment once and reuse it everywhere, including screens that only need the title and price. Build narrower, purpose-specific fragments instead of one large one, especially for high-frequency queries like search-as-you-type or cart updates.

Chaining mutations without batching. Since every mutation costs a flat 10 points, updating 50 products one mutation at a time costs 500 points before you've touched a single connection field. Where Shopify offers a bulk-capable mutation for the operation you need, use it. Where it doesn't, queue the writes and pace them against your restore rate instead of firing them concurrently.

A useful habit: run new queries through the GraphiQL app in your Partner Dashboard first and check the extensions.cost block before you ship them. It's the cheapest testing you'll ever do.

When to stop optimizing and use a bulk operation instead


At some point, no amount of query tuning gets a large export under the 1,000-point single-query cap. Pulling every product, variant, and inventory level for a catalog of 50,000 SKUs is that kind of job. Forcing it through paginated single queries just means more requests, more throttling, and more code to manage the pacing.

Shopify's bulk operations API exists for exactly this case. You submit a query wrapped in a bulkOperationRunQuery mutation, Shopify processes it asynchronously in the background, and when it finishes, you get a URL to a JSONL file with every result. Bulk operations don't count against the query-cost rate limit at all, because they run outside the normal request path.

mutation {
  bulkOperationRunQuery(
    query: """
    {
      products {
        edges {
          node {
            id
            title
            variants {
              edges {
                node {
                  id
                  sku
                  inventoryQuantity
                }
              }
            }
          }
        }
      }
    }
    """
  ) {
    bulkOperation {
      id
      status
    }
    userErrors {
      field
      message
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Here's a simple rule of thumb. If you're pulling more than roughly 1,000 records, reach for a bulk operation. The same goes for any connection nested more than two levels deep across a full catalog. Don't just reach for a bigger first argument instead. Bulk operations trade a little speed, since results aren't instant, for a rate limit ceiling that basically disappears.

Building a retry strategy that respects the restore rate

A backoff strategy that ignores throttleStatus is guessing. One that reads it is calculating.

async function shopifyGraphQL(query, variables) {
  const res = await fetch(SHOPIFY_ENDPOINT, {
    method: "POST",
    headers: adminHeaders,
    body: JSON.stringify({ query, variables }),
  });
  const data = await res.json();

  const throttle = data.extensions?.cost?.throttleStatus;
  const wasThrottled = data.errors?.some(
    (e) => e.extensions?.code === "THROTTLED"
  );

  if (wasThrottled && throttle) {
    const needed = data.extensions.cost.requestedQueryCost;
    const deficit = needed - throttle.currentlyAvailable;
    const waitMs = Math.ceil(deficit / throttle.restoreRate) * 1000;
    await new Promise((r) => setTimeout(r, waitMs));
    return shopifyGraphQL(query, variables);
  }

  return data;
}
Enter fullscreen mode Exit fullscreen mode

This code isn't production-ready as written. A real version needs a retry limit, so it doesn't loop forever. It also needs some randomness in the wait time, so a whole fleet of workers doesn't wake up at the exact same moment and cause a new spike. But the core idea holds up: calculate the wait time from the real restore rate instead of guessing a fixed delay. Shopify recommends a one-second minimum backoff before any retry. Building your queue around the actual restore rate, instead of a flat delay, keeps your throughput closer to your true ceiling.

Where this fits into a bigger Shopify app build

Query cost discipline matters most once an app moves past a prototype. It matters the moment you start syncing real catalog volume, inventory, or order data on a schedule. This is one of the first things worth planning for. It shouldn't be a patch you add after the first wave of 429 errors hits production. On the custom Shopify app builds our team scopes, cost-aware pagination and bulk operations tend to be a default part of the data layer. It sits right alongside authentication and webhook handling, not as a cleanup task for later.

FAQ

What does a MAX_COST_EXCEEDED error mean in Shopify's GraphQL API?
It means the requested cost of a single query is over the 1,000-point cap that applies to every plan. The fix is almost always to lower the first/last values on connections or split the query, not to wait longer, since waiting won't help a query that could never fit.

Does having a large currentlyAvailable balance let me exceed the 1,000-point single-query cap?
No. The 1,000-point limit applies per query regardless of how much capacity is sitting in your bucket. A store with 10,000 points available still can't run a single query that costs more than 1,000.

Is the Storefront API rate-limited the same way?
No. Real buyer traffic on the Storefront API doesn't hit a fixed points limit. Shopify does throttle automated bot and crawler traffic on that API, according to the official limits documentation.

Do REST Admin API rate limits still matter if I'm migrating to GraphQL?
Only for as long as your app keeps making REST calls. Since new public apps have been required to use GraphQL exclusively since April 1, 2025, most new builds skip REST rate limits entirely and only need to design around the query-cost model from the start.

Can I request a higher GraphQL rate limit for my app?
Outside of moving to a higher Shopify plan tier, Shopify doesn't offer a general way to raise the per-app limit. The better lever is almost always reducing cost per query and offloading large reads to bulk operations.

What's the most stubborn 429 you've had to chase down in a Shopify integration, and did the fix end up being a query rewrite or a queue redesign? Drop it in the comments.

Top comments (0)