Shopify GraphQL Bulk Operations for Large Catalog Sync: 2026 Playbook
Scale Shopify catalog sync beyond count/query limits with bulk operations, JSONL pipelines, throttle-safe writes, and failure recovery.
Large catalog teams break sync pipelines by relying on synchronous queries and naive count checks. At scale, the API limit model forces asynchronous extraction and controlled write batching.
Failure pattern in high-SKU operations
What usually fails first:
- Sync jobs depend on
productsCountand treat10000+as a real count. - Pagination loops are interrupted by throttle or timeout.
- Delta logic is based on stale snapshots and misses writes.
- Mutation batches overload query cost budget and fail mid-run.
Core architecture for stable sync
Use a four-stage flow:
- Bulk extract source snapshot.
- Build deterministic diff offline.
- Apply writes in bounded batches.
- Verify post-write state with audit queries.
Run bulk extraction instead of synchronous scans
mutation BulkCatalogSnapshot {
bulkOperationRunQuery(
query: """
{
products {
edges {
node {
id
handle
title
updatedAt
variants {
edges {
node {
id
sku
price
compareAtPrice
inventoryItem {
id
}
}
}
}
}
}
}
}
"""
) {
bulkOperation {
id
status
}
userErrors {
field
message
}
}
}
Poll status and download URL when completed.
query CurrentBulk {
currentBulkOperation(type: QUERY) {
id
status
errorCode
createdAt
completedAt
objectCount
fileSize
url
}
}
Shopify supports one running bulk operation per type per shop, so orchestrate jobs accordingly.
Parse JSONL snapshot deterministically
import fs from "node:fs";
import readline from "node:readline";
type ProductNode = {
id: string;
handle: string;
updatedAt: string;
};
export async function parseBulkJsonl(filePath: string) {
const stream = fs.createReadStream(filePath, "utf8");
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
const products = new Map<string, ProductNode>();
for await (const line of rl) {
if (!line) continue;
const row = JSON.parse(line);
if (row.id?.startsWith("gid://shopify/Product/")) {
products.set(row.id, {
id: row.id,
handle: row.handle,
updatedAt: row.updatedAt,
});
}
}
return products;
}
Diff against your source ERP/PIM snapshot and produce explicit action lists.
Write updates in bounded mutation batches
Keep write payloads small and deterministic. If one batch fails, re-run only that chunk.
function chunkArray<T>(items: T[], size: number) {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
async function applyPriceUpdates(updates: Array<{ variantId: string; amount: string }>) {
for (const batch of chunkArray(updates, 50)) {
await adminGraphql(PRICE_UPDATE_MUTATION, { updates: batch });
await sleep(120);
}
}
Track userErrors and request cost metrics for every batch.
Don’t trust count-only control logic
Community reports confirm productsCount can cap at 10000+, which breaks job progress logic. Use bulk objectCount and your own processed row counters instead.
Failure recovery design
Store run checkpoints
Persist:
- bulk operation ID,
- downloaded snapshot checksum,
- last successful write batch index,
- failed entity list.
Re-run from checkpoint
Never restart full catalog sync if only batch 47/300 failed.
Technical Checklist
- Sync pipeline uses bulk extraction for large catalog snapshots.
- Job orchestration respects one running bulk operation per type.
- JSONL parsing builds deterministic diff artifacts.
- Mutation writes are chunked and retryable with checkpoints.
- Count/cursor logic does not rely on truncated count strings.
- Batch-level telemetry captures request cost and
userErrors.
SEO Checklist
- Handle and canonical-critical fields are protected from accidental overwrite.
- Title/meta updates are validated before bulk publish.
- Collection/product visibility flags are not changed by stale deltas.
- Structured data fields remain consistent after catalog sync runs.
- Sync rollback plan exists for SEO-critical attribute regressions.