Caching Shopify GraphQL: A Practical Guide for Developers
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.
The Core Problem
REST caching is URL-based. One endpoint = one cache entry. Easy.
GraphQL 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.
Your cache key has to include the query + variables + user context.
// Naive (broken) approach
const key = endpoint; // same key for every query — wrong
// Correct approach
const key = hash(JSON.stringify({
query: normalizedQuery,
variables: sortedVariables,
locale,
buyerSegment
})); // correct
| Factor | REST | GraphQL |
|---|---|---|
| Endpoints | Many | One |
| Cache key | URL | Query + variables |
| Granularity | Coarse | Field-level possible |
| Invalidation | Simpler | More complex |
The 4 Cache Layers
Don't think "a cache." Think layers , each catching a different request type.
- Client cache (Apollo / urql) — session reuse
- Edge / CDN cache — public storefront pages
- App cache (Redis / Memcached) — shared, semi-static data
- Persisted queries — stable hash-based keys
All of these sit in front of the Shopify GraphQL API.
| Layer | Location | Best for | Typical TTL |
|---|---|---|---|
| Client | Browser/app | Single session | Session length |
| Edge/CDN | Network edge | Public data | Minutes to hours |
| App | Your server | Shared data | Seconds to hours |
| Persisted query | Server | Stable identity | Long-lived |
What to Cache (and What Will Burn You)
- Cache hard: product details, collections, shop settings
- Cache short: pricing, availability (a few seconds)
- Never cache: carts, checkout, customer-specific pricing
| Data | Volatility | Approach |
|---|---|---|
| Product details | Low | Cache hours, invalidate on update |
| Collections | Low | Cache hours |
| Inventory | High | Cache seconds or skip |
| Pricing | Med-high | Short TTL + invalidation |
| Cart/checkout | Very high | Don't cache |
| Customer data | High + private | Scope per user or skip |
I once cached inventory too long and oversold during a launch. Learn from my pain.
Cache Invalidation: 3 Strategies
1. TTL (time-based) — simplest, but you're guessing the window.
await redis.set(key, payload, 'EX', 60); // expire in 60s
2. Event-based (webhooks) — most accurate. Product updates fire a webhook, you purge the entry.
// products/update webhook handler
app.post('/webhooks/products/update', verifyHmac, async (req, res) => {
const productId = req.body.id;
await redis.del(`product:${productId}:*`);
res.sendStatus(200);
});
A dropped webhook means stale cache. Make your consumers reliable (retries, dead-letter queues).
3. Stale-while-revalidate — serve stale instantly, refresh in background.
Cache-Control: max-age=60, stale-while-revalidate=300
| Method | Freshness | Complexity | Best for |
|---|---|---|---|
| TTL | Medium | Low | Predictable data |
| Event-based | High | Med-high | Inventory, pricing |
| SWR | High | Medium | Public pages |
Smart Cache Keys (don't leak data!)
For 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.
function buildKey({ query, variables, context }) {
return hash(JSON.stringify({
q: normalize(query),
v: sortObjectKeys(variables), // sort for consistency
locale: context.locale,
currency: context.currency,
buyer: context.companyId ?? 'anonymous'
}));
}
Handling Personalized Data
Field-level splitting is the cleanest pattern:
- Catalog data: cache once, globally
- Cart + pricing: fetch fresh, per user, no cache
Keep your hit rate high where it counts, fetch fresh where it matters.
Measure It
- Hit ratio = hits / (hits + misses) — maximize
- Latency delta = before vs after — should drop
- API calls avoided = cache hits — cost savings
- Stale incidents = wrong price/stock reports — target zero
Common Mistakes
- Caching personal data in a shared key
- TTLs so long prices go stale
- Ignoring webhook reliability
- Caching mutation results
Wrap-Up
Layer your caches. Match TTLs to volatility. Invalidate via webhooks. Build keys that respect personalization. Measure, then tune.
Done right, caching turns a throttled, sluggish app into a fast, resilient one.
I wrote a longer, more detailed version with extra comparison tables and architecture notes here: Caching Strategies for Shopify GraphQL
What's your go-to invalidation strategy? Drop it in the comments.
Discussion in the ATmosphere