Why Shopify Rate-Limits You (And Why Your Integration Fails)

Shopify's APIs enforce rate limiting. Not to be annoying. To protect their infrastructure from clients who hammer their servers with 1,000 requests per second.

But most integrations don't handle rate limiting gracefully. They just crash. A webhook handler that queues too fast doesn't implement backoff and slams the API with retries. An inventory sync that runs 4 times per hour hits the limit by 11 AM. A batch import tool that fires 50 parallel requests gets 429 throttled and fails completely.

The result: broken integrations, missed orders, silent data loss.

Understanding Shopify's rate limits and implementing proper retry logic separates integrations that work from integrations that die under load.

Understanding Shopify's Rate Limit Buckets

Shopify's rate limiting is complex because it's bucket-based, not per-request-based. This matters.

Shopify has two rate limit buckets: API call limit and GraphQL query complexity limit.

The API Call Limit (REST & GraphQL)

  • You get 2 points per second (burst capacity)
  • You regenerate 1 point every 0.5 seconds
  • So you can make up to 2 calls immediately, then 1 call every 0.5 seconds indefinitely
  • Or: 120 calls per minute sustained, 240 calls in a 2-second burst

This is the same for all Shopify plans (Standard, Plus, etc.). No plan differentiation.

Example timeline:

t=0.0s: You have 2 points. Make 1 call. Points: 1
t=0.0s: Make another call. Points: 0
t=0.5s: Regenerate 1 point. Points: 1
t=1.0s: Regenerate 1 point. Points: 2
t=1.0s: Make 1 call. Points: 1
t=1.0s: Make 1 call. Points: 0

The GraphQL Query Complexity Limit

  • Separate from API call count
  • Each GraphQL query has a "complexity cost" based on the operations and fields requested
  • You get 1,000 complexity points per minute
  • A simple query (e.g., fetch product ID and title): cost ~10-15 points
  • A complex query (fetch product details + collections + images + inventory): cost ~100-200 points

So you can make 60-100 simple queries per minute, but only 5-10 complex queries per minute.

Example query costs:

# Cost: ~20 points (simple)
{
  product(id: "gid://shopify/Product/123") {
    id
    title
  }
}

# Cost: ~150 points (moderate)
{
  product(id: "gid://shopify/Product/123") {
    id
    title
    variants(first: 10) {
      edges {
        node {
          id
          sku
          inventory {
            available
          }
        }
      }
    }
  }
}

# Cost: ~500+ points (very complex)
{
  product(id: "gid://shopify/Product/123") {
    id
    title
    variants(first: 100) {
      edges {
        node {
          id
          sku
          pricing
          inventory
          images(first: 20) { ... }
          metafields(first: 50) { ... }
          translations(first: 10) { ... }
        }
      }
    }
    collections(first: 50) { ... }
    seo { ... }
  }
}

The first query lets you make 50+ queries per minute. The third query lets you make 2 queries per minute.

This is why most integrations that "work" in development explode in production—they never tested with realistic data complexity.

How to Detect Rate Limiting (Before It Breaks Your App)

Shopify sends rate limit headers with every response. If you don't read them, you'll hit limits blindly.

REST API Rate Limit Headers

HTTP/1.1 200 OK
X-Request-Id: 12345abc
X-Shopify-Shop-Api-Call-Limit: 32/40
Date: Wed, 09 Apr 2026 14:23:45 GMT

X-Shopify-Shop-Api-Call-Limit: 32/40 means: "You've made 32 calls in your 40-call bucket. You have 8 calls left before you're throttled."

When you hit the limit, Shopify returns:

HTTP/1.1 429 Too Many Requests
Retry-After: 5
X-Shopify-Shop-Api-Call-Limit: 40/40

{
  "errors": "Throttled"
}

Retry-After: 5 means "wait 5 seconds before retrying." Always respect this header.

GraphQL API Rate Limit Headers

