ADSX
JULY 1, 2026 // UPDATED JUL 1, 2026

Shopify GraphQL Rate Limits & Error Handling Reference

A copy-paste reference for Shopify Admin GraphQL API rate limits and errors: the cost-based bucket, throttleStatus, error codes, and backoff patterns.

AUTHOR
AE
AdsX Engineering
SHOPIFY API & COMMERCE ENGINEERING
READ TIME
8 MIN
SUMMARY

A copy-paste reference for Shopify Admin GraphQL API rate limits and errors: the cost-based bucket, throttleStatus, error codes, and backoff patterns.

The Shopify Admin GraphQL API enforces a calculated, cost-based rate limit: a bucket of 1,000 cost points on standard plans that refills at 50 points per second, with larger buckets on Plus. Every query costs points equal to the fields and nodes it returns, each response reports the exact cost in extensions.cost, and exceeding the bucket returns a THROTTLED error. This page is the pure reference — the numbers, the error codes, and the retry math — for building catalog jobs that never thrash the limit.

This is the reference the Shopify Product Catalog API guide points to for limits and errors. The how-tos apply these numbers: see bulk operations for large catalogs and fetching your entire catalog with GraphQL. For OAuth and API fundamentals, see the Shopify Admin API guide. Every number below links to shopify.dev.

The cost-based model

GraphQL does not count requests — it counts query cost. You have a bucket of points; each query subtracts its cost; the bucket refills continuously. When the bucket cannot cover the next query, Shopify rejects it with THROTTLED rather than running a partial query.

Plan tierBucket (max cost)Restore rateNotes
Standard (Basic, Shopify, Advanced)1,000 points50 points/secDefault for most stores
Shopify Plus2,000 points100 points/sec2× bucket and restore
Commerce Components10,000 points500 points/secEnterprise tier

These figures are the current published values (Shopify: GraphQL rate limits). Treat them as a floor you read at runtime, not hard-coded constants — always trust the throttleStatus in the response over any number you cache.

How query cost is computed

Cost is assessed in two phases. Shopify first computes a requested cost from the query shape (before execution), and if that exceeds the bucket the query is rejected outright. After execution it computes the actual cost and charges that against your bucket.

ElementCostRule of thumb
Scalar / object field0Fields like id, title, price are free
Connection (products, variants)2 + nodes returnedfirst: 50 ≈ 2 + 50
Mutation10Flat base cost per mutation
bulkOperationRunQuery10The bulk read runs past the bucket

So a products(first: 250) query pulling nested variants(first: 100) can cost well into the hundreds — a single call can drain a standard bucket. Requested cost is capped at the maximum bucket size, which is why deeply nested queries fail before they even run (Shopify: query cost).

Reading extensions.cost.throttleStatus

Every GraphQL response carries an extensions.cost object. This is the single source of truth for pacing — never guess your remaining budget when Shopify hands it to you on every call.

{
  "extensions": {
    "cost": {
      "requestedQueryCost": 202,
      "actualQueryCost": 62,
      "throttleStatus": {
        "maximumAvailable": 1000.0,
        "currentlyAvailable": 938.0,
        "restoreRate": 50.0
      }
    }
  }
}
FieldMeaning
requestedQueryCostEstimated cost before execution
actualQueryCostPoints actually charged
throttleStatus.maximumAvailableYour bucket ceiling (plan-dependent)
throttleStatus.currentlyAvailablePoints left right now
throttleStatus.restoreRatePoints refilled per second

To compute how long until you can afford the next query: secondsToWait = (nextCost − currentlyAvailable) / restoreRate. Read this on every response and back off before you hit zero.

Errors: userErrors vs top-level errors

Shopify surfaces failures in two distinct places, and conflating them is the most common catalog-integration bug.

KindWhere it appearsHTTPMeaning
Top-level errorsRoot of the response200 (or 4xx/5xx)The query failed: syntax, THROTTLED, ACCESS_DENIED, internal error. data is usually null.
userErrorsInside a mutation payload200The request ran, but business logic rejected it: invalid handle, missing variant, validation failure.

A THROTTLED response is a top-level error and still returns HTTP 200 — you cannot rely on status codes alone. Always parse both: check json.errors first, then check payload.userErrors on every mutation you send.

{
  "errors": [
    {
      "message": "Throttled",
      "extensions": { "code": "THROTTLED" }
    }
  ]
}

Common error codes

CodeLocationCauseAction
THROTTLEDTop-levelBucket cannot cover the queryBack off using restoreRate, retry
MAX_COST_EXCEEDEDTop-levelRequested cost > bucket ceilingReduce first, trim fields, split query
ACCESS_DENIEDTop-levelMissing scope / unapproved appFix OAuth scopes; do not retry
INTERNAL_SERVER_ERRORTop-levelShopify 5xx / transientRetry with exponential backoff
SHOP_INACTIVETop-levelStore frozen or closedDo not retry; surface to user
Field validationuserErrorsBad input to a mutationFix payload; do not retry blindly

