South Arc Digital
Case Study15 min read

Shopify GraphQL vs REST for Real-Time Inventory Sync

Shopify GraphQL vs REST API for real-time inventory sync: when GraphQL cuts cost and latency on real workloads, and three cases where REST still fits.

Vignesh Ramakrishnan

Shopify marked the REST Admin API as legacy on October 1, 2024, and starting April 1, 2025 all new public apps had to be built on the GraphQL Admin API. Product and variant REST endpoints stopped accepting writes for new apps in February 2025. This is not a suggestion. It is a migration deadline, and inventory sync is the workload where the two APIs diverge most sharply on cost, latency, and error surface.

This piece answers a narrower question than "should we migrate." For Shopify GraphQL vs REST API inventory sync specifically: when does the GraphQL Admin API reduce the API cost and end-to-end latency of a running integration, and when does REST still make sense for a build you have to ship this quarter? The answer depends on batch size, on whether your reads and writes touch multiple fields, and on how much engineering time you can spend rewriting a code path that already works. We have made this call on both sides for several Shopify integrations, and the decision is less obvious than the deprecation notice suggests. For the broader failure catalog these integrations share, see why your Shopify-to-NetSuite sync keeps breaking.

The Business Problem

Real-time inventory sync exists because oversells are expensive. A customer places an order for a SKU that shows 4 units available on the storefront but has 0 in the warehouse. The immediate cost is a refund and a "sorry, backorder" email. The larger cost is a hit to marketplace listings (Amazon suppresses SKUs with poor fulfillment records), a hit to trust (the customer does not come back), and a hit to marketing spend (the acquisition cost was paid for an order that will not be delivered).

The inventory the storefront shows is a cache. The warehouse is the source of truth. If the cache lags the warehouse by more than a few seconds, oversells accumulate. Shopify's own inventory guides frame this as one of the top three drivers of D2C churn during peak season, alongside slow shipping and inaccurate delivery estimates.

50

points per second is the GraphQL Admin API restore rate on standard plans, doubled to 100 on Shopify Plus (Shopify API limits, 2026)

The second cost is downstream reconciliation. Every wrong inventory record produces a wrong daily report, a wrong reorder decision, and a wrong cash forecast. We have seen finance teams spend two days a month reconciling inventory when the live sync drifts more than a few tenths of a percent. That is not a rounding error at scale. It is a full-time analyst wasted on a problem the API layer should have solved.

The Technical Problem

Both APIs expose the same underlying inventory service. The POST /admin/api/2024-01/inventory_levels/adjust.json REST call and the inventoryAdjustQuantities GraphQL mutation land in the same place. The difference is the meter.

REST uses a leaky bucket, per endpoint, throttled to 2 requests per second on standard plans with a burst of 40. GraphQL uses calculated query cost points, a 1,000-point bucket that refills at 50 points per second on standard plans and 2,000 points with a 100 per second refill on Shopify Plus. Most mutations cost 10 points. Simple scalar reads cost 0 or 1 point. Connection fields cost 2 points plus 1 per returned item.

The consequence is that the API you pick determines what your access pattern costs. A workload that fires one small request at a time pays roughly the same on both. A workload that reads or writes many things in one request pays dramatically less on GraphQL, because GraphQL charges for the shape of the query, not the count of HTTP calls. A workload that already runs at REST's ceiling and hits it every hour pays for the migration by not hitting the ceiling anymore. We have to compute this per workload, not per company, and the answer changes when the workload changes.

The Rate-Limit Math for Shopify Inventory Sync

The workload that forced this decision on the last integration we ran: 5,000 SKUs across 3 warehouse locations, adjusted every 15 minutes from an external inventory management system. That is 15,000 potential inventory-level updates per cycle, 60,000 per hour, when nothing has changed. Real change rate is closer to 8% of SKUs per cycle, so realistically 1,200 updates per cycle.

On the REST path, each inventory_levels/adjust.json call updates one inventory item at one location. 1,200 updates per cycle at 2 requests per second sustained is 10 minutes of API time per cycle for a 15-minute window. The bucket refills, so bursts are absorbed, but if the change rate ever spikes past the average, the sync starts lagging and the queue grows. On a standard plan under any real burst, you are one Black Friday morning from a sync that never catches up.

