HHarsh Patel

Here's a pattern I've watched happen more than once. Someone writes a script that loops over every product in a store and updates it. It runs fine on a dev store with 30 products. Then it meets a real store with 40,000 products and starts failing with Throttled errors. They add more workers, and that makes it worse.

To understand why, you need to know how Shopify meters the GraphQL Admin API. It doesn't count requests. It counts points, and it keeps them in a bucket.

Points, not requests

Every GraphQL query has a cost, calculated from the fields you ask for. Shopify's rate limit docs give the rules:

  • Scalar and enum fields (a title, a status) cost 0.
  • Objects (a product, an image) cost 1.
  • Connections cost more the more you ask for. They're sized by their first or last argument.
  • Mutations cost 10.

So products(first: 5) is cheap and products(first: 250) with nested variants is not. A single query can't cost more than 1,000 points, whatever plan the store is on.

The bucket

Your app gets a bucket of points for each store. Every query takes its cost out of the bucket. The bucket also refills at a steady rate, called the restore rate. If a query costs more than what's left in the bucket, Shopify rejects it.

Try it. Send a few queries and watch the response change.

refills at 100 points/s


                  
A 1,000-point bucket refilling at the Standard plan's 100 points/s. Click fast and you'll get throttled.

Two numbers in that response matter more than the rest. currentlyAvailable tells you how full the bucket is right now. restoreRate tells you how fast it refills. Every successful response includes both, so your client always knows where it stands. Most clients ignore them.

The restore rate depends on the merchant's plan:

PlanRestore rate
Standard100 points/s
Advanced200 points/s
Shopify Plus1,000 points/s
Enterprise2,000 points/s

The same app gets ten times the throughput on a Plus store as on a Standard one. If you only ever test on one kind of store, you'll tune for the wrong number.

Requested cost vs actual cost

You'll notice the response has two costs. requestedQueryCost is the worst case, worked out from your query before it runs. actualQueryCost is what the query really cost once it ran. It can be lower, for example when you ask for first: 100 and only 12 products exist. Shopify checks the requested cost against your bucket before running the query, so asking for far more than you need can get you throttled even if the result is small.

Running a real job

One query at a time is easy to reason about. Real jobs run several workers in parallel, each sending a query, waiting for the response, and sending the next. This simulator runs that loop. Each request takes about 300ms round trip.

Start with the default settings, which just fire queries as fast as the workers can. Then switch the strategy to Wait for points.

Strategy
Points available Throttled request
Succeeded
0
Throttled
0
Points/s used (10s)
0
A simulated sync job. Bucket size is 1,000 points. Try adding workers in each mode.

A few things worth noticing:

  • In fire mode, the bucket drains in the first second, and after that most requests get throttled.
  • Throughput levels off at the restore rate either way. Once the bucket is empty, the restore rate is your speed limit, and no number of workers changes that.
  • Adding workers in fire mode only adds throttled requests. Each one is wasted network time, and a real client usually backs off after errors, so it ends up slower than this simulation.
  • In wait mode, you get the same throughput with zero throttles. The work gets done at the same speed, without burning requests.

A client that waits

The fix is small. Keep a local copy of the bucket, update it from throttleStatus on every response, and sleep before sending a query you can't afford yet.

// One bucket per shop. Updated from every response.
const bucket = { available: 1000, max: 1000, rate: 100, seenAt: Date.now() };

function pointsNow() {
  const refilled = ((Date.now() - bucket.seenAt) / 1000) * bucket.rate;
  return Math.min(bucket.max, bucket.available + refilled);
}

async function shopifyQuery(query, variables, expectedCost) {
  const missing = expectedCost - pointsNow();
  if (missing > 0) {
    await sleep((missing / bucket.rate) * 1000);
  }

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

  const status = json.extensions?.cost?.throttleStatus;
  if (status) {
    bucket.available = status.currentlyAvailable;
    bucket.max = status.maximumAvailable;
    bucket.rate = status.restoreRate;
    bucket.seenAt = Date.now();
  }
  return json;
}

Notice that it reads maximumAvailable and restoreRate from the response instead of hardcoding them. The merchant's plan sets those numbers, and Shopify has changed them before.

Two caveats. First, expectedCost should be the requested cost, since that's what Shopify checks. The first response for a given query tells you what it is. Second, this bucket lives in one process. If you have several processes or servers hitting the same shop, the bucket has to live somewhere they all share, such as Redis. Otherwise each process thinks it has the whole bucket to itself.

When to stop fighting it

If the job is really "read every product in the store", don't page through it at all. Use a bulk operation. You submit one query, Shopify runs it in the background, and you download the results as a JSONL file. Bulk operations don't have the per-query cost limit or the rate limit that normal queries have.

The bucket is for the requests a person is waiting on: loading a page in your app's admin, or saving a form. Large background jobs should go through bulk operations.


Numbers in this post come from Shopify's GraphQL Admin API rate limit docs as of October 2026. The simulator is a simplified model: it charges requested cost and ignores network jitter.