HTTP/1.1 200 OK
X-GraphQL-Cost: 100
X-GraphQL-Cost-Limit: 1000/1000

X-GraphQL-Cost: 100 = your query cost 100 points.
X-GraphQL-Cost-Limit: 1000/1000 = you've used 1000 out of 1000 points for the minute. Next call will be throttled.

When you hit the complexity limit:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-GraphQL-Cost: 200
X-GraphQL-Cost-Limit: 1000/1000

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

Retry-After: 60 means "wait 60 seconds." If you ignore this and retry immediately, you'll be throttled again.

Implementing Exponential Backoff (The Right Way)

Most developers implement backoff wrong. They do:

# WRONG: Linear backoff
for attempt in range(5):
    try:
        response = call_shopify_api()
        return response
    except RateLimitError:
        time.sleep(5)  # Always wait 5 seconds

This fails because:

  1. If you're under heavy load (1000 requests queued), waiting 5 seconds doesn't help—you'll hit the limit again when you resume.
  2. You ignore the Retry-After header (Shopify tells you how long to wait; ignoring it is wasteful).
  3. Linear backoff doesn't distribute load—all your queued requests wait 5 seconds, then fire simultaneously, causing another spike.

Correct implementation: Exponential backoff with jitter

import time
import random
from shopify import ShopifyAPIError

def call_api_with_backoff(api_call, max_retries=5):
    for attempt in range(max_retries):
        try:
            response = api_call()
            return response
        except ShopifyAPIError as e:
            if e.status_code != 429:
                raise  # Not a rate limit error; re-raise
            
            # Read Retry-After header if available
            retry_after = int(e.headers.get('Retry-After', 5))
            
            # Exponential backoff: 2^attempt seconds + jitter
            base_wait = min(2 ** attempt, 32)  # Cap at 32 seconds
            jitter = random.uniform(0, 0.1 * base_wait)  # Random ±10%
            wait_time = max(retry_after, base_wait + jitter)
            
            print(f"Rate limited. Attempt {attempt+1}/{max_retries}. Waiting {wait_time:.2f}s")
            time.sleep(wait_time)
    
    raise Exception("Max retries exceeded")

This implements:

  1. Respects Retry-After: Uses the header value as a minimum wait time
  2. Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s+
  3. Jitter: Adds randomness to prevent thundering herd (all retries firing simultaneously)

For GraphQL specifically, also reduce query complexity on retry:

def call_graphql_with_backoff(query, variables):
    for attempt in range(3):
        try:
            response = shopify_gql(query, variables)
            # Check X-GraphQL-Cost-Limit
            if response.headers.get('X-GraphQL-Cost-Limit') == '1000/1000':
                print("Approaching GraphQL limit. Reducing batch size.")
            return response
        except GraphQLThrottledError:
            # On GraphQL throttle, reduce batch size
            if 'first: 100' in query:
                query = query.replace('first: 100', 'first: 50')
            elif 'first: 50' in query:
                query = query.replace('first: 50', 'first: 25')
            
            wait_time = 2 ** attempt + random.uniform(0, 1)
            time.sleep(wait_time)
    
    raise Exception("GraphQL throttled after retries")

Architectural Patterns: Queue-Based Processing

The best way to avoid rate limits entirely is to not slam the API with requests.

Pattern 1: Background Job Queues

Instead of making API calls synchronously:

# WRONG: Synchronous, no backoff
for order in orders:
    inventory = shopify.inventory_fetch(order.product_id)
    update_local_db(inventory)

Use a job queue (Bull, Celery, SQS):

# RIGHT: Async queue with rate limiting
for order in orders:
    queue.enqueue(
        'fetch_inventory',
        order.product_id,
        scheduled_in=randint(0, 60)  # Spread requests over 60 seconds
    )

# Worker processes jobs at controlled rate
@job_queue.task()
def fetch_inventory(product_id):
    inventory = shopify.inventory_fetch(product_id)
    update_local_db(inventory)

