Every Shopify app developer hits the rate limit wall eventually. You're building a feature, testing works great, and then you deploy to production where real merchants with real data start using your app — and suddenly you're drowning in HTTP 429 responses.
Shopify's rate limiting system isn't arbitrary. It's designed to protect their infrastructure and ensure fair usage across the ecosystem. But if you don't understand how it works, you'll waste hours debugging mysterious failures and building workarounds that don't actually solve the problem.
This guide covers everything you need to know about Shopify API rate limits — how they work under the hood, how to stay within them, and how to handle errors gracefully when you inevitably exceed them.
How Shopify Rate Limits Actually Work
Shopify uses a leaky bucket algorithm for rate limiting. Think of it like a bucket that can hold a certain number of tokens. Each API request removes a token. The bucket refills at a constant rate. If the bucket is empty, your request gets rejected with a 429 status code.
REST Admin API Limits
For the REST Admin API, the limits are straightforward:
| Plan | Bucket Size | Leak Rate |
|---|---|---|
| Standard (most apps) | 40 requests | 2 requests/second |
| Shopify Plus | 80 requests | 4 requests/second |
This means:
- You can burst up to 40 requests instantly
- After that, you can sustain 2 requests per second
- If you exceed the limit, you'll get a 429 Too Many Requests response
Shopify includes rate limit information in every response header:
X-Shopify-Shop-Api-Call-Limit: 32/40This tells you: "You've used 32 of your 40 available requests." Use this header proactively to throttle your requests before hitting the wall.
GraphQL Admin API Limits
GraphQL rate limiting is more nuanced. Instead of counting requests, Shopify calculates the cost of each query based on its complexity.
| Plan | Bucket Size | Leak Rate |
|---|---|---|
| Standard | 1,000 points | 50 points/second |
| Shopify Plus | 2,000 points | 100 points/second |
Each GraphQL query returns its cost in the extensions field:
{
"extensions": {
"cost": {
"requestedQueryCost": 12,
"actualQueryCost": 8,
"throttleStatus": {
"maximumAvailable": 1000,
"currentlyAvailable": 992,
"restoreRate": 50
}
}
}
}Key insight: `requestedQueryCost` and `actualQueryCost` are often different. Shopify estimates the cost before executing the query, but the actual cost depends on how much data is returned. If your query requests 50 products but only 10 exist, the actual cost is lower.
Some tips to minimize GraphQL query costs:
- Request only the fields you need. Each field adds to the cost.
- Use pagination wisely. Requesting 250 items per page costs more than 50.
- Avoid deeply nested connections.
products → variants → inventoryLevelscompounds quickly. - Use bulk operations for large datasets. They don't count against your rate limit.
The Most Common Rate Limit Mistakes
Mistake 1: Not reading the response headers
The rate limit headers are your early warning system. If you're at 35/40, you should slow down — not continue at full speed and hope for the best.
async function shopifyRequest(url: string, options: RequestInit) {
const response = await fetch(url, options);
// Read rate limit headers
const callLimit = response.headers.get(
"X-Shopify-Shop-Api-Call-Limit"
);
if (callLimit) {
const [used, total] = callLimit.split("/").map(Number);
const remaining = total - used;
// If we're running low, pause before the next request
if (remaining <= 5) {
const waitMs = (5 - remaining + 1) * 500;
await sleep(waitMs);
}
}
return response;
}Mistake 2: Retrying 429s immediately
When you get a 429 response, Shopify includes a Retry-After header (in seconds). Respect it. Retrying immediately just wastes a request attempt and can extend your rate limit window.
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
const waitSeconds = retryAfter ? parseFloat(retryAfter) : 2;
console.log(
`Rate limited. Waiting ${waitSeconds}s before retry.`
);
await sleep(waitSeconds * 1000);
// Now retry the request
}Mistake 3: Parallel requests without throttling
Promise.all is great for performance, but firing 40 API requests simultaneously exhausts your entire bucket in one shot.
// ❌ BAD: Exhausts rate limit instantly
const products = await Promise.all(
productIds.map((id) => fetchProduct(id))
);
// ✅ GOOD: Throttled parallel requests
async function throttledMap<T, R>(
items: T[],
fn: (item: T) => Promise<R>,
concurrency = 2
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency);
const batchResults = await Promise.all(batch.map(fn));
results.push(...batchResults);
// Small delay between batches
if (i + concurrency < items.length) {
await sleep(500);
}
}
return results;
}Mistake 4: Ignoring bulk operations
If you need to process thousands of products, orders, or customers, don't paginate through the REST API one page at a time. Use Shopify's Bulk Operations feature via GraphQL.
Bulk operations run asynchronously on Shopify's servers and return a JSONL file with all the data. They don't count against your rate limit and can process millions of records efficiently.
mutation {
bulkOperationRunQuery(
query: """
{
products {
edges {
node {
id
title
variants {
edges {
node {
id
price
inventoryQuantity
}
}
}
}
}
}
}
"""
) {
bulkOperation {
id
status
}
userErrors {
field
message
}
}
}Building a Production-Grade Error Handling System
Handling rate limits is just one piece of the puzzle. A production Shopify app needs to handle the full spectrum of API errors. Let's build a comprehensive error handling system.
Error Classification
Not all errors are equal. Classify them by whether they're retryable:
function classifyError(
status: number
): "retry" | "fix" | "ignore" {
// Retryable: server issues, rate limits
if (status === 429) return "retry";
if (status >= 500 && status < 600) return "retry";
if (status === 408) return "retry"; // Request timeout
// Fix required: client errors
if (status === 401) return "fix"; // Auth expired
if (status === 403) return "fix"; // Scope missing
if (status === 404) return "fix"; // Resource deleted
if (status === 422) return "fix"; // Validation error
return "ignore";
}Retryable errors should be retried with backoff. Fix-required errors indicate a problem with your request that won't resolve by retrying — you need to update your code or configuration. Ignore errors are informational.
Request Context for Debugging
When an error occurs in production, you need context. Wrap every API call with metadata that helps you debug:
interface RequestContext {
shopDomain: string;
endpoint: string;
method: string;
attempt: number;
correlationId: string;
}
async function trackedRequest(
ctx: RequestContext,
fn: () => Promise<Response>
) {
const start = Date.now();
try {
const response = await fn();
const duration = Date.now() - start;
if (!response.ok) {
console.error(
`[API Error] ${ctx.method} ${ctx.endpoint}`,
{
shop: ctx.shopDomain,
status: response.status,
duration,
attempt: ctx.attempt,
correlationId: ctx.correlationId,
}
);
}
return response;
} catch (error) {
const duration = Date.now() - start;
console.error(`[API Exception] ${ctx.method} ${ctx.endpoint}`, {
shop: ctx.shopDomain,
error: error instanceof Error ? error.message : "Unknown",
duration,
attempt: ctx.attempt,
correlationId: ctx.correlationId,
});
throw error;
}
}Dead Letter Queue for Failed Operations
Some operations will fail permanently — after all retries are exhausted, after the circuit breaker trips, after every fallback is tried. These failures need to go somewhere you can investigate later.
interface FailedOperation {
id: string;
shopDomain: string;
operation: string;
payload: unknown;
error: string;
attempts: number;
firstFailedAt: Date;
lastFailedAt: Date;
}
async function recordFailure(op: FailedOperation) {
// Store in your database for manual review
await db.failedOperations.create(op);
// Alert the team if failures are piling up
const recentCount = await db.failedOperations.count({
where: {
shopDomain: op.shopDomain,
lastFailedAt: { gte: new Date(Date.now() - 3600000) },
},
});
if (recentCount >= 10) {
await alertTeam(
`High failure rate for ${op.shopDomain}: ${recentCount} operations failed in the last hour`
);
}
}Monitoring: The Missing Piece
You've built retry logic, circuit breakers, and error classification. But how do you know it's actually working in production? How do you know when your error rate spikes from 0.1% to 5% before merchants notice?
You need monitoring and alerting. Specifically:
- 1Error rate tracking — What percentage of your API calls are failing?
- 2Latency monitoring — Are response times degrading before a full outage?
- 3Rate limit proximity — How close are you running to your rate limits?
- 4Incident detection — When error rates cross a threshold, trigger an alert.
- 5Status communication — When something's wrong, tell your merchants.
This is a lot to build from scratch. You could cobble together Datadog for monitoring, PagerDuty for alerting, and Statuspage for communication — but that's expensive and complex for a small team.
[Statufy](https://statufy.nanocorp.app) handles all of this in one tool, built specifically for Shopify apps. It monitors your API interactions in real time, detects anomalies (including rate limit spikes and error rate increases), alerts your team on Slack, and automatically updates your public status page.
The key insight is that monitoring, alerting, and communication shouldn't be three separate tools with three separate configurations. For Shopify app developers, they should be one integrated system that understands your specific API patterns.
Quick Reference: Shopify API Error Codes
Here's a cheat sheet of common Shopify API errors and how to handle them:
| Status | Meaning | Action |
|---|---|---|
| 401 | Unauthorized | Refresh access token; re-authenticate if needed |
| 402 | Payment Required | Shop's Shopify plan doesn't support this feature |
| 403 | Forbidden | Check your app's API scopes |
| 404 | Not Found | Resource was deleted or ID is wrong |
| 406 | Not Acceptable | Check your Accept header |
| 409 | Conflict | Resource was modified concurrently; retry with fresh data |
| 422 | Unprocessable Entity | Validation error; check the response body for details |
| 429 | Too Many Requests | Rate limited; wait for Retry-After then retry |
| 500 | Internal Server Error | Shopify-side issue; retry with backoff |
| 502 | Bad Gateway | Shopify infrastructure issue; retry with backoff |
| 503 | Service Unavailable | Shopify is down; retry with longer backoff |
| 504 | Gateway Timeout | Request took too long; simplify query and retry |
Key Takeaways
- 1Understand the leaky bucket. Shopify rate limits aren't just a number — they're a refilling bucket. Plan your request patterns around the refill rate, not the bucket size.
- 1Use the headers. Every Shopify API response tells you how close you are to the limit. Read them, use them, and throttle proactively.
- 1Batch and bulk whenever possible. GraphQL bulk operations and batched REST requests dramatically reduce your rate limit consumption.
- 1Classify your errors. Not every error should be retried. Build error classification into your request pipeline to avoid wasting retries on permanent failures.
- 1Monitor in production. Rate limits and error handling that work in development often fail at scale. Monitor your error rates, latency, and rate limit usage continuously.
- 1Communicate with merchants. When things go wrong — and they will — your merchants need to know. An automated status page turns a crisis into a managed incident.
*Tired of manually monitoring your Shopify app's API health? Statufy automatically detects rate limit issues, API errors, and outages — then alerts you on Slack and updates your status page. Zero setup, $29/month.*