Shopify GraphQL Rate Limits: Budget by Query Cost

Shopify’s GraphQL Admin API rate limit is a points budget, not a request count. Each app and store pair gets a bucket that refills at a fixed restore rate: 100 points per second on Standard, 200 on Advanced, 1,000 on Plus and 2,000 on Commerce Components. Every query is charged by its calculated cost, and no single query may exceed 1,000 points.

Staying inside that budget takes three things: reading the cost Shopify reports on every response, pacing jobs from that report, and handling a THROTTLED error that retry logic keyed on HTTP status codes never sees.

The figures and examples below come from Shopify’s GraphQL Admin API rate limits page and the Admin API reference for version 2026-07, as of September 2026.

How does Shopify calculate GraphQL query cost?

Every field has a cost. By default, scalars and enums cost 0, objects cost 1, a mutation costs 10, and interfaces and unions cost the most expensive of their possible selections. Connections are sized by their first or last argument, which is where budgets go.

Take a product page query that asks for 50 products and 10 variants on each:

query ProductPage($cursor: String) {
  products(first: 50, after: $cursor) {
    pageInfo {
      hasNextPage
      endCursor
    }
    nodes {
      id
      title
      variants(first: 10) {
        nodes {
          id
          sku
        }
      }
    }
  }
}

Shopify prices this before it runs anything. It assumes all 50 products come back and every one of them has 10 variants, so that is 50 product objects plus 500 variant objects before any connection overhead. Push both arguments to 250 and the nested connection alone asks for 62,500 variants, far past the 1,000-point ceiling. Shopify rejects the query with MAX_COST_EXCEEDED before running it. The reference shows this response (message shortened here):

{
  "errors": [
    {
      "message": "Query cost is 2003, which exceeds the single query max cost limit (1000). ...",
      "extensions": {
        "code": "MAX_COST_EXCEEDED",
        "cost": 2003,
        "maxCost": 1000
      }
    }
  ]
}

Nested connections multiply. The usual fix is a smaller inner first, plus a second query for the occasional parent that has more children than the page allows.

Why is requestedQueryCost higher than actualQueryCost?

Shopify works out two numbers. The requested cost is the worst case from the query’s shape. The actual cost is what the response really contained, so a store where most products have two variants pays for two, not ten.

The bucket needs enough room for the requested cost before execution starts. When the query finishes, Shopify refunds the difference. Both numbers come back on every response under extensions.cost:

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

A large gap between those two numbers means you are reserving budget you never use. On a single request that costs nothing. On a job that runs thousands of pages, an inflated requested cost means you wait for capacity the query then hands back.

To see which field is expensive, send the header Shopify-GraphQL-Cost-Debug=1. The response adds a fields array with definedCost, requestedTotalCost and requestedChildrenCost for each path. That tells you which connection to trim, instead of guessing.

What happens when you hit the Shopify GraphQL rate limit?

When the bucket cannot cover a query’s requested cost, the request fails with the error code THROTTLED. The Admin API reference lists it among the errors that arrive in a 200 OK response, “similar to 429 Too Many Requests”, but with a 200 status.

Retry logic keyed on HTTP status misses it. Shopify’s own @shopify/admin-api-client has a retries option (0 to 3), and its README says those retries happen when “the server responded with a Too Many Requests (429) or Service Unavailable (503) response”. The fetch wrapper checks response.ok and the status code. A THROTTLED error inside a 200 body passes straight through to your code as a GraphQL error, and any wrapper that only inspects the status code has the same blind spot.

A throttled query is safe to retry, mutations included. Shopify checks bucket capacity before execution begins, so a throttled request never ran. That is a different situation from a timeout, where a mutation may have run and a blind retry could apply it twice. Shopify’s idempotent requests guide covers that case.

There is also a separate, daily limit on large catalogues. Once a store has 500,000 variants, it can create at most 10,000 new variants per day through productCreate, productUpdate and productVariantCreate, and that throttle does return a real 429. Plus stores are exempt.

How do you avoid THROTTLED errors in a sync job?

The rate-limit docs recommend a one-second backoff after a throttle error. You can do better, because every response tells you how much budget is left and how fast it refills. Before sending the next query, compare what you expect it to cost with currentlyAvailable and sleep for the shortfall divided by restoreRate.