This spreads requests over time, preventing burst overload.

Pattern 2: Batch Operations

Instead of individual API calls:

# WRONG: 100 API calls
for product_id in product_ids:
    response = shopify.fetch_product(product_id)

Batch into fewer, more complex calls:

# RIGHT: 4 API calls (batch 25 per call, assuming complexity allows)
batches = [product_ids[i:i+25] for i in range(0, len(product_ids), 25)]
for batch in batches:
    products = shopify.fetch_products_batch(batch)

This reduces call count by 96% (100 calls → 4 calls).

Pattern 3: Caching

Cache API responses locally to reduce repeat calls:

import hashlib
from datetime import timedelta

def fetch_product_cached(product_id, ttl_minutes=60):
    cache_key = f"product_{product_id}"
    
    # Check local cache first
    cached = cache.get(cache_key)
    if cached and not cached.is_expired():
        return cached.data
    
    # Not in cache or expired; fetch from API
    product = shopify.fetch_product(product_id)
    
    # Store in cache with TTL
    cache.set(cache_key, product, ttl=timedelta(minutes=ttl_minutes))
    return product

With caching, a product that's accessed 50 times per day requires only 1 API call, not 50.

Real-World Example: Inventory Sync Integration

A typical problem: syncing Shopify inventory to an ERP system.

The naive approach (breaks immediately):

# This will be rate limited
for product in all_products:
    for variant in product.variants:
        shopify.fetch_inventory(variant.id)
        erp.update_inventory(variant.id, inventory_count)

The production approach:

import queue
import threading
import time

class InventorySyncWorker:
    def __init__(self, num_workers=4):
        self.job_queue = queue.Queue()
        self.workers = [
            threading.Thread(target=self._worker, daemon=True)
            for _ in range(num_workers)
        ]
        for w in self.workers:
            w.start()
    
    def _worker(self):
        while True:
            variant_id = self.job_queue.get()
            try:
                inventory = self._fetch_with_backoff(variant_id)
                self.erp.update_inventory(variant_id, inventory)
            except Exception as e:
                print(f"Failed to sync {variant_id}: {e}")
            finally:
                self.job_queue.task_done()
    
    def _fetch_with_backoff(self, variant_id, max_retries=3):
        for attempt in range(max_retries):
            try:
                return shopify.fetch_inventory(variant_id)
            except RateLimitError:
                retry_after = 2 ** attempt
                print(f"Rate limited. Retrying in {retry_after}s")
                time.sleep(retry_after)
        raise Exception(f"Failed to fetch {variant_id} after {max_retries} retries")
    
    def sync_all_products(self, products):
        for product in products:
            for variant in product.variants:
                self.job_queue.put(variant.id)
        
        self.job_queue.join()  # Wait for all jobs to complete

# Usage
syncer = InventorySyncWorker(num_workers=4)
syncer.sync_all_products(all_products)

This approach:

  • Spreads requests across 4 worker threads
  • Implements exponential backoff on rate limits
  • Respects Shopify's rate limits by design
  • Can sync 1000s of products without ever hitting 429

Monitoring Rate Limit Health

Track your rate limit usage to catch problems early:

class RateLimitMonitor:
    def __init__(self):
        self.api_call_usage = []
        self.complexity_usage = []
    
    def log_api_call(self, headers):
        call_limit = headers.get('X-Shopify-Shop-Api-Call-Limit')
        if call_limit:
            current, max_cap = map(int, call_limit.split('/'))
            self.api_call_usage.append({
                'timestamp': time.time(),
                'current': current,
                'max': max_cap,
                'utilization': current / max_cap
            })
            
            if current >= max_cap * 0.8:  # Alert at 80% utilization
                print(f"WARNING: API call limit at {current}/{max_cap}")
    
    def log_graphql_call(self, headers):
        cost_limit = headers.get('X-GraphQL-Cost-Limit')
        if cost_limit:
            current, max_cap = map(int, cost_limit.split('/'))
            self.complexity_usage.append({
                'timestamp': time.time(),
                'current': current,
                'max': max_cap,
                'utilization': current / max_cap
            })
            
            if current >= max_cap * 0.9:  # Alert at 90% utilization
                print(f"WARNING: GraphQL complexity limit at {current}/{max_cap}")

