Blog/Fix: Shopify API 429 Too Many Requests — The Leaky Bucket Explained
·Driple Engineering

Fix: Shopify API 429 Too Many Requests — The Leaky Bucket Explained

Getting Shopify API 429 Too Many Requests errors? Learn how Shopify's leaky bucket rate limiter works, why it breaks in production, and how to fix it with code examples.

Fix: Shopify API 429 Too Many Requests — The Leaky Bucket Explained

If you've landed here, you're probably staring at this:

HTTP/1.1 429 Too Many Requests
{
  "errors": "Exceeded 2 calls per second for api client. Reduce request rates to resume unthrottled access."
}

The Shopify API 429 Too Many Requests error means your app has exceeded the rate limit that Shopify enforces on every store. It works fine in development. It works fine with 10 stores. Then you onboard your first merchant doing 5,000 orders a day, and suddenly half your API calls fail.

This article explains exactly how Shopify's rate limiter works, why the common fixes break under real load, and what actually solves the problem.


How Shopify's Leaky Bucket Works

Shopify doesn't use a simple "X requests per minute" rate limit. They use a leaky bucket algorithm — and if you don't understand it, you'll keep getting 429s no matter how many sleep() calls you add.

Here's the mental model:

graph TD
    A["Your requests"] --> B["🪣 BUCKET (40)<br/>███████░░░░░░<br/>Current fill level"]
    B -->|"drip drip<br/>Leaks at 2 req/sec"| C["Shopify servers"]

REST Admin API

  • Bucket size: 40 requests
  • Leak rate: 2 requests per second
  • What this means: You can burst up to 40 requests instantly, but then you're limited to 2/sec until the bucket drains

The current fill level is returned in every response:

X-Shopify-Shop-Api-Call-Limit: 32/40

That header tells you 32 of your 40 slots are filled. You have 8 left before the next 429.

GraphQL Admin API

GraphQL works differently. Instead of counting requests, Shopify counts query cost points:

  • Bucket size: 1,000 points
  • Leak rate (restore): 50 points per second
  • Each query has a calculated cost based on the fields and connections you request

The throttle status comes back in the response body:

{
  "extensions": {
    "cost": {
      "requestedQueryCost": 42,
      "actualQueryCost": 12,
      "throttleStatus": {
        "maximumAvailable": 1000,
        "currentlyAvailable": 988,
        "restoreRate": 50
      }
    }
  }
}

Key insight: requestedQueryCost is what Shopify estimates before executing. actualQueryCost is what it actually consumed. If your query returns fewer results than the page size, the actual cost is lower. Smart apps use actualQueryCost for their calculations.


Why It's Worse Than You Think

The leaky bucket sounds manageable — 2 requests per second should be plenty, right? Here's why it breaks in production.

1. It Works in Dev, Breaks in Prod

Your development store has 50 products and 20 orders. A full sync takes 3 API calls. You'll never hit the bucket limit.

Your production merchant has 15,000 products, 200 variants each, and 50,000 orders. A full sync needs hundreds of paginated requests. The bucket fills up on the first page of results.

2. Parallel Workers Fill the Bucket Instantly

You're running Sidekiq, BullMQ, or any background job system. You have 5 workers processing webhooks. Each worker makes 2–3 API calls per job.

graph LR
    W1["Worker 1<br/>GET /orders/123"] --> B["🪣 Bucket<br/>35/40"]
    W2["Worker 2<br/>GET /products/456"] --> B
    W3["Worker 3<br/>PUT /inventory_levels"] --> B
    W4["Worker 4<br/>GET /orders/789"] --> B
    W5["Worker 5<br/>POST /fulfillments"] --> B
    B -->|"10+ requests<br/>in < 1 second"| S["Shopify API"]
    B -.->|"429 🚫"| W4
    B -.->|"429 🚫"| W5

That's 5+ requests hitting the bucket simultaneously. The bucket was already at 35/40 from a sync job running in the background. Workers 4 and 5 get 429s.

