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
firstorlastargument. - 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
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:
| Plan | Restore rate |
|---|---|
| Standard | 100 points/s |
| Advanced | 200 points/s |
| Shopify Plus | 1,000 points/s |
| Enterprise | 2,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.
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.