Common Mistakes That Cause Silent Failures

Mistake 1: Ignoring the Retry-After header

  • Shopify tells you how long to wait; ignoring it wastes your time and theirs
  • Always use Retry-After as minimum wait time

Mistake 2: Retrying with the same query complexity

  • If a complex GraphQL query triggered a 429, reduce the query complexity before retrying
  • Fetch 50 products instead of 100; reduces complexity by 50%

Mistake 3: No monitoring or alerting

  • You won't know you're hitting rate limits until orders fail to sync
  • Track X-Shopify-Shop-Api-Call-Limit and X-GraphQL-Cost-Limit headers
  • Alert when utilization exceeds 80%

Mistake 4: Using per-request rate limiting instead of bucket-based logic

  • The naive approach: "I'll sleep 1 second between requests"
  • This doesn't work. Shopify uses token buckets, not per-request limiting
  • You can burst 2 calls immediately; space remaining calls at 0.5s intervals

Mistake 5: Not testing under load

  • Your integration works fine with 10 products; breaks with 10,000
  • Load test before going live (use Locust, k6, or Apache JMeter)
  • Simulate realistic concurrent requests

Ready to Build Rate-Limit-Resilient Integrations?

Rate limiting isn't a bug in Shopify's API. It's a feature that protects their infrastructure and ensures fair access for all merchants.

Understanding it—and building your integration to respect it—is the difference between working systems and failing systems.

Tenten's integration team builds queue-based, backoff-aware, production-grade Shopify API clients. We've integrated 50+ Shopify stores with ERP, accounting, marketing automation, and fulfillment platforms. All of them handle rate limits gracefully.

Let's build yours.


Editorial Note
Rate limiting breaks integrations that don't respect it. Most developers don't discover this until they scale to 1000s of daily API calls. Implement exponential backoff, batch operations, and caching from day one. Your future self will thank you.

Frequently Asked Questions

What's the difference between REST and GraphQL rate limits?

REST uses call count (2 points/sec). GraphQL uses complexity cost (1000 points/min). REST is faster for simple queries; GraphQL is faster for complex queries. Use GraphQL for fetching multiple related entities (product + variants + inventory in 1 query). Use REST for simple CRUD operations.

Can I request a higher rate limit from Shopify?

No. Rate limits are fixed at 2 API calls/sec for all stores. This applies to Standard, Pro, and Plus plans equally. Shopify's reasoning: if they allowed higher limits for large merchants, they'd be inundated with requests and the API would become unreliable. Everyone gets the same limits; the difference is how you use them (batching, caching, queue-based processing).

What happens if I exceed my rate limit?

You get a 429 Too Many Requests response with a Retry-After header. If you ignore it and keep retrying, Shopify will temporarily block your API key (usually 15-60 minutes). For Shopify Plus, you might face escalation to the Shopify team. Implement backoff; it's not hard.

Should I use REST or GraphQL for my integration?

GraphQL if you're fetching complex nested data (product + variants + inventory). REST if you're doing simple CRUD. GraphQL is usually 5-10x fewer API calls for complex operations. But REST is simpler to implement. Start with what's easiest; optimize to GraphQL if you hit rate limits.

How can I reduce my API call count?

Batch operations (fetch 50 products in 1 call instead of 50 calls). Implement caching (don't fetch data you already have locally). Use webhooks (let Shopify push data to you instead of polling). Use GraphQL instead of REST for complex queries.