3. X-Shopify-Shop-Api-Call-Limit Shows "1/40" But You Still Get 429

This one confuses everyone. You check the header, see plenty of room, and still get throttled.

The reason: the header reflects the state at the time of that response. By the time your next request arrives (even milliseconds later), other processes may have filled the bucket. The header is informational, not a reservation.

4. The Missing Retry-After Header

Unlike many APIs, Shopify's 429 response does not always include a Retry-After header. You can't just read a header to know when to retry. You have to calculate it yourself based on the bucket state and leak rate.

The math:

wait_time = (current_fill - bucket_size + 1) / leak_rate

But current_fill is stale the moment you read it, so this is an estimate at best.


Solution 1: Naive Sleep + Retry

The first thing every developer tries:

async function shopifyRequest(url: string, options: RequestInit) {
  const MAX_RETRIES = 5;

  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
    const response = await fetch(url, options);

    if (response.status === 429) {
      const retryAfter = response.headers.get("Retry-After");
      const delay = retryAfter ? parseFloat(retryAfter) * 1000 : 2000;

      console.warn(
        `Rate limited. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms`
      );
      await sleep(delay);
      continue;
    }

    if (!response.ok) {
      throw new Error(`Shopify API error: ${response.status}`);
    }

    return response.json();
  }

  throw new Error("Max retries exceeded for Shopify API");
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Why this breaks:

  • Every worker retries independently. If 5 workers all get 429s, they all retry after 2 seconds, all hit the bucket again, and all get 429s again. This is the thundering herd problem.
  • You're wasting time sleeping when you could be sending requests at exactly the leak rate.
  • Under sustained load, retries compound. Your job queue backs up, timeouts start firing, and your app spirals.

This solution is fine for a hobby app. It's not fine for production.


Solution 2: Centralized Queue

The correct architectural solution is a single, centralized queue that controls all API calls to a given shop:

import { EventEmitter } from "events";

class ShopifyRateLimiter {
  private queue: Array<{
    execute: () => Promise<any>;
    resolve: (value: any) => void;
    reject: (error: any) => void;
  }> = [];
  private bucketSize = 40;
  private currentFill = 0;
  private leakRate = 2; // requests per second
  private processing = false;

  async enqueue<T>(fn: () => Promise<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      this.queue.push({ execute: fn, resolve, reject });
      this.processQueue();
    });
  }

  private async processQueue() {
    if (this.processing) return;
    this.processing = true;

    while (this.queue.length > 0) {
      if (this.currentFill >= this.bucketSize - 1) {
        // Wait for bucket to drain
        const drainTime = (1 / this.leakRate) * 1000;
        await new Promise((r) => setTimeout(r, drainTime));
        this.currentFill = Math.max(
          0,
          this.currentFill - this.leakRate * (drainTime / 1000)
        );
      }

      const item = this.queue.shift();
      if (!item) break;

      try {
        this.currentFill++;
        const result = await item.execute();
        item.resolve(result);
      } catch (error) {
        item.reject(error);
      }
    }

    this.processing = false;
  }
}

// Usage
const limiter = new ShopifyRateLimiter();

// All workers go through the same limiter
await limiter.enqueue(() =>
  fetch(`https://${shop}/admin/api/2024-01/orders.json`, {
    headers: { "X-Shopify-Access-Token": token },
  })
);