Only THROTTLED, MAX_COST_EXCEEDED (after shrinking the query), and INTERNAL_SERVER_ERROR are retry-worthy. Auth and validation errors are permanent — retrying them just wastes your bucket (Shopify: error handling).

Retry and backoff strategy

FailureStrategyMax attempts
THROTTLEDWait (cost − available) / restoreRate seconds5
INTERNAL_SERVER_ERRORExponential backoff + jitter4
MAX_COST_EXCEEDEDShrink query, then one retry1
ACCESS_DENIED / validationNone — fail fast0

The rule: honor the bucket first, then layer exponential backoff with jitter for transient 5xx. A retry helper that reads throttleStatus sleeps for exactly the right time instead of a fixed guess, so a full-catalog job paces itself against the real limit.

// Retry-safe GraphQL client that honors the cost bucket.
async function shopifyGraphQL(shop, token, query, variables = {}, attempt = 0) {
  const res = await fetch(`https://${shop}/admin/api/2026-01/graphql.json`, {
    method: "POST",
    headers: {
      "X-Shopify-Access-Token": token,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ query, variables }),
  });
  const json = await res.json();

  // 1. Top-level THROTTLED: wait exactly until the bucket can afford a retry.
  const throttled = json.errors?.some((e) => e.extensions?.code === "THROTTLED");
  if (throttled) {
    const status = json.extensions?.cost?.throttleStatus;
    const needed = json.extensions?.cost?.requestedQueryCost ?? 200;
    const deficit = Math.max(needed - (status?.currentlyAvailable ?? 0), 0);
    const restore = status?.restoreRate ?? 50;
    const waitMs = (deficit / restore) * 1000 + jitter();
    if (attempt >= 5) throw new Error("Throttled: max retries exceeded");
    await sleep(waitMs);
    return shopifyGraphQL(shop, token, query, variables, attempt + 1);
  }

  // 2. Transient 5xx / internal errors: exponential backoff with jitter.
  const transient = json.errors?.some(
    (e) => e.extensions?.code === "INTERNAL_SERVER_ERROR"
  );
  if (transient) {
    if (attempt >= 4) throw new Error("Server error: max retries exceeded");
    await sleep(2 ** attempt * 1000 + jitter());
    return shopifyGraphQL(shop, token, query, variables, attempt + 1);
  }

  // 3. Permanent errors (ACCESS_DENIED, validation): fail fast.
  if (json.errors?.length) {
    throw new Error(`GraphQL error: ${JSON.stringify(json.errors)}`);
  }

  // 4. Proactively pace the NEXT call before the bucket empties.
  const cost = json.extensions?.cost?.throttleStatus;
  if (cost && cost.currentlyAvailable < cost.maximumAvailable * 0.2) {
    const target = cost.maximumAvailable * 0.5;
    await sleep(((target - cost.currentlyAvailable) / cost.restoreRate) * 1000);
  }

  return json;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const jitter = () => Math.floor(Math.random() * 250); // avoid thundering herd

The proactive pause in step 4 is what separates a smooth job from a sawtooth of THROTTLED retries: instead of sprinting until you hit zero and bouncing off the limit, you glide back to half a bucket whenever you drop below 20%.

When to stop tuning and go bulk

If you find yourself pushing first values down and still throttling, you have outgrown synchronous reads. The Bulk Operations API charges a flat cost and runs the read server-side, past the bucket entirely — the correct tool for full-catalog exports. Rate-limit tuning matters for targeted reads and writes; it is not how you move 50,000 products.

This is also where API hygiene meets revenue: throttled, half-finished catalog jobs produce incomplete feeds, and incomplete feeds cap ad performance and AI shopping visibility no matter how good the campaigns are. Getting the extraction reliable is foundational work — exactly what AdsX does for Shopify brands feeding Google, Meta, and AI shopping surfaces.

Next steps

ABOUT THE AUTHOR
AE
AdsX Engineering
SHOPIFY API & COMMERCE ENGINEERING

The AdsX engineering team builds the data pipelines that turn a Shopify product catalog into high-performing ad feeds across Google, Meta, and AI shopping agents. We work hands-on with the Shopify Admin GraphQL API, the Product Feed and Catalog APIs, metafields, and bulk operations every day, and these guides document the patterns we use in production.

MORE BY ADSX ENGINEERING

Ready to Dominate AI Search?

Get your free AI visibility audit and see how your brand appears across ChatGPT, Claude, and more.

Get Your Free Audit