Nadim Tuhin
Published on

Building Fault-Tolerant Shopify Apps: GraphQL Rate Limits and Webhook Pipelines

Building Fault-Tolerant Shopify Apps: GraphQL Rate Limits and Webhook Pipelines
Authors

Most custom Shopify integrations work fine in development and immediately fail during a flash sale.

The breakdown almost always follows the same pattern: an unthrottled GraphQL query consumes the entire leaky-bucket credit allowance, or a burst of webhooks arrives out of order, overwriting current order state with stale data.

Having built production backend integrations for over a decade, here is the architecture I use to keep Shopify apps resilient under heavy load.

The Shopify Admin GraphQL throttling model

Unlike REST APIs that rate-limit by the number of requests per second, Shopify's Admin GraphQL API operates on a calculated query cost model using a leaky bucket algorithm.

Every standard store has a bucket capacity of 1,000 cost points with a restore rate of 50 points per second (Shopify Plus stores have higher limits).

When you make a request, Shopify calculates the maximum possible complexity of your query before execution. If your query costs 300 points, 300 points are drained from your bucket. If you send four such queries in parallel, the fourth call returns an HTTP 429 error.

Parsing throttle status on every response

Never guess how much budget remains. Every GraphQL response includes an extensions.cost payload:

{
  "extensions": {
    "cost": {
      "requestedQueryCost": 252,
      "actualQueryCost": 124,
      "throttleStatus": {
        "maximumAvailable": 1000.0,
        "currentlyAvailable": 748.0,
        "restoreRate": 50.0
      }
    }
  }
}

A production GraphQL client must parse these numbers and back off before hitting a 429:

import { setTimeout } from 'timers/promises'

interface ThrottleStatus {
  maximumAvailable: number
  currentlyAvailable: number
  restoreRate: number
}

export async function executeShopifyGraphQL<T>(
  query: string,
  variables: Record<string, unknown> = {}
): Promise<T> {
  const response = await fetch('https://shop.myshopify.com/admin/api/2026-07/graphql.json', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!,
    },
    body: JSON.stringify({ query, variables }),
  })

  const json = await response.json()
  const throttle = json.extensions?.cost?.throttleStatus as ThrottleStatus | undefined

  if (throttle) {
    const safetyThreshold = 150
    if (throttle.currentlyAvailable < safetyThreshold) {
      const deficit = safetyThreshold - throttle.currentlyAvailable
      const waitMs = Math.ceil((deficit / throttle.restoreRate) * 1000)
      // Pause worker before dispatching next mutation
      await setTimeout(waitMs)
    }
  }

  if (json.errors) {
    throw new Error(`Shopify GraphQL Error: ${JSON.stringify(json.errors)}`)
  }

  return json.data as T
}

When to use the Bulk Operations API

If your workload synchronizes more than 250 products or orders at a time, paginating GraphQL connections is an anti-pattern. You will spend all your available cost points navigating cursors.

Instead, dispatch a asynchronous bulk query via bulkOperationRunQuery:

mutation {
  bulkOperationRunQuery(
    query: """
    {
      products {
        edges {
          node {
            id
            title
            variants {
              edges {
                node {
                  id
                  price
                  inventoryQuantity
                }
              }
            }
          }
        }
      }
    }
    """
  ) {
    bulkOperation {
      id
      status
    }
    userErrors {
      field
      message
    }
  }
}

Shopify compiles the dataset on their infrastructure and fires a bulk_operations/finish webhook containing a download URL for a JSONL file. Your application downloads the file in a single stream with zero GraphQL credit drain.

Webhook ingestion: solving the 3 production traps

Shopify webhook deliveries guarantee at-least-once delivery, not exactly-once or strictly in-order delivery.