This works, but:

  • You need one limiter per shop. If you manage 500 stores, that's 500 limiter instances.
  • It only works within a single process. If you have multiple server instances (which you do, because you're running in production), you need shared state — Redis, a database, or a coordination service.
  • You have to track the bucket state yourself, handle clock drift between instances, deal with the gap between reading the X-Shopify-Shop-Api-Call-Limit header and acting on it.
  • When the limiter has a bug (and it will), every API call to every shop goes through your buggy code.

Building this correctly takes weeks. Maintaining it takes forever.


Solution 3: Use a Rate-Limit-Aware Proxy

What if you didn't have to build any of this?

A rate-limit-aware proxy sits between your app and Shopify. It reads the bucket headers, manages the queue, handles retries, and your app just makes normal HTTP requests.

The key insight: rate limiting is an infrastructure problem, not an application problem. You shouldn't be writing rate limiting code any more than you should be writing your own TLS implementation.

This is exactly what Driple does. It's a transparent proxy — you change the base URL from https://{shop}.myshopify.com to https://{shop}.driple.dev, and the proxy handles the rest.


Code Examples: Before and After

Before — Direct Shopify API call (with retry logic you have to maintain)

import Shopify from "@shopify/shopify-api";

const client = new Shopify.Clients.Rest(shop, accessToken);

async function getAllProducts() {
  let products: any[] = [];
  let pageInfo: string | undefined;

  while (true) {
    try {
      const response = await client.get({
        path: "products",
        query: { limit: "250", page_info: pageInfo },
      });

      products.push(...response.body.products);

      // Check rate limit header
      const callLimit = response.headers["x-shopify-shop-api-call-limit"];
      if (callLimit) {
        const [used, total] = callLimit.split("/").map(Number);
        if (used > total - 5) {
          // Getting close to limit, slow down
          await sleep(1000);
        }
      }

      const link = response.headers["link"];
      if (!link || !link.includes('rel="next"')) break;

      pageInfo = extractPageInfo(link);
    } catch (error: any) {
      if (error.code === 429) {
        console.warn("Rate limited, waiting 2s...");
        await sleep(2000);
        continue;
      }
      throw error;
    }
  }

  return products;
}

After — Through Driple proxy

async function getAllProducts() {
  let products: any[] = [];
  let pageInfo: string | undefined;

  while (true) {
    const url = new URL(`https://${shop}.driple.dev/admin/api/2024-01/products.json`);
    url.searchParams.set("limit", "250");
    if (pageInfo) url.searchParams.set("page_info", pageInfo);

    const response = await fetch(url.toString(), {
      headers: {
        "X-Shopify-Access-Token": accessToken,
      },
    });

    // No rate limit handling needed — Driple queues and retries for you
    const data = await response.json();
    products.push(...data.products);

    const link = response.headers.get("link");
    if (!link || !link.includes('rel="next"')) break;

    pageInfo = extractPageInfo(link);
  }

  return products;
}

No retry logic. No sleep calls. No bucket tracking. Just change the URL.

Driple sits in the middle, reads the X-Shopify-Shop-Api-Call-Limit header, and automatically queues your requests at the optimal rate. If a 429 does happen (edge cases exist), Driple retries it transparently before your app ever sees the error.


GraphQL-Specific Tips

If you're using the GraphQL Admin API, the shopify api 429 too many requests error manifests differently. Instead of an HTTP 429, you get a THROTTLED error in the response:

{
  "errors": [
    {
      "message": "Throttled",
      "extensions": {
        "code": "THROTTLED"
      }
    }
  ]
}

Here's how to minimize your query cost and avoid throttling:

1. Request Only the Fields You Need

# Bad — high cost, returns everything
{
  products(first: 100) {
    edges {
      node {
        id
        title
        description
        variants(first: 100) {
          edges {
            node {
              id
              price
              inventoryQuantity
              sku
              barcode
              weight
              # ... 20 more fields you don't need
            }
          }
        }
      }
    }
  }
}

# Good — only what you need
{
  products(first: 50) {
    edges {
      node {
        id
        title
        variants(first: 10) {
          edges {
            node {
              id
              price
            }
          }
        }
      }
    }
  }
}

2. Use actualQueryCost for Throttle Calculations

async function graphqlWithThrottle(shop: string, token: string, query: string) {
  const response = await fetch(
    `https://${shop}.myshopify.com/admin/api/2024-01/graphql.json`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Shopify-Access-Token": token,
      },
      body: JSON.stringify({ query }),
    }
  );

  const data = await response.json();

  if (data.errors?.some((e: any) => e.extensions?.code === "THROTTLED")) {
    const available =
      data.extensions?.cost?.throttleStatus?.currentlyAvailable ?? 0;
    const restoreRate =
      data.extensions?.cost?.throttleStatus?.restoreRate ?? 50;
    const waitTime = Math.ceil(
      ((1000 - available) / restoreRate) * 1000
    );

    console.warn(`GraphQL throttled. Waiting ${waitTime}ms`);
    await sleep(waitTime);

    // Retry
    return graphqlWithThrottle(shop, token, query);
  }

  return data;
}

