Key Takeaways
- A storefront widget that recomputes pricing on every page view and re-hits your app proxy is slow; one that caches naively shows stale prices. The fix is to cache the expensive decision and recompute the cheap number.
- Back the cache with
sessionStorageso it survives same-tab navigation and reload, and scope it to the tab session.- Invalidate on exactly one signal, a presentment currency change, and let a content hash of the injected rules invalidate matches automatically.
- Never cache the displayed price; compute it fresh from the current product data every time. Cache only the matched rule id.
Storefront widgets live in a hostile performance environment. They run on the merchant's theme, after the main content, and they often need to match pricing rules, resolve a customer's eligibility, and compute a discounted price for the product on screen. Do that work on every page view and the widget feels sluggish and hammers your app proxy. Cache it carelessly and a shopper sees a price that is no longer true.
This guide documents the cache model we use in the Flicky theme app extension. The core idea is a split: the match (which rule applies) is cached, but the price (what number to show) is always computed fresh from the current product data. That one distinction is what lets the cache be aggressive without ever going stale on price.
The Two Failure Modes
A storefront pricing cache fails in one of two directions.
Cache nothing. Every navigation re-runs rule matching and, if eligibility needs a server check, re-hits your app proxy. Browsing from product A to B back to A pays the full cost three times. The widget lags behind the page and your backend carries needless load.
Cache everything, including the price. Now the widget is fast, but when a merchant edits a product's price, or when the base price simply differs from what you cached, a shopper can see the old number. For a pricing app, showing a wrong price is worse than being slow.
The resolution is to notice that these two things have very different volatility. Which rule matches a product changes only when the merchant edits their rules or the shopper logs in or out. What price to show depends on the product's current base price, which you should always read live. So cache the first and recompute the second.
What We're Building
Three pieces:
- A small
sessionStoragecache layer (shared/cache.js) that persists across navigation and reload, invalidated only on a currency change. - A pricing resolver (
shared/pricing-cache.js) that caches the matched rule id, keyed by a hash of the injected rules, and computes the unit price fresh. - A deliberate, documented trade-off about what can briefly go stale and what never can.
Step 1: A sessionStorage Cache Invalidated Only on Currency
In this step you'll build a cache layer that survives navigation and reload, degrades gracefully when storage is unavailable, and clears on exactly one signal.
The guard runs once on import, before any consumer reads the cache. It compares the current presentment currency against a stored marker and clears the whole namespace only when the currency changed:
// frontend/shared/cache.js
const PREFIX = 'flicky:';
const CUR_KEY = 'flicky:__currency';
function ss() {
try { return window.sessionStorage; } catch { return null; } // Safari private mode -> null
}
// Currency-only invalidation: clear the `flicky:` namespace when the presentment
// currency changes; otherwise the cache persists across navigation AND reload.
(function guard() {
const s = ss();
if (!s) return;
try {
const cur = window.flickyDiscount?.currency ?? null;
const prev = s.getItem(CUR_KEY);
if (cur && cur !== prev) {
for (const k of Object.keys(s)) {
if (k.startsWith(PREFIX) && k !== CUR_KEY) s.removeItem(k);
}
}
if (cur) s.setItem(CUR_KEY, cur);
} catch { /* private mode / quota -> leave cache untouched */ }
})();The read/write helpers wrap values in a { v } object so a cached null (fetched, not found) is distinguishable from absent (undefined):
export function readCache(key) {
const s = ss();
if (!s) return undefined; // no storage -> always a miss
try {
const raw = s.getItem(PREFIX + key);
if (raw === null) return undefined; // absent
return JSON.parse(raw).v; // may be null (fetched, not found)
} catch { return undefined; }
}
export function writeCache(key, value) {
const s = ss();
if (!s) return;
try { s.setItem(PREFIX + key, JSON.stringify({ v: value })); }
catch { /* quota / unavailable -> value still usable in memory */ }
}What just happened: the cache persists for the whole tab session, including hard reloads, so repeat visits are instant. It resets on a currency change because presentment currency is what drives displayed prices. And it degrades to a live-fetch no-op when sessionStorage throws (Safari private mode, quota), so the widget always works, just without the speedup.
Watch out: the
{ v }wrapper matters. Without it, you cannot tell "we cached that no rule matched" from "we never checked." Both would read back as a falsy value and you would re-run the match forever on no-match products.
Step 2: Cache the Match, Not the Price
In this step you'll cache only the matched rule id and compute the displayed unit price fresh every time, so the price can never be stale.
The resolver keys its cache by a hash of the injected rules plus the customer and product. It stores the matched rule id (or an empty string for "matched none"), and always computes unit from the base the caller passes in:
// frontend/shared/pricing-cache.js
import { matchRule } from './conditions.js';
import { computeUnit } from './pricing.js';
import { readCache, writeCache, shortHash } from './cache.js';
let _hash = null;
// Hash of the injected pricing rules. Fresh each page load (rules re-injected
// from the app metafield), so a merchant rule edit yields a new hash -> old entries miss.
export function pricingRulesHash() {
if (_hash !== null) return _hash;
try { _hash = shortHash(JSON.stringify(window.flickyDiscount?.pricingRules ?? [])); }
catch { _hash = '0'; }
return _hash;
}
export function resolveVariantPricing(product, variantId, customer, pricingRules, base, compareAt = 0) {
const rules = pricingRules ?? [];
const handle = product?.handle;
const key = handle
? `price:${pricingRulesHash()}:${customer?.id ?? 'anon'}:${handle}:${variantKey(variantId)}`
: null;
let rule = null;
let ruleId = key ? readCache(key) : undefined; // undefined = miss · '' = matched none · else id
if (ruleId === undefined) {
rule = matchRule(product, variantId, customer, rules); // the expensive part, cached below
if (key) writeCache(key, rule?.id ?? '');
} else if (ruleId !== '') {
rule = rules.find((r) => r.id === ruleId) ?? null;
if (!rule) rule = matchRule(product, variantId, customer, rules); // safety: id no longer present
}
// ALWAYS computed fresh from the caller's current base -> price can't go stale.
const unit = rule && base > 0 ? computeUnit(base, rule, compareAt) : base;
return { rule, unit };
}What just happened: switching variants or re-scanning collection cards no longer re-runs matchRule, which is the costly part. But computeUnit runs every call against the current base, so the displayed price always reflects the latest product data. On a product page the base comes fresh from Liquid, so the number is always correct.
The invalidation is elegant because it is implicit:
- Merchant edits a rule → the injected
pricingRuleschange →pricingRulesHash()changes → every old key misses. - Shopper logs in or out → the customer id in the key changes → miss.
- Currency changes → the whole
flicky:namespace is cleared by the Step 1 guard.
No manual cache-busting calls anywhere.
Step 3: Decide What Is Allowed to Go Stale
In this step you'll make the staleness boundary explicit, because an honest cache model names what it trades away.
Not everything can be fresh and fast at once. Our accepted trade-off:
| Data | Cached? | Staleness risk |
|---|---|---|
| Matched rule id | Yes (sessionStorage) | None in practice; rule edits change the hash and miss |
| Displayed unit price | No, computed fresh from base |
None; always reflects current product data |
| Product data (by handle) | Yes (sessionStorage) | A product's base price edit may show old on a collection card within the same tab session |
| Stock / availability | No, polled live | None; never cached |
The one real trade-off: a collection card reads cached product data, so if a merchant edits a product's base price and the shopper does not change currency, that card can show the old base for the rest of the tab session. On the product page itself the base is always fresh from Liquid, so the page the shopper actually buys from is correct. We accepted that boundary deliberately; stock is never cached because it changes fastest and a wrong stock number is the most damaging.
Verification
- Persistence: browse A to B back to A and reload; confirm the second visit to A does not re-run matching or re-hit the proxy (watch the network tab and add a temporary log in
matchRule). - Price freshness: on a product page, confirm the displayed unit price always matches the current Liquid base, even with a warm cache.
- Rule-edit invalidation: edit a pricing rule in the app, reload the storefront, and confirm old cache entries miss because the rules hash changed.
- Currency invalidation: switch presentment currency and confirm the whole
flicky:namespace clears. - Graceful degradation: open the storefront in a private window where
sessionStoragemay throw, and confirm the widget still works, just without caching.
Key Takeaways
1. Separate the decision from the number. Rule matching is expensive and stable; the price is cheap and volatile. Cache the first, recompute the second, and staleness on price disappears.
2. sessionStorage is the right scope for storefront caches. It survives navigation and reload (so repeat visits are fast) but dies with the tab, which matches how long a shopper's session and currency choice stay meaningful.
3. Let a content hash do your invalidation. Hashing the injected rules means a merchant edit invalidates matches for free, with no cache-busting API to call and keep in sync.
4. Name what you let go stale. A cache that pretends nothing is stale is lying. Writing down that collection cards can show an old base, but the product page and stock never can, is what makes the model trustworthy.
Frequently Asked Questions
Why sessionStorage instead of localStorage or in-memory?
In-memory loses everything on reload, so a shopper who refreshes pays the full cost again. localStorage persists too long: a currency or rule change from a week ago should not still be cached. sessionStorage matches the natural lifetime of a shopping session, surviving navigation and reload but clearing when the tab closes.
How do I stop showing a stale price after a merchant edits a rule?
You do not have to do anything explicit. The cache key includes a hash of the injected rules, which are re-injected from the app metafield on every page load. A rule edit changes the hash, so every previous entry misses and the widget recomputes.
What is the one thing that can still be stale?
A collection card can show an old product base price within the same tab session, because product data is cached by handle and only a currency change clears it. The product page the shopper buys from always reads a fresh base from Liquid, and stock is never cached.
Does caching the match risk applying a rule that no longer exists?
No. If the cached rule id is no longer present in the current rules (an edge case after an edit within the same session), the resolver falls back to running matchRule again rather than trusting a dangling id.
Written by Duke, based on the production implementation of the Flicky Shopify theme app extension. Code samples are drawn from the shipping codebase and simplified for readability. See also rendering widgets without layout shift and the engineering notes hub.