Failure ModeProduction ImpactArchitectural Fix
Duplicate DeliveryDuplicate inventory deduction or double email dispatchesRedis distributed lock (SETNX) on X-Shopify-Webhook-Id
Out-of-Order ExecutionOlder webhook overwrites newer order updatesMonotonic timestamp verification against DB
Worker Timeout CascadesSlow DB queries cause Shopify to drop the webhook endpointDecoupled ingest: return 200 in <50ms, process in BullMQ

1. Raw buffer HMAC verification

Shopify signs payloads with SHA256 using your app's secret. A common bug is verifying the hash after body-parser has parsed JSON, which alters whitespace and breaks the digest.

import crypto from 'crypto'

export function verifyShopifyWebhook(
  rawBodyBuffer: Buffer,
  hmacHeader: string,
  secret: string
): boolean {
  const generatedHmac = crypto.createHmac('sha256', secret).update(rawBodyBuffer).digest('base64')

  return crypto.timingSafeEqual(Buffer.from(generatedHmac, 'utf8'), Buffer.from(hmacHeader, 'utf8'))
}

2. Idempotency and monotonic ordering

To prevent duplicate runs and handle delayed packets that arrive after newer ones:

import { Redis } from 'ioredis'
import { db } from './db'

export async function processOrderWebhook(
  redis: Redis,
  webhookId: string,
  payload: { id: string; updated_at: string; [key: string]: unknown }
) {
  // 1. Deduplication lock (24h TTL)
  const lockKey = `webhook:lock:${webhookId}`
  const acquired = await redis.set(lockKey, '1', 'EX', 86400, 'NX')
  if (!acquired) {
    // Already ingested; ignore duplicate delivery
    return
  }

  // 2. Monotonic order timestamp check
  const incomingTime = new Date(payload.updated_at).getTime()
  const existing = await db.orders.findUnique({
    where: { shopifyOrderId: payload.id },
    select: { lastShopifyUpdatedAt: true },
  })

  if (existing && existing.lastShopifyUpdatedAt) {
    const existingTime = existing.lastShopifyUpdatedAt.getTime()
    if (incomingTime <= existingTime) {
      // Out-of-order delivery: a newer state is already stored
      return
    }
  }

  // 3. Apply state mutation inside transaction
  await db.$transaction([
    db.orders.upsert({
      where: { shopifyOrderId: payload.id },
      update: {
        lastShopifyUpdatedAt: new Date(payload.updated_at),
        data: payload,
      },
      create: {
        shopifyOrderId: payload.id,
        lastShopifyUpdatedAt: new Date(payload.updated_at),
        data: payload,
      },
    }),
  ])
}

Embedded apps and iframe authentication

Modern browsers (Safari ITP, Chrome Privacy Sandbox) block third-party cookies by default. If your embedded app relies on standard Express session cookies inside the Shopify Admin iframe, your users will experience broken logins and redirect loops.

The only reliable approach is Shopify App Bridge Session Tokens:

  1. The frontend React component requests a short-lived JWT from App Bridge via getSessionToken(app).
  2. The JWT is attached to every API request in the Authorization: Bearer <token> header.
  3. The Node.js backend verifies the JWT using the app API secret:
import jwt from 'jsonwebtoken'

export function verifySessionToken(token: string, apiSecret: string) {
  // Verifies issuer, signature, and expiration without relying on cookies
  return jwt.verify(token, apiSecret, {
    algorithms: ['HS256'],
  }) as { dest: string; sub: string }
}

Summary architecture

  • Frontend: React + Shopify Polaris embedded via App Bridge with Session Token auth.
  • API Layer: Node.js (Fastify) with HMAC validation and fast 200 HTTP acknowledgement.
  • Queue Layer: BullMQ + Redis for asynchronous processing, rate limiting, and webhook deduplication.
  • Data Layer: PostgreSQL with monotonic timestamp guards and connection pooling via PgBouncer.
  • Bulk Engine: GraphQL Bulk Operations API for heavy product and inventory catalog updates.

Building with these guardrails from day one prevents the silent data corruption and cascading rate limits that plague naive integrations.