The Shopify Admin API has two different rate limit systems, and they're both strict enough to melt a batch job on the first afternoon if you're not careful. I learned this the hard way while building a page generator that issued thousands of metafield writes in under an hour. By the end of this walkthrough, you'll have a wrapper that handles both APIs without melting anything.
Prerequisites
You'll need a Shopify store (dev store works), an Admin API access token with the appropriate scopes, and a runtime to call from. I'll use TypeScript on Node 20 in the examples, but the pattern is the same in Python, Go, or Ruby.
Before starting, make sure you know which API you're calling. Shopify's REST Admin API and GraphQL Admin API have different rate limit models. Most new integrations should prefer GraphQL, but REST is still ubiquitous and plenty of existing code is on it.
- ↺ honor retry-after + jitter
Step 1: Understand the two rate limit models
REST Admin API. Shopify uses a leaky-bucket rate limiter on the REST API. Each store has a bucket with a fixed capacity and a steady refill rate. Every request consumes one token. The bucket refills over time.
The numbers as of 2026:
- Shopify (standard): bucket capacity 40, refill 2 requests/second
- Shopify Advanced: bucket 80, refill 4 req/s
- Shopify Plus: bucket 80, refill 4 req/s (standard apps) or up to 40 req/s on custom app credentials with higher allocations
Your bucket fills in response to requests up to capacity, then returns 429 until it refills. The bucket is per-shop-per-app-credential, not per-process and not per-token.
GraphQL Admin API. GraphQL uses a cost-based limiter instead of a count-based one. Each query has a calculated cost; your bucket has a point balance. The max query cost is 2000 points, the bucket capacity is 2000 points, and the restore rate is 50 points/second on standard plans (100 on Plus).
Cost is calculated per field, and the total cost of a response is returned in the extensions.cost block so you can budget the next call. Requesting 250 product records costs more than requesting 10, even if both are single queries.
Step 2: Read every response header Shopify sends
Shopify gives you enough information to avoid 429s entirely if you read it.
For REST, every successful response includes:
X-Shopify-Shop-Api-Call-Limit: 32/80
That's current usage over bucket capacity. If you're at 75/80, slow down.
For GraphQL, every response body includes:
{
"data": { /* ... */ },
"extensions": {
"cost": {
"requestedQueryCost": 112,
"actualQueryCost": 85,
"throttleStatus": {
"maximumAvailable": 2000,
"currentlyAvailable": 1915,
"restoreRate": 50
}
}
}
}
Read currentlyAvailable and restoreRate after every call. If your next query costs more than currentlyAvailable, you need to wait before sending it.
For 429 responses on either API, Shopify includes a Retry-After header with the number of seconds to wait. Honor it. This is not optional; retrying too soon after 429 is how you end up temporarily banned.
Step 3: Build the backoff primitive
Here's the core wrapper. It honors Retry-After, falls back to exponential with jitter, and tracks consecutive retries per request:
type FetchInit = RequestInit & { timeoutMs?: number };
async function shopifyFetch(
url: string,
init: FetchInit = {},
options: { maxRetries?: number; baseDelayMs?: number } = {}
): Promise<Response> {
const { maxRetries = 5, baseDelayMs = 500 } = options;
let attempt = 0;
while (true) {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
init.timeoutMs ?? 30_000
);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
if (res.status !== 429) return res;
if (attempt >= maxRetries) {
throw new Error(`Shopify 429 after ${attempt} retries: ${url}`);
}
const retryAfterSec = Number(res.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfterSec) && retryAfterSec > 0
? retryAfterSec * 1000
: baseDelayMs * Math.pow(2, attempt) + Math.random() * 250;
await new Promise((r) => setTimeout(r, delayMs));
attempt += 1;
} finally {
clearTimeout(timeout);
}
}
}
Three behaviors to notice. The wrapper only retries on 429, not on 5xx (those need different handling because they may be idempotent or not). It honors Retry-After when Shopify provides it. Its fallback is exponential with a small random jitter so parallel callers don't synchronize their retries.
Step 4: Respect GraphQL cost points explicitly
For GraphQL, the 429 retry isn't enough. You'll hit 429 less often, but when you hit it you've already wasted the call. Instead, track the bucket balance and preempt.
type BucketState = { available: number; restoreRate: number; updatedAt: number };
const bucketByShop = new Map<string, BucketState>();
function estimateAvailable(shopDomain: string): number {
const s = bucketByShop.get(shopDomain);
if (!s) return 2000; // assume full if we've never called
const elapsed = (Date.now() - s.updatedAt) / 1000;
return Math.min(2000, s.available + elapsed * s.restoreRate);
}
function updateBucket(
shopDomain: string,
throttleStatus: {
currentlyAvailable: number;
restoreRate: number;
}
) {
bucketByShop.set(shopDomain, {
available: throttleStatus.currentlyAvailable,
restoreRate: throttleStatus.restoreRate,
updatedAt: Date.now(),
});
}
async function waitForCost(shopDomain: string, neededCost: number) {
const available = estimateAvailable(shopDomain);
if (available >= neededCost) return;
const shortfall = neededCost - available;
const state = bucketByShop.get(shopDomain);
const rate = state?.restoreRate ?? 50;
const waitSec = shortfall / rate + 0.25; // 250ms margin
await new Promise((r) => setTimeout(r, waitSec * 1000));
}
Before issuing each GraphQL call, estimate cost (Shopify's docs publish per-field costs) and wait until the bucket has enough. After the call, read extensions.cost.throttleStatus and update the local bucket state. This turns a reactive system (retry on 429) into a proactive one (never send a call that's going to 429 in the first place).
For very large reads, don't paginate through a regular query at all. Use GraphQL bulk operations; they cost 10 points total regardless of result size and return a downloadable JSONL when complete.
Step 5: Queue at the boundary, not per-call
For a single-process tool, queue in-memory:
// Simple per-shop serial queue. One slot; parallel calls serialize.
const queueByShop = new Map<string, Promise<unknown>>();
function enqueue<T>(shopDomain: string, fn: () => Promise<T>): Promise<T> {
const prev = queueByShop.get(shopDomain) ?? Promise.resolve();
const next = prev.then(fn, fn);
queueByShop.set(
shopDomain,
next.catch(() => undefined) // keep the chain alive
);
return next as Promise<T>;
}
For multi-process systems, use Redis, SQS, or a similar coordination layer keyed on the shop domain. The important property is that no two workers can issue simultaneous Admin API calls for the same shop without seeing each other.
If you control the throughput you want, you can size the queue to stay well under the bucket capacity. For a REST standard bucket, capping at 1.5 req/s per shop leaves headroom for user-triggered calls and avoids 429 entirely.
Step 6: Make every retry observable
Instrument the wrapper. Log request URL, response status, retry count, total duration, and the bucket state before and after. You want to know:
- Which endpoints are driving most of your rate-limit pressure
- Whether retries cluster around specific times (scheduled jobs colliding)
- Whether a new feature has blown up your token consumption
A simple metric per shop (429s per hour, average retry count, average queue wait) will tell you if your queue sizing is right. I've written about the broader instrumentation pattern I use for Shopify work in the skills I use day-to-day; the admin-api-backoff skill in that set wraps exactly this logic.
Common mistakes
Retrying without honoring Retry-After. If Shopify tells you to wait 5 seconds and you retry in 1, you'll get another 429. Persistent offenders can land on a temporary block list.
Running parallel calls without a shared queue. The bucket is per-shop. Four workers issuing metafield updates against the same shop without coordination will all hit 429 in parallel, burn their retries, and possibly trigger a block.
Using REST when GraphQL bulk would cost 10x less. A loop of 1000 REST product reads uses 1000 tokens. The equivalent GraphQL bulk operation uses 10 points total. For large reads, always use bulk.
Assuming the dev store has the same limits as production. Development stores have the same rate limit logic as production standard stores, but your production store might be on Plus with a bigger bucket. Don't tune your backoff only against the dev store; check which tier your production store is on.
Forgetting that the bucket is per-app-credential. If you're using a public app with multiple install tokens, each installation has its own bucket. If you're using a custom app, all calls share one bucket. This matters when you're deciding between a custom app and a public app for a large integration.
What to try next
Once your backoff is stable, look at bulk operations for any read that returns more than a few hundred records. They're drastically cheaper and don't count against your rate limit the way repeated queries do.
For writes where latency isn't critical, consider webhooks + eventual consistency instead of polling. Shopify's webhook topics cover most of the change events you'd otherwise poll for, and receiving them doesn't count against your rate limit at all.
The backoff wrapper also pairs directly with the metafield migration sequence I run for legacy Shopify content, where a few thousand product writes need to pace themselves across the same leaky bucket.
For integrations that do serious Admin API work, custom app credentials with elevated rate limits (requested via Shopify Partners support on Plus accounts) are worth the ask. The standard budget is enough for most integrations, but a batch migration or a continuous sync is exactly the workload that justifies a higher allocation.
“The bucket is per-shop, per-app-credential. Queue at that boundary and parallel callers stop dogpiling.”
The skill version
If you're writing Shopify integrations regularly, the admin-api-backoff skill in the Claude Code Skills Pack wraps this pattern into a reusable Claude Code skill with the leaky-bucket math, queue, observability hooks, and retry semantics pre-built.
For the broader architecture these calls sit inside, see the DTC Shopify theme architecture hub. For the case study that first forced me to build this pattern, see the AI Shopify page generator case study, which documents the batch metafield workload that melted my first naive implementation.
What are the Shopify Admin API rate limits in 2026?
The REST Admin API uses a leaky bucket. Standard Shopify is 40 bucket capacity with 2 req/s refill; Advanced is 80/4; Plus is 80/4 (or higher on custom credentials). The GraphQL Admin API uses cost-based limiting at 2000 max points per query, 2000 bucket capacity, and 50 points/second restore (100 on Plus).
How do I handle a Shopify 429 response correctly?
Read the Retry-After header and wait exactly that many seconds before retrying. If it's absent, use exponential backoff with jitter, starting around 500ms and doubling per attempt. Cap total retries at 5 for a single call. Never retry a 429 immediately.
Is Shopify GraphQL Admin API cheaper than REST for reads?
Usually yes, especially at scale. A single GraphQL query can pull the equivalent of 10 to 20 REST calls at a fraction of the rate-limit cost. For very large reads, GraphQL bulk operations cost 10 points total regardless of result size and are the correct tool.
Should I use a queue for Shopify Admin API calls?
Yes, keyed on the shop domain. The rate limit bucket is per-shop-per-app-credential, so parallel callers for the same shop will collide. Single-process tools can use an in-memory serial queue; multi-process systems need Redis or an equivalent coordination layer.
Do Shopify webhooks count against Admin API rate limits?
No. Webhooks are push from Shopify to you; they don't consume your Admin API bucket. For change events (product updates, order creation, customer updates), prefer webhooks over polling. You can still acknowledge and process webhooks without rate limit pressure on the Admin API side.
Can I request a higher Shopify Admin API rate limit?
For Shopify Plus merchants and their custom apps, yes. Shopify Partners support reviews requests for higher rate limit allocations on a case-by-case basis. The standard allocation covers most integrations; ask for more only when you have a continuous sync or a large batch workload that genuinely requires it.
Sources and specifics
- Shopify's REST Admin API leaky-bucket limits as of 2026: 40/2 on standard, 80/4 on Advanced and Plus (standard apps), with higher allocations available on Plus custom apps.
- GraphQL Admin API cost-based limiting: 2000 max query cost, 2000 point bucket, 50 points/s restore on standard (100 on Plus).
- GraphQL bulk operations cost 10 points regardless of result size and return downloadable JSONL via signed URL.
- Retry-After header semantics follow standard HTTP conventions; Shopify returns seconds as a decimal integer.
- The backoff pattern documented here was built after a page generator workload hit the leaky-bucket ceiling issuing thousands of metafield writes within an hour during a Shopify integration project in Q3 2025.
