{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreicaes6jiyxw55cbuorhbbqcy4rd56c4pufiya26yxcqvcb6m4ibpy",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3moocnscyjrp2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreihx63c727bt4f2zgch3fbl4zyi6x55qqqxrpinrcirypinwlwk7lu"
},
"mimeType": "image/webp",
"size": 326740
},
"path": "/masadashraf/caching-shopify-graphql-a-practical-guide-for-developers-33k8",
"publishedAt": "2026-06-19T20:54:35.000Z",
"site": "https://dev.to",
"tags": [
"shopify",
"graphql",
"performance",
"webdev",
"Caching Strategies for Shopify GraphQL"
],
"textContent": "> **TL;DR:** GraphQL can't be cached by URL like REST. Cache by query + variables, layer your caches (client → edge → app → persisted queries), match TTLs to data volatility, and invalidate via webhooks. Never cache carts or customer-specific pricing.\n\n### The Core Problem\n\nREST caching is URL-based. One endpoint = one cache entry. Easy.\n\nGraphQL uses **a single endpoint** for everything. The query body defines the response, so two requests to the same URL can return totally different data. URL-based caching is useless here.\n\nYour cache key has to include the **query** + **variables** + **user context**.\n\n\n\n // Naive (broken) approach\n const key = endpoint; // same key for every query — wrong\n\n // Correct approach\n const key = hash(JSON.stringify({\n query: normalizedQuery,\n variables: sortedVariables,\n locale,\n buyerSegment\n })); // correct\n\n\nFactor | REST | GraphQL\n---|---|---\nEndpoints | Many | One\nCache key | URL | Query + variables\nGranularity | Coarse | Field-level possible\nInvalidation | Simpler | More complex\n\n### The 4 Cache Layers\n\nDon't think \"a cache.\" Think **layers** , each catching a different request type.\n\n 1. **Client cache** (Apollo / urql) — session reuse\n 2. **Edge / CDN cache** — public storefront pages\n 3. **App cache** (Redis / Memcached) — shared, semi-static data\n 4. **Persisted queries** — stable hash-based keys\n\n\n\nAll of these sit in front of the Shopify GraphQL API.\n\nLayer | Location | Best for | Typical TTL\n---|---|---|---\nClient | Browser/app | Single session | Session length\nEdge/CDN | Network edge | Public data | Minutes to hours\nApp | Your server | Shared data | Seconds to hours\nPersisted query | Server | Stable identity | Long-lived\n\n### What to Cache (and What Will Burn You)\n\n * **Cache hard:** product details, collections, shop settings\n * **Cache short:** pricing, availability (a few seconds)\n * **Never cache:** carts, checkout, customer-specific pricing\n\n\n\nData | Volatility | Approach\n---|---|---\nProduct details | Low | Cache hours, invalidate on update\nCollections | Low | Cache hours\nInventory | High | Cache seconds or skip\nPricing | Med-high | Short TTL + invalidation\nCart/checkout | Very high | Don't cache\nCustomer data | High + private | Scope per user or skip\n\nI once cached inventory too long and oversold during a launch. Learn from my pain.\n\n### Cache Invalidation: 3 Strategies\n\n**1. TTL (time-based)** — simplest, but you're guessing the window.\n\n\n\n await redis.set(key, payload, 'EX', 60); // expire in 60s\n\n\n**2. Event-based (webhooks)** — most accurate. Product updates fire a webhook, you purge the entry.\n\n\n\n // products/update webhook handler\n app.post('/webhooks/products/update', verifyHmac, async (req, res) => {\n const productId = req.body.id;\n await redis.del(`product:${productId}:*`);\n res.sendStatus(200);\n });\n\n\nA dropped webhook means stale cache. Make your consumers reliable (retries, dead-letter queues).\n\n**3. Stale-while-revalidate** — serve stale instantly, refresh in background.\n\n\n\n Cache-Control: max-age=60, stale-while-revalidate=300\n\n\nMethod | Freshness | Complexity | Best for\n---|---|---|---\nTTL | Medium | Low | Predictable data\nEvent-based | High | Med-high | Inventory, pricing\nSWR | High | Medium | Public pages\n\n### Smart Cache Keys (don't leak data!)\n\nFor B2B stores with tiered pricing, **the buyer's company must be in the key** or you'll serve Customer A's contract price to Customer B.\n\n\n\n function buildKey({ query, variables, context }) {\n return hash(JSON.stringify({\n q: normalize(query),\n v: sortObjectKeys(variables), // sort for consistency\n locale: context.locale,\n currency: context.currency,\n buyer: context.companyId ?? 'anonymous'\n }));\n }\n\n\n### Handling Personalized Data\n\nField-level splitting is the cleanest pattern:\n\n * Catalog data: cache once, globally\n * Cart + pricing: fetch fresh, per user, no cache\n\n\n\nKeep your hit rate high where it counts, fetch fresh where it matters.\n\n### Measure It\n\n * **Hit ratio** = hits / (hits + misses) — maximize\n * **Latency delta** = before vs after — should drop\n * **API calls avoided** = cache hits — cost savings\n * **Stale incidents** = wrong price/stock reports — target zero\n\n\n\n### Common Mistakes\n\n * Caching personal data in a shared key\n * TTLs so long prices go stale\n * Ignoring webhook reliability\n * Caching mutation results\n\n\n\n### Wrap-Up\n\nLayer your caches. Match TTLs to volatility. Invalidate via webhooks. Build keys that respect personalization. Measure, then tune.\n\nDone right, caching turns a throttled, sluggish app into a fast, resilient one.\n\nI wrote a longer, more detailed version with extra comparison tables and architecture notes here:\n**Caching Strategies for Shopify GraphQL**\n\nWhat's your go-to invalidation strategy? Drop it in the comments.",
"title": "Caching Shopify GraphQL: A Practical Guide for Developers"
}