On the GraphQL path, inventoryAdjustQuantities accepts a changes array. We batch 100 changes per mutation. 1,200 updates becomes 12 mutations, at roughly 10 points each, so 120 points per cycle. The bucket has 1,000 points and refills 50 per second. Even if the entire batch fires in one second, you consume 120 points against a 1,000-point ceiling and refill in 2.4 seconds. The workload finishes in under a second of API time.

# graphql/mutations/inventory-adjust.graphql
mutation inventoryAdjustQuantities(
  $input: InventoryAdjustQuantitiesInput!
) {
  inventoryAdjustQuantities(input: $input) {
    inventoryAdjustmentGroup {
      createdAt
      reason
      changes {
        name
        delta
        quantityAfterChange
      }
    }
    userErrors { field message }
  }
}
// services/shopify/inventory.ts
export async function batchAdjust(changes: InventoryChange[]) {
  const batches = chunk(changes, 100);
  for (const batch of batches) {
    await shopify.graphql(INVENTORY_ADJUST, {
      input: {
        reason: "correction",
        name: "available",
        referenceDocumentUri: `logistics://sync/${runId}`,
        changes: batch,
      },
    });
  }
}

The referenceDocumentUri is the idempotency anchor. As of API version 2026-04, Shopify made idempotency mandatory for inventory adjustments and refund mutations. Old code that passed the same delta twice without a unique reference now fails, correctly.

When REST Wins

REST still wins for three shapes of workload. We have kept REST in production for all three.

The first is small stores with sub-100 SKU update workloads and no plans to grow past that. A shop doing 40 inventory updates a day against 200 total SKUs will never approach either bucket. The REST call is three lines. The GraphQL equivalent requires a query file, a batching layer, a cost budget, and error handling for userErrors versus network errors versus rate-limit rejections. For a Shopify-only single-store deployment where the integration is a Google Apps Script that runs when a warehouse manager clicks a button, REST is honest engineering. GraphQL is over-engineering.

The second is simple single-endpoint syncs where the parsing overhead of GraphQL isn't worth it. A webhook handler that receives orders/create, extracts three fields, writes to a database, and calls POST /shipping.json on a 3PL is not going to benefit from GraphQL. There is no multi-field read, no batching, no cost pressure. Adding GraphQL to that path introduces a client library, a schema fetch, and a code review for a call that was already correct.

The third is teams that already have production REST-based tooling and cannot afford a migration this quarter. This is the case we see most often. A company has a working integration, a runbook, a set of dashboards keyed off REST response codes, and a queue that has proven itself through two peak seasons. The engineering cost to migrate is real. The risk of migrating is real. If the workload is not hitting rate limits and the API version they are on has not yet been deprecated for the endpoints they use, the correct answer is to plan the migration for next year and instrument the current sync so you know when to move.

// services/shopify/rest-legacy.ts
// This works. It has worked for two years. If it is not broken,
// the deprecation timeline for THIS specific endpoint is the trigger,
// not a general-purpose "GraphQL is faster" argument.
export async function adjustLevelRest(
  inventoryItemId: number,
  locationId: number,
  availableAdjustment: number,
) {
  return shopify.rest.post("/admin/api/2024-01/inventory_levels/adjust.json", {
    inventory_item_id: inventoryItemId,
    location_id: locationId,
    available_adjustment: availableAdjustment,
  });
}

Notice the version pin. Any REST inventory call in production right now needs an owner who is watching the Shopify changelog for that specific endpoint. inventory_levels/adjust.json is on the deprecation glide path; the exact retirement date for existing apps has not been announced, but the direction is set.

Do not read "REST still wins for these three shapes" as permission to build new integrations on REST. All three shapes above are about NOT rewriting a working system yet. New builds in 2026 should target GraphQL from day one. The migration cost only goes up.

When GraphQL Wins

GraphQL wins for three workload shapes, and they are the ones a modern inventory sync usually needs.

The first is multi-field reads. You want SKU, current available quantity, per-location breakdown, item cost, and last-updated timestamp in one request. REST needs three or four calls to assemble that view, one per resource, with the client stitching the results. GraphQL asks for the exact shape you need and returns it in one round trip at a total cost of 5 to 15 points depending on how many locations you include. The saving is not just points. It is latency: one TCP round trip instead of four, and a payload that already matches your internal data model.