3. Reduce Page Sizes for Nested Connections

A query requesting products(first: 100) with variants(first: 100) has a maximum cost of 100 x 100 = 10,002 (including the base query cost). That blows through the entire 1,000-point bucket in one request.

Rule of thumb: first on parent x first on child should stay under 500.


Monitoring Your Shopify Rate Limit Usage

You can't fix what you can't see. Here's a minimal monitoring setup:

interface RateLimitMetrics {
  shop: string;
  timestamp: Date;
  bucketUsed: number;
  bucketTotal: number;
  statusCode: number;
}

function trackRateLimit(
  shop: string,
  response: Response
): RateLimitMetrics {
  const callLimit = response.headers.get("x-shopify-shop-api-call-limit");
  const [used, total] = callLimit?.split("/").map(Number) ?? [0, 40];

  const metrics: RateLimitMetrics = {
    shop,
    timestamp: new Date(),
    bucketUsed: used,
    bucketTotal: total,
    statusCode: response.status,
  };

  // Log to your monitoring system
  if (used > total * 0.8) {
    console.warn(
      `[RATE LIMIT WARNING] ${shop}: ${used}/${total} (${Math.round(
        (used / total) * 100
      )}% full)`
    );
  }

  if (response.status === 429) {
    console.error(`[RATE LIMIT HIT] ${shop}: 429 Too Many Requests`);
    // Send alert to Slack, PagerDuty, etc.
  }

  return metrics;
}

Key things to track:

  • Bucket fill percentage over time — If you're consistently above 80%, you're one traffic spike away from 429s
  • 429 count per shop per hour — This is your primary alert metric
  • Retry count — If retries are growing, your queue is backing up
  • Time spent waiting on rate limits — This is latency your merchants feel

Driple provides this monitoring out of the box via a dashboard, so you don't have to instrument your own code.


Shopify API Rate Limit Quick Reference

| | REST Admin API | GraphQL Admin API | |---|---|---| | Bucket size | 40 requests | 1,000 cost points | | Leak/restore rate | 2 requests/sec | 50 points/sec | | Time to fill from empty | 20 seconds of bursting | Depends on query cost | | Time to fully drain | 20 seconds | 20 seconds | | Error format | HTTP 429 | THROTTLED error in JSON | | Limit header | X-Shopify-Shop-Api-Call-Limit | extensions.cost.throttleStatus | | Scope | Per store, per app | Per store, per app |

Note: Shopify Plus stores get higher limits (80 requests / 4 per second for REST). If your merchants are on Plus, the bucket is bigger but the same problems apply at scale.


Conclusion

The Shopify API 429 Too Many Requests error is the most common production issue for Shopify app developers. The leaky bucket algorithm is elegant but unforgiving — it works perfectly in development and fails at scale.

You have three options:

  1. Sleep and retry — works for simple apps, fails with parallelism
  2. Build a centralized rate limiter — correct but complex, takes weeks to get right
  3. Use a proxy that handles it for you — change one URL, move on to building features

The rate limiting code in your app is a liability. Every retry loop is a potential bug. Every sleep call is wasted compute. Every 429 error is a failed operation that might leave your merchant's data in an inconsistent state.

Driple is a transparent proxy that handles Shopify API rate limits for you. Change one URL, never see 429 again. Join the waitlist at driple.dev

Stop fighting rate limits.

Driple handles Shopify API rate limits so you can focus on building. Change one URL, never see 429 again.

Join the Waitlist
shopify api 429 too many requestsshopify rate limitleaky bucketX-Shopify-Shop-Api-Call-Limitshopify api rate limitingshopify graphql throttlingshopify api retry