[{"data":1,"prerenderedAt":259},["ShallowReactive",2],{"post-en-cache-by-change-not-timer":3},{"id":4,"title":5,"body":6,"category":239,"cover":240,"coverAlt":241,"date":242,"description":243,"extension":244,"locale":245,"meta":246,"navigation":247,"path":248,"published":247,"readingMinutes":249,"seo":250,"slug":251,"stem":252,"tags":253,"__hash__":258},"posts\u002Fen\u002Fposts\u002Fcache-by-change-not-timer.md","Replacing a cache timer with \"has this product changed?\"",{"type":7,"value":8,"toc":230},"minimark",[9,13,16,26,29,34,37,40,51,54,57,117,120,128,131,135,138,141,144,159,162,165,169,176,179,182,186,189,192,196,199,202,205,208,212,215,218,221],[10,11,12],"p",{},"Put a few product collections on a store's home page and you can easily get 60 to 200 products on one screen. Each of them needs data in the browser: prices, variants, stock, and what happens when a customer clicks \"Add to cart\". On inSales, an e-commerce platform with 60,000+ merchants, that work is done by CommonJS, the JavaScript library behind the storefronts. I maintained it for several years.",[10,14,15],{},"When I started digging into its caching, product data in the browser lived on a timer: two minutes in the common case, less on product pages, a few seconds in admin mode. It's the most ordinary cache there is, and it failed in two opposite directions at once:",[17,18,19,23],"ol",{},[20,21,22],"li",{},"If a product had changed, the customer could keep seeing old data until the timer ran out.",[20,24,25],{},"If nothing had changed, the timer still expired, and the library downloaded the same data again.",[10,27,28],{},"So the cache was too long and too short at the same time. There was also a less obvious cost. After a customer added one item to the cart, the library checked the whole product list it had saved when the page loaded. If the timer had expired by then, one click could re-request data for every product on the page, not just the one the customer touched.",[30,31,33],"h2",{"id":32},"asking-a-different-question","Asking a different question",[10,35,36],{},"A timer answers \"how old is this data?\". The storefront doesn't really care about age. It needs to know \"is this still the same product?\". The server already had the answer, because every product has an update timestamp. It just never reached the browser.",[10,38,39],{},"So I passed it through the template. The Liquid markup puts the timestamp on each product element:",[41,42,48],"pre",{"className":43,"code":45,"language":46,"meta":47},[44],"language-liquid","\u003Cdiv\n  data-product-id=\"{{ product.id }}\"\n  data-product-updated-at=\"{{ product.updated_at }}\"\n>\n","liquid","",[49,50,45],"code",{"__ignoreMap":47},[10,52,53],{},"The library stores that value in IndexedDB next to the product data. On the next page load it compares the value from the markup with the one it saved. It's a client-side cache: the server only provides the timestamp to compare against.",[10,55,56],{},"The decision for each product became a small table:",[58,59,60,73],"table",{},[61,62,63],"thead",{},[64,65,66,70],"tr",{},[67,68,69],"th",{},"Product state",[67,71,72],{},"What the library does",[74,75,76,85,93,101,109],"tbody",{},[64,77,78,82],{},[79,80,81],"td",{},"In the cache, same timestamp, enough data for this widget",[79,83,84],{},"Reuse it without a request",[64,86,87,90],{},[79,88,89],{},"Not in the cache",[79,91,92],{},"Fetch and store it",[64,94,95,98],{},[79,96,97],{},"Timestamp changed",[79,99,100],{},"Fetch fresh data and update the record",[64,102,103,106],{},[79,104,105],{},"This widget needs variants the cache doesn't have",[79,107,108],{},"Fetch the full product",[64,110,111,114],{},[79,112,113],{},"Old template without the timestamp",[79,115,116],{},"Keep the old time-based check",[10,118,119],{},"Simplified, the rule fits in one function:",[41,121,126],{"className":122,"code":124,"language":125,"meta":47},[123],"language-js","\u002F** Decides where one product's data comes from on page load. *\u002F\nfunction resolveProductSource({ cached, updatedAt, needsVariants }) {\n  if (!cached) return 'fetch'\n\n  \u002F\u002F Templates that don't pass the timestamp keep the previous timer behaviour\n  if (!updatedAt) return isExpired(cached) ? 'fetch' : 'cache'\n\n  if (cached.updatedAt !== updatedAt) return 'refresh'\n  if (needsVariants && !cached.hasAllVariants) return 'fetch-full'\n\n  return 'cache'\n}\n","js",[49,127,124],{"__ignoreMap":47},[10,129,130],{},"The cart got its own rule. Products that are in the cart but not on the current page no longer trigger a reload of everything visible. Cart-only products reuse their cached data, and if there's nothing cached, the library requests just the variants it needs. Products that are also on the page go through the normal timestamp check.",[30,132,134],{"id":133},"the-hard-part-fresh-isnt-the-same-as-complete","The hard part: fresh isn't the same as complete",[10,136,137],{},"The timestamp turned out to be the easy half. The harder question was what \"the cached product\" even means when different parts of a page want different amounts of it.",[10,139,140],{},"Some large stores have products with 500 to 1,000 variants. A request for a list of 20 such products could take 20–30 seconds and end in a timeout, while a preview in a slider didn't need the variant list at all. I wrote up the requirements for a lighter API response for previews, agreed them with the backend team and adapted the library to it. A preview now only learns that a product has several variants and offers to open the product card or a modal. A product with a single variant can still go straight to the cart.",[10,142,143],{},"After that, the same product could sit in one shared cache while being shown in several places:",[145,146,147,150,153,156],"ul",{},[20,148,149],{},"a preview without variant selection;",[20,151,152],{},"another widget with a full variant selector;",[20,154,155],{},"a modal where the customer picks a variant;",[20,157,158],{},"the cart.",[10,160,161],{},"Both naive rules break something. If a light response overwrites a full record, the next widget loses the variants it needs. If any cached record counts as good enough, the modal opens with half the data. So freshness alone wasn't enough. The cache rules had to take into account what each place on the page actually needs: a lighter response must not remove variants another widget relies on, and a widget that needs more than the record holds asks for the rest. That's the fourth row of the table.",[10,163,164],{},"Even \"fresh\" turned out to have more than one dimension. After stock changes, the cache could keep the old order of variants, and a preview showed the price of the first variant even though it was no longer in stock. I worked on updating the variant data and its position in the stored list, not just the timestamp.",[30,166,168],{"id":167},"rolling-it-out-to-templates-i-dont-control","Rolling it out to templates I don't control",[10,170,171,172,175],{},"The library runs inside templates that merchants and their developers customize. A new version that suddenly required ",[49,173,174],{},"data-product-updated-at"," would have broken every customized template without it.",[10,177,178],{},"So the rollout had two stages. First, a library version that understood the timestamp and kept the old timer for markup without it, which is the last row of the table. Then the attribute was added to the widgets' markup. No store ever needed both changes to arrive at the same moment.",[10,180,181],{},"The QA scenarios followed the same table: moving between pages, changing a price in the admin and reopening the page, reopening after the old timer would have expired, mixing full and reduced variant data, products in the cart, and the same product in several widgets.",[30,183,185],{"id":184},"what-changed","What changed",[10,187,188],{},"When I compared the number of product-data requests before and after the change, there were about 70% fewer of them, roughly 3.3 times fewer. Unchanged products stopped reloading just because two minutes had passed.",[10,190,191],{},"Together with moving product blocks to AJAX rendering after the main page content, product-heavy pages got noticeably faster. In representative measurements they went from over 5 seconds to 2–3 seconds. I checked more than 10 standard templates and real client pages, including pages with about 60–200 products. That's a representative result, and a particular store can differ: it depends on the number of widgets, the device, the network and the cache state. Unusually heavy pages could previously approach 15 seconds.",[30,193,195],{"id":194},"after-the-release","After the release",[10,197,198],{},"Production found things QA didn't, and two of those bugs were the same lesson in different clothes.",[10,200,201],{},"In December 2024 a product stopped initializing on mobile Safari after a customer moved from the catalog to the product card. It looked like storage hanging on the product page. Step-by-step reproduction showed the problem started earlier: \"Load more\" in the catalog initialized each loaded product separately, and each run prepared the list again and went back to IndexedDB. The client's page had two catalog widgets, which doubled the repeated work. I switched to batch initialization, so the loaded products are processed together instead of scanning storage once per card.",[10,203,204],{},"In May 2025 an error response for part of the products crashed initialization of the whole library. Even the products whose data had loaded fine stopped working. I changed the error handling so successfully loaded products still initialize.",[10,206,207],{},"A shared cache spreads one product's data across the whole page. Work that runs per item and failures that happen per item need to stay per item, or one card takes the page down with it.",[30,209,211],{"id":210},"if-your-cache-runs-on-a-timer","If your cache runs on a timer",[10,213,214],{},"Ask what you actually need to know. Usually it isn't the age of the data. It's whether the source has changed since you stored it. If the server already has an update timestamp or a version, getting it to the client is often cheaper than tuning TTLs, because you stop trading stale data against wasted requests.",[10,216,217],{},"Before you ship, ask a second question: can two consumers want different shapes of the same record? If they can, completeness belongs in the cache rules next to freshness, and the lighter response must never quietly win.",[10,219,220],{},"And if you don't control every place that renders your markup, ship the reader before the writer: a version that understands the new signal and still works without it.",[10,222,223,224,229],{},"The full project is in the ",[225,226,228],"a",{"href":227},"\u002Fwork\u002Fstorefront-product-loading\u002F","storefront product loading case",".",{"title":47,"searchDepth":231,"depth":231,"links":232},2,[233,234,235,236,237,238],{"id":32,"depth":231,"text":33},{"id":133,"depth":231,"text":134},{"id":167,"depth":231,"text":168},{"id":184,"depth":231,"text":185},{"id":194,"depth":231,"text":195},{"id":210,"depth":231,"text":211},"Performance","\u002Fimages\u002Fblog\u002Fcache-by-change-not-timer.svg","Decision table comparing a time-based cache expiry with a check of each product's update timestamp","2026-10-21","A storefront library cached product data for a fixed time, so pages either showed stale data or re-requested products that hadn't changed. Comparing each product's update timestamp cut product-data requests by about 70%.","md","en",{},true,"\u002Fen\u002Fposts\u002Fcache-by-change-not-timer",7,{"title":5,"description":243},"cache-by-change-not-timer","en\u002Fposts\u002Fcache-by-change-not-timer",[254,255,256,257,239],"Caching","IndexedDB","Storefront","Liquid","aLT7cfs3qKY8M1uMl5CVl_tOPOfCRC247FfeudlnHoA",1793195986410]