The second is bulk mutations. inventoryAdjustQuantities accepts up to 100 changes per call. inventorySetQuantities accepts the same. If you are pushing inventory from an external source, batching is the correct pattern and REST does not offer it. The math from the previous section extends: a workload that would consume the entire REST bucket at peak fits inside 15% of the GraphQL bucket, on the same underlying resources.

The third is the webhook-plus-fetch pattern. Shopify webhooks carry a minimal payload. inventory_levels/update gives you an inventory item id, a location id, and an available count. If your handler needs the SKU, the product title, or the vendor to make a routing decision, you have to fetch. On REST, that is one or two additional calls. On GraphQL, you fetch exactly what you need in one query, at cost proportional to the fields.

# graphql/queries/enrich-webhook.graphql
query enrichInventoryWebhook($id: ID!) {
  inventoryItem(id: $id) {
    id
    sku
    tracked
    variant {
      id
      title
      product { id title vendor }
    }
    inventoryLevels(first: 10) {
      edges {
        node {
          location { id name }
          quantities(names: ["available", "committed", "on_hand"]) {
            name
            quantity
          }
        }
      }
    }
  }
}

That query returns everything a routing decision needs. Requested cost budgets around 15 points; actual cost after execution is usually 6 to 8 because most inventory items have fewer than 10 locations. Shopify refunds the difference between requested and actual to your bucket, which is the mechanic that makes worst-case-static analysis workable in practice.

REST inventory sync. 4 separate calls per webhook to assemble SKU, product, location, and quantities. 2 requests per second sustained ceiling. 15,000 requests per 15-minute sync cycle.

GraphQL bulk mutation and enrichment. 1 query per webhook, 12 batched mutations per sync cycle. 50 points per second restore, 1,000-point bucket. 120 points per cycle against a 1,000-point ceiling.

Migration Path from REST to GraphQL

For teams already on REST, the migration is not a rewrite. It is a phased swap with a shadow-run in the middle. We have run this sequence twice and it survives the failure modes of the systems it replaces.

Step one: wrap every REST call in an interface. Not an abstraction that hides HTTP, an interface that hides which API you are calling. The methods are what the business needs: adjustInventory, readInventoryForSku, readOrderForFulfillment. Behind the interface, the initial implementation calls REST. Nothing else changes yet.

Step two: add a GraphQL implementation behind the same interface. Do not ship it. Register it in a factory keyed by an environment variable. Instrument both implementations to log requested cost, actual cost, latency, and error codes. This is where you find out that your query cost estimate was wrong on 8% of calls because a specific variant has 47 metafields you did not expect.

Step three: shadow-run. For every real REST call in production, fire the GraphQL equivalent asynchronously, discard the response, and compare metadata. Bucket depth used, response shape, error taxonomy. This is the step teams skip. It is the step that catches the field that GraphQL returns as null where REST returned an empty array, and the mutation that succeeds on REST but returns a userErrors entry on GraphQL that your caller ignores.

Step four: switch traffic per endpoint. Not per store. Not per app. Per endpoint. Move inventory reads first because they are the highest cost on REST and the lowest risk on GraphQL. Move mutations second, one at a time, with rollback ready. Move webhook enrichment last because it touches the most upstream code.

// services/shopify/client.ts
export interface ShopifyInventoryClient {
  adjustInventory(changes: InventoryChange[]): Promise<AdjustResult>;
  readInventoryForSku(sku: string): Promise<InventorySnapshot>;
}
 
export function makeClient(): ShopifyInventoryClient {
  const impl = process.env.SHOPIFY_INVENTORY_IMPL ?? "rest";
  return impl === "graphql" ? new GraphQLInventoryClient() : new RestInventoryClient();
}

Do not big-bang migrate. We have seen a big-bang cutover take down a live inventory sync for two hours because a metafield the REST implementation ignored was required by the GraphQL query the migrated version wrote.

Webhook Plus API Pattern

Real-time inventory sync on Shopify is almost never a polling loop. It is webhook-driven, with polling as a fallback. Both APIs support the same webhook topics: inventory_levels/update, inventory_items/update, products/update, and the fulfillment topics that indirectly move inventory. The question is not which API you subscribe to. The question is what your handler does inside.

The pattern that survives peak load: the webhook fires, the handler decodes the payload, writes an idempotency record keyed by Shopify's inventory item id and the webhook's X-Shopify-Webhook-Id header, and enqueues a job. The job pulls a batch of pending changes, calls one GraphQL mutation to apply the batch, and marks each record as processed. The handler returns 200 in under 500ms. The job runs at the tempo the API can sustain.

The two mistakes we see most often. First, calling the API from inside the webhook handler. Shopify retries webhooks aggressively when the handler is slow, and each retry fires another API call, which slows the handler further. The failure spirals. Second, keying idempotency on the webhook id instead of the business object. Shopify may deliver the same inventory change through two different webhooks (the direct topic and an order-related topic). Business-level idempotency, keyed on the inventory item and the delta reason, is what prevents double-application.

The API you pick is a smaller decision than what your webhook handler does inside. Bulk mutations and per-item idempotency matter more than REST versus GraphQL.

What This Architecture Enabled

  • API cost dropped from a sustained 85% bucket utilization on REST to a 12% utilization on GraphQL, measured over a two-week peak window. Headroom absorbs the Black Friday spike that used to force a manual queue drain.
  • Webhook handler p95 latency fell from 340ms (with inline REST enrichment) to 90ms (enqueue only, GraphQL batch in worker).
  • Oversell rate on a 1,800-SKU catalog dropped from 0.9% of orders to 0.1% of orders after the sync moved to GraphQL batches and the reconciliation cadence tightened from hourly to every 15 minutes.
  • End-to-end inventory sync latency, from warehouse commit to storefront reflection, dropped from a p95 of 3 minutes to a p95 of 22 seconds.

Observations

What worked. Cost point budgeting per workload, computed before we shipped, and instrumented after we shipped. Batching mutations at 100 items per call. Keeping the webhook handler thin and the API calls in a worker. Wrapping REST behind an interface and shadow-running GraphQL for a week before switching traffic.

What didn't. Assuming GraphQL was universally faster. It is not. For a single-record read of a scalar field, GraphQL adds parsing overhead that REST does not have. For a mutation that already fits inside REST's leaky bucket at low utilization, the migration is engineering effort with no user-visible payoff. We over-invested in a GraphQL wrapper for a client whose entire integration was a nightly full-inventory push, where the correct answer was Bulk Operations, not per-mutation calls at all.

What we'd do differently. Instrument API cost from day one, not after the first rate-limit incident. The Shopify GraphQL response headers expose X-GraphQL-Cost-Include-Fields and the throttle status; the REST response headers expose X-Shopify-Shop-Api-Call-Limit. Log both, alert on both, review both weekly. Second, use Bulk Operations for the inventory backfill on day one instead of iterating small mutations. A 25,000-SKU initial sync that took 4 hours through mutation batching finishes in 20 minutes as a bulk operation. If you have a similar cost decision on the connector layer, choosing a Shopify-NetSuite connector walks through the same year-two cost shape from a different angle.

References

  1. Shopify, "About Shopify API versioning," Shopify developer docs, 2026. https://shopify.dev/docs/api/usage/versioning
  2. Shopify, "Shopify API rate limits," Shopify developer docs, 2026. https://shopify.dev/docs/api/usage/limits
  3. Shopify, "inventoryAdjustQuantities GraphQL mutation," Shopify Admin GraphQL API reference, 2026. https://shopify.dev/docs/api/admin-graphql/latest/mutations/inventoryAdjustQuantities
  4. Shopify, "Perform bulk operations with the GraphQL Admin API," Shopify developer docs, 2026. https://shopify.dev/docs/api/usage/bulk-operations
  5. Shopify Engineering, "Making idempotency mandatory for inventory adjustments and refund mutations," Shopify developer changelog, April 2026. https://shopify.dev/changelog/making-idempotency-mandatory-for-inventory-adjustments-and-refund-mutations
  6. Shopify Engineering, "Rate Limiting GraphQL APIs by Calculating Query Complexity," Shopify Engineering blog, 2018 (still the canonical explanation of the cost model). https://shopify.engineering/rate-limiting-graphql-apis-calculating-query-complexity

Stack: Node.js 20, TypeScript, Shopify Admin GraphQL API 2026-04, Postgres for idempotency and job state, a BullMQ worker on Redis for webhook fan-out, deployed on Vercel for the webhook edge and Google Cloud Run for the workers. Orchestration and reconciliation cadences are managed in n8n.


Want to build something like this?

We design and ship AI products, automation systems, and custom software.

Get in touch

Related