Back to blog
Engineering11 min read

Shopify API Rate Limits and Error Handling Best Practices

A comprehensive guide to Shopify API rate limits for REST and GraphQL APIs. Learn the bucket algorithm, best practices for staying under limits, and how to handle 429 errors and API failures in production.

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:

PlanBucket SizeLeak Rate
Standard (most apps)40 requests2 requests/second
Shopify Plus80 requests4 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/40

This 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.

PlanBucket SizeLeak Rate
Standard1,000 points50 points/second
Shopify Plus2,000 points100 points/second

Each GraphQL query returns its cost in the extensions field:

json
{
  "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 → inventoryLevels compounds 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.

typescript
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.

typescript
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.

typescript
// ❌ 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.

graphql
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:

typescript
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:

typescript
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.

typescript
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:

  1. 1Error rate tracking — What percentage of your API calls are failing?
  2. 2Latency monitoring — Are response times degrading before a full outage?
  3. 3Rate limit proximity — How close are you running to your rate limits?
  4. 4Incident detection — When error rates cross a threshold, trigger an alert.
  5. 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:

StatusMeaningAction
401UnauthorizedRefresh access token; re-authenticate if needed
402Payment RequiredShop's Shopify plan doesn't support this feature
403ForbiddenCheck your app's API scopes
404Not FoundResource was deleted or ID is wrong
406Not AcceptableCheck your Accept header
409ConflictResource was modified concurrently; retry with fresh data
422Unprocessable EntityValidation error; check the response body for details
429Too Many RequestsRate limited; wait for Retry-After then retry
500Internal Server ErrorShopify-side issue; retry with backoff
502Bad GatewayShopify infrastructure issue; retry with backoff
503Service UnavailableShopify is down; retry with longer backoff
504Gateway TimeoutRequest took too long; simplify query and retry

Key Takeaways

  1. 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.
  1. 1Use the headers. Every Shopify API response tells you how close you are to the limit. Read them, use them, and throttle proactively.
  1. 1Batch and bulk whenever possible. GraphQL bulk operations and batched REST requests dramatically reduce your rate limit consumption.
  1. 1Classify your errors. Not every error should be retried. Build error classification into your request pipeline to avoid wasting retries on permanent failures.
  1. 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.
  1. 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.*

Monitor your Shopify app.
Automatically.

Statufy auto-detects API issues, alerts you on Slack, and updates your status page — with zero setup.

Get Statufy for $29/mo