Here is a small client that does both, pacing ahead of time and retrying THROTTLED when pacing is not enough (another process on the same app and store shares the bucket):

type ThrottleStatus = {
  maximumAvailable: number;
  currentlyAvailable: number;
  restoreRate: number;
};

type GraphqlResponse<T> = {
  data?: T;
  errors?: { message: string; extensions?: { code?: string } }[];
  extensions?: {
    cost?: {
      requestedQueryCost: number;
      actualQueryCost: number | null;
      throttleStatus: ThrottleStatus;
    };
  };
};

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export function createAdminClient(shop: string, accessToken: string) {
  const url = `https://${shop}/admin/api/2026-07/graphql.json`;
  let lastStatus: ThrottleStatus | undefined;
  let lastRequestedCost = 0;

  /** Wait until the bucket has probably refilled enough for a query of this cost. */
  async function waitForBudget(expectedCost: number) {
    if (!lastStatus) return;
    const shortfall = expectedCost - lastStatus.currentlyAvailable;
    if (shortfall > 0) {
      await sleep(Math.ceil((shortfall / lastStatus.restoreRate) * 1000));
    }
  }

  return async function request<T>(
    query: string,
    variables: Record<string, unknown> = {},
    maxAttempts = 5,
  ): Promise<T> {
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      await waitForBudget(lastRequestedCost);

      const response = await fetch(url, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-Shopify-Access-Token": accessToken,
        },
        body: JSON.stringify({ query, variables }),
      });

      if (response.status === 429 || response.status === 503) {
        const retryAfter = Number(response.headers.get("Retry-After") ?? "1");
        await sleep(retryAfter * 1000);
        continue;
      }
      if (!response.ok) {
        throw new Error(`Shopify returned HTTP ${response.status}`);
      }

      const body = (await response.json()) as GraphqlResponse<T>;
      const cost = body.extensions?.cost;
      if (cost) {
        lastStatus = cost.throttleStatus;
        lastRequestedCost = cost.requestedQueryCost;
      }

      const throttled = body.errors?.some((e) => e.extensions?.code === "THROTTLED");
      if (throttled) {
        // Nothing executed, so a retry cannot double-apply a mutation.
        if (!cost) await sleep(1000);
        continue;
      }
      if (body.errors?.length) {
        throw new Error(body.errors.map((e) => e.message).join("; "));
      }
      return body.data as T;
    }
    throw new Error(`Still throttled after ${maxAttempts} attempts`);
  };
}

Two decisions in there are worth defending. The client uses the last requested cost as its estimate for the next call, which is right for a paginating loop that sends the same query shape each time and a little cautious for anything else. It also never hard-codes the bucket size or the restore rate. Those values depend on the store’s plan, and Shopify says it “may temporarily reduce API rate limits to protect platform stability”, so the numbers in the latest response are the only ones you can trust.

Rate limits apply per app and per store. Two of your jobs hitting one store with the same app credentials share one bucket. The same job against two stores gets two.

When should you use a bulk operation instead?

If a job reads the whole catalogue, every order for a year, or all inventory levels, pacing single queries is the wrong tool. Bulk operations do not have the single-query cost cap or the rate limits, and Shopify runs the pagination for you. Our guide to Shopify bulk operations walks through the JSONL output and polling.

Keep single queries for the interactive paths: an admin page loading one order, a webhook handler fetching the product it was told about. Also keep them for incremental syncs that touch dozens of records rather than thousands. Pagination has its own ceiling as well, since paging through a connection caps at 25,000 objects according to the API limits page.

What we would do

For a new integration, we would start with the paced client above, set inner connection sizes from real data rather than the 250 maximum, and log requestedQueryCost next to actualQueryCost for the first week. Where the two drift far apart, shrink the query. Anything that touches more than a few thousand records goes to a bulk operation from day one.

We would not build a queue with fixed requests-per-second limits borrowed from the REST API. Request counts do not map to GraphQL cost. A fixed rate tuned for a Standard store wastes most of a Plus store’s budget, and one tuned for Plus gets throttled on Standard. The client also pins 2026-07 in its URL, and that string needs bumping as Shopify ships a new version each quarter; our note on the quarterly Shopify API version upgrade covers that routine.

Whoooop builds and maintains Shopify apps and back-office integrations. If your sync spends more time throttled than working, our Shopify development team can look at the query shapes and the job design.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch