Skip to main content
OZAN ALTINBAS
Performance & SEO

SFCC Page Cache, Custom Caches and eCDN: A Complete Caching Guide

By 12 min read

Caching on Salesforce B2C Commerce is not one switch. A request can be answered by the eCDN, by the web tier's page cache or by the application server, and each layer has its own API, invalidation rules and failure modes. Salesforce's Caching Strategies for Salesforce B2C Commerce paper names the usual misconception directly: caching is not just "adding <iscache> to a few templates".

This guide follows a request through each layer, with minimal correct code for SFRA cache middleware, dw.system.Response, caches.json and dw.system.CacheMgr, and ends with a table of what to cache where. Every limit quoted comes from Salesforce documentation. For the wider platform picture, start with the SFCC guide.


The caching layers at a glance

Salesforce's rule is to serve cached content as close to the shopper as possible, because every layer a request traverses costs time and throughput.

LayerWhat it holdsHow you control itHow it is cleared
eCDNStatic storefront content at the edgeCache-Control TTL, fingerprinted asset URLsTTL expiry, or the CDN Zones API purge
Static content cache (web tier)Images, CSS, JSTTL on the site's Cache tab in Business ManagerBusiness Manager, InvalidateCache job step, replication
Page cache (web tier)Whole controller responses and remote-include fragmentsresponse.setExpires(), SFRA cache middleware or <iscache>TTL expiry, replication, Business Manager
SCAPI web-tier cacheEligible Shopper API GET responsesPage caching enabled; setExpires() in Custom APIsIts own TTLs and invalidation (see the web-tier caching docs)
Custom caches (application server)Results of expensive computations or external callscaches.json plus dw.system.CacheMgrexpireAfterSeconds, code activation, replication
Request and sessionPer-request or per-shopper dataModule scope, request.custom, session.privacyEnd of request or session

Custom objects are missing on purpose: they are consistent across application servers, but every read traverses all tiers, so they are storage, not a cache.


How the SFCC page cache works

The page cache sits in the web tier, in front of the application servers. Salesforce describes it as a key-value store whose key is the full URL including the query string. On a hit the web tier serves the stored response and resolves any remote includes inside it the same way; on a miss it calls the application server and stores the response if it is marked as cacheable. Three consequences follow:

  1. On a hit, your code does not run. No controller, middleware, hooks or logging, and no load on the application or storage layers.
  2. Every distinct URL is a distinct entry. A different parameter value, an extra parameter or a different parameter order all create new entries. Salesforce's named anti-pattern is a position parameter on product tiles, which caches the same tile once per search position.
  3. Nothing is cached by default. Per the dw.system.Response reference, responses are cached only when code opts in.

Two feature switches under Administration > Global Preferences > Feature Switches help, per the Trailhead unit Implement Caching in the Web-Server Layer: Web Adapter Cache Key Ignore by Query String drops meaningless parameters such as campaign IDs from the key, and Enable 404 response caching overrides lets 404s be cached.

Enabling page caching in Business Manager

Page caching is set per site under Administration > Sites > Manage Sites > (site) > Cache with Enable Page Caching; when it is off, <iscache> is ignored. The Content Cache documentation restricts the settings by instance type:

Instance typeRestriction
Sandbox or developmentNone
StagingValues for the production setting can't be changed
ProductionThe page cache can't be disabled, and the Cache-tab TTL minimum is 24 hours

Salesforce suggests disabling page caching on sandboxes and staging to see changes immediately. Turn it back on when you test a new endpoint, or caching bugs will only surface in production.


Setting cache times: SFRA middleware, script API and <iscache>

Two APIs set the cache time: dw.system.Response#setExpires() and the ISML <iscache> tag. Both apply to the entire response, not to one template, and if expiry is set more than once the lowest value wins. Salesforce strongly recommends the script API, because with nested templates it is hard to tell which one set the cache time.

SFRA: the cache middleware

SFRA's app_storefront_base ships cartridge/scripts/middleware/cache.js. In SFRA 6.1 it exports four functions:

MiddlewareCache timePersonalised (price_promotion)Examples in base SFRA
applyDefaultCache24 hoursNoHome-Show, Page-Show, Page-Include
applyPromotionSensitiveCache24 hoursYesProduct-Show, Tile-Show
applyShortPromotionSensitiveCache1 hourYesSearch-Show, Search-ShowAjax
applyInventorySensitiveCache30 minutesNoAvailable for your own routes

The middleware only sets res.cachePeriod, res.cachePeriodUnit and res.personalized; when the route completes, SFRA's server.js turns them into setExpires() and, if personalised, setVaryBy('price_promotion'):

'use strict';

var server = require('server');
var cache = require('*/cartridge/scripts/middleware/cache');

// Brand-Show: a landing page that changes a few times a week.
// applyDefaultCache sets res.cachePeriod = 24 (hours).
server.get('Show', cache.applyDefaultCache, function (req, res, next) {
    res.render('brand/landing', { brandId: req.querystring.bid });
    next();
});

module.exports = server.exports();

For a TTL the built-ins don't cover, write a small middleware of your own that sets res.cachePeriod and res.cachePeriodUnit (SFRA reads 'minutes' and treats anything else as hours), so the policy stays visible in the route signature.

Outside SFRA: setExpires() and setVaryBy()

In a non-SFRA controller or a Custom API script, call the response API directly. setExpires() takes milliseconds since the epoch, or a Date:

// Cache this response for 30 minutes
response.setExpires(Date.now() + 30 * 60 * 1000);

// Only when the output really differs by price book or promotion
response.setVaryBy('price_promotion');

Cache times needn't be constants: the Caching Strategies paper tiers a product page's TTL by availabilityModel.isOrderable(n), long when stock is deep and short when it is nearly gone.

<iscache> in templates

You will still meet the tag in SiteGenesis and older cartridges. Per the iscache element reference, relative expires the page after a period and daily at a fixed time:

<iscomment>Expire 24 hours after the page is generated</iscomment>
<iscache type="relative" hour="24" />

<iscomment>Expire every day at 03:30</iscomment>
<iscache type="daily" hour="3" minute="30" />

<iscomment>Personalised: one entry per price book and promotion combination</iscomment>
<iscache type="relative" hour="1" varyby="price_promotion" />

The trap is local includes: the lowest setting across the template and every locally included template decides the whole page, so a short <iscache> in a shared partial quietly shortens every page that uses it.


Remote includes: cached pages with personalised fragments

<isinclude template="..."> is a local include: it renders in the same request and becomes part of the same cache entry. <isinclude url="..."> is a remote include: the web tier requests it separately, with its own cache policy, and assembles the result into the cached frame. That is how a page cached for a day still shows the shopper's login state. SFRA's header does exactly this:

<iscomment>pageHeader.isml: the page around these lines is cached; each include is its own request</iscomment>
<isinclude url="${URLUtils.url('Account-Header')}" />
<isinclude url="${URLUtils.url('Cart-MiniCart')}" />
<isinclude url="${URLUtils.url('Page-IncludeHeaderMenu')}" />

Account-Header and Cart-MiniCart use server.middleware.include (which rejects direct requests) and no cache middleware, so they render per request. Page-IncludeHeaderMenu uses applyDefaultCache, because the menu is the same for everyone.

  • Pass only parameters that change the output. Include URLs are cache keys too.
  • Keep nesting shallow. Salesforce's troubleshooting unit recommends three to seven levels, fewer being better.
  • Invalidation is per pipeline. Invalidating Home-Show does not refresh the remote includes inside it.
  • Reuse existing dynamic includes. Salesforce's example: rather than rendering a wishlist heart per tile, expose the wishlist as a data attribute in an include that is already dynamic and set the icons client-side.

Page Designer uses the same model: PageMgr.renderPage() renders through two nested remote includes, __SYSTEM__Page-Include (which computes a visibility fingerprint) and __SYSTEM__Page-Render, so each visibility variation is cached separately. Content slots are served in remote includes too.


Personalised caching: what price_promotion varies by

The setVaryBy() reference is precise: the platform tells variants apart by price book, promotion, sorting rule and A/B test segment, and price_promotion is the only supported value. A page personalised by any other means must not be cached, because shoppers would get the wrong variant. Once anything in a request sets vary-by, the whole response is personalised, and entries multiply with every price book and promotion combination.

  • Prices or promotion callouts in the HTML: use a promotion-sensitive cache, as SFRA does for PDPs, tiles and search.
  • Output that varies by anything else (shopper name, basket, session data): move it into an uncached remote include or a client-side call.
  • No prices on the page: no vary-by. It costs hit rate and buys nothing.

Custom caches with dw.system.CacheMgr

When a response can't be page-cached (cart, checkout, account, uncached includes), cache the expensive work inside it. The Custom Caches documentation names typical candidates: merged configuration, external system responses such as store availability, and derived data such as "is any variant on promotion".

Limits and behaviour

  • About 20 MB is reserved for all custom caches per application server; a code version supports up to 100 caches.
  • Entries are capped at 128 KB. A larger loader result is returned but not stored, with a warning in the custom warn log.
  • Caches are per application server and not synchronised; invalidate(key) only clears the current server.
  • All custom caches are cleared when a file in the active code version changes, when a code version is activated, and after data or code replication.
  • Only primitives and tree-like structures can be stored, and reads return immutable copies; undefined and Script API objects are not allowed (dw.system.Cache reference).

Registering a cache: package.json and caches.json

The cartridge root's package.json points at the definition file, with a path relative to itself. If the cartridge already registers hooks, add the caches key to the same file:

{
  "hooks": "./hooks.json",
  "caches": "./caches.json"
}

Then define the caches in caches.json:

{
  "caches": [
    { "id": "StorefrontConfig", "expireAfterSeconds": 3600 },
    { "id": "ExternalStoreStock", "expireAfterSeconds": 60 }
  ]
}

Each id must be unique across the code version (a duplicate disables the cache and logs an error), and expireAfterSeconds is optional. CacheMgr.getCache() throws if the ID isn't defined in any caches.json. The cartridge development guide covers cartridge layout.

Reading through the cache

Use get(key, loader): it returns the cached value, or runs the loader on a miss and stores the result.

'use strict';

var CacheMgr = require('dw/system/CacheMgr');
var Site = require('dw/system/Site');

function getStorefrontConfig() {
    var siteId = Site.getCurrent().getID();
    // Scope the key by site and locale; never put personal data in a key (keys can appear in logs)
    var key = siteId + '_' + request.locale + '_config';

    return CacheMgr.getCache('StorefrontConfig').get(key, function loadConfig() {
        // Runs only on a miss, on this application server
        var raw = Site.getCurrent().getCustomPreferenceValue('storefrontConfigJson');
        return raw ? JSON.parse(raw) : null; // plain data only; never return undefined
    });
}

module.exports = { getStorefrontConfig: getStorefrontConfig };

storefrontConfigJson stands in for whatever expensive source you have. Design for misses: eviction can happen at any time, and each server warms its own copy. For values reused within one request, a module-level variable or request.custom is enough; for small per-shopper data, prefer session.privacy, because writes to session.custom make the platform re-evaluate dynamic customer groups.


Invalidation without hurting the site

Cached pages refresh when their TTL expires, when a data or code replication runs (which invalidates the site cache automatically), or when someone invalidates the whole site or one page cache partition under Administration > Sites > Manage Sites > (site) > Cache.

  • Eviction is gradual. It takes 15 minutes, the "Clear Period". Triggering again inside it resets the period and can stop the cache from ever clearing completely.
  • Partitions limit the blast radius. A partition groups controllers such as Home-Show; you can create up to 10 partitions of up to 20 pipelines each. Put remote-include pipelines in their own partition.
  • Replication can be targeted. A replication process can be set to Do not invalidate or Invalidate Impacted Cache Partitions, so an urgent fix can be replicated and then only its partition invalidated.
  • Jobs can do it too. The InvalidateCache job step takes InvalidatePageCache, InvalidateStaticCache and an optional PageCachePartitionIDs list.

If data looks stale often, find the cause: Salesforce suggests checking scheduled jobs that trigger invalidation and using granular replications for frequent price changes.


eCDN: edge caching and purges

The eCDN caches static storefront content. Per the Trailhead unit Clear eCDN Cache and Configure HSTS, a file stays cached until its Cache-Control TTL expires, with a 30-day default when no header is set. Static asset URLs carry a fingerprint, so a changed asset gets a new URL and a normal deployment needs no purge.

When you do need one, the CDN Zones API exposes POST /cdn/zones/v1/organizations/{organizationId}/zones/{zoneId}/cachepurge on your SCAPI short-code host. It takes a path or a list of tags, not both, and needs the sfcc.cdn-zones.rw scope. The B2C CLI wraps it:

b2c ecdn cache purge --tenant-id zzxy_prd --zone my-zone --path "www.example.com/dw/image/v2/realm_instance/*"

An eCDN purge and a page cache invalidation are separate operations, so find which layer holds the stale response first.


Headless: SCAPI and PWA Kit caching

SCAPI caches eligible GET responses (Shopper Products, Search, SEO, Stores and Experience among them) in the server-side web tier, which requires page caching to be enabled for the site.

  • The cache key is calculated before any hook runs. A hook that changes price books or promotions doesn't change the key, which lowers the hit rate or serves the wrong entry. Personalise through the Shopper Context API; the SFCC hooks guide covers where hook logic belongs.
  • Custom APIs opt in explicitly. Per Custom API caching, enable page caching, then call response.setExpires() in the script.
  • personalized=none is a promise you make. The Shopper API specifications describe it as opting out of personalisation so the response is safe to cache at the CDN layer. It is necessary but not sufficient: the endpoint must also be cacheable in the web tier.

In PWA Kit, rendered HTML is cached by the Managed Runtime CDN according to the Cache-Control header the app sets; the Retail React App's home page sends s-maxage with stale-while-revalidate. See SFRA vs PWA Kit for the wider trade-offs.


Measuring cache hit rates

Open Reports & Dashboards, Technical tab, sorted by share of processing time. The last three columns show requests read from cache, calculated and stored to cache, and always calculated dynamically.

  • 100% in the last column means the endpoint isn't cached; a missing directive on a new route is the usual cause.
  • A cached endpoint should show 0% there. Anything else points to errors or code paths that skip caching.
  • Watch the key pages and includes: Home-Show, Search-Show, Product-Show, Page-Show, product tiles, Page-Include and slots. Salesforce's ideal for search tiles is at least a 99% hit rate at 1 ms or less.

Then profile what stays dynamic in Administration > Operations > Code Profiler (Extended Script Development mode, Category equals REQUEST, sorted by Elapsed Time > Sum), and review every endpoint a few days after launch. Faster responses also help Core Web Vitals, covered in the SFCC SEO guide.


Common mistakes

  • No cache directive on a new route. It works; it is just 100% dynamic.
  • Side effects in a cached controller. Session writes, cookies or analytics calls run only for the shopper who caused the miss, because the controller doesn't run on a hit.
  • Personalised output in a cached response. Anything beyond the four vary-by dimensions belongs in an uncached remote include.
  • Vary-by everywhere, or volatile include parameters. Both multiply cache entries for nothing.
  • Very short TTLs as a freshness strategy. Partitions, granular replication or targeted invalidation work better.
  • Repeated manual invalidation. Each trigger restarts the Clear Period.

What to cache where

ContentLayerMechanismStarting point
Images, CSS, JSeCDN and static content cacheFingerprinted URLs, Cache-ControlLong TTL; no routine purges
Home, content and landing pagesPage cachecache.applyDefaultCache24 hours
PDP and product tilesPage cache, personalisedcache.applyPromotionSensitiveCache24 hours plus price_promotion
Search and category resultsPage cache, personalisedcache.applyShortPromotionSensitiveCache1 hour
Stock-sensitive fragmentsPage cachecache.applyInventorySensitiveCache or tiered setExpires()30 minutes or availability-based
Login state, mini-cart, accountUncached remote includeserver.middleware.include, no cache middlewareNot cached
Configuration, external API resultsCustom cacheCacheMgr.get(key, loader)expireAfterSeconds = acceptable staleness
Headless product and search dataSCAPI web tierPage caching on; Shopper Context for personalisationPer the SCAPI web-tier caching docs

Key takeaways

  • The page cache key is the full URL: keep URLs, especially include URLs, free of parameters that don't change the output.
  • Prefer SFRA middleware or response.setExpires() over <iscache>; the lowest expiry in a response wins.
  • Cache the page frame; keep shopper-specific fragments in uncached remote includes.
  • price_promotion covers price books, promotions, sorting rules and A/B segments, nothing else.
  • Custom caches are per server, about 20 MB in total and 128 KB per entry, with no persistence guarantee.
  • Invalidate by partition, wait out the Clear Period, and check hit rates after every launch.

If you want a second opinion on a storefront's caching before peak trading, see how I work on the service page or get in touch.

Further reading