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

URL: https://www.ozanaltinbas.com/en/blog/sfcc-caching
Author: Ozan Altinbas (Ozan Altınbaş), SFCC Technical Architect
Published: 2026-11-12
Category: Performance & SEO | Tags: SFCC, Caching, Performance, SFRA

---

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](https://developer.salesforce.com/docs/commerce/sfra/guide/caching-strategies-sk.html) 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](https://www.ozanaltinbas.com/en/sfcc).

---

## 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.

| Layer | What it holds | How you control it | How it is cleared |
|---|---|---|---|
| eCDN | Static storefront content at the edge | `Cache-Control` TTL, fingerprinted asset URLs | TTL expiry, or the CDN Zones API purge |
| Static content cache (web tier) | Images, CSS, JS | TTL on the site's Cache tab in Business Manager | Business Manager, `InvalidateCache` job step, replication |
| Page cache (web tier) | Whole controller responses and remote-include fragments | `response.setExpires()`, SFRA cache middleware or `<iscache>` | TTL expiry, replication, Business Manager |
| SCAPI web-tier cache | Eligible Shopper API `GET` responses | Page caching enabled; `setExpires()` in Custom APIs | Its own TTLs and invalidation (see the web-tier caching docs) |
| Custom caches (application server) | Results of expensive computations or external calls | `caches.json` plus `dw.system.CacheMgr` | `expireAfterSeconds`, code activation, replication |
| Request and session | Per-request or per-shopper data | Module scope, `request.custom`, `session.privacy` | End 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](https://salesforcecommercecloud.github.io/b2c-dev-doc/docs/current/scriptapi/html/api/class_dw_system_Response.html), 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](https://trailhead.salesforce.com/content/learn/modules/b2c-implement-cache/b2c-implement-cache-web-server): **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](https://developer.salesforce.com/docs/commerce/sfra/guide/b2c-content-cache.html) restricts the settings by instance type:

| Instance type | Restriction |
|---|---|
| Sandbox or development | None |
| Staging | Values for the production setting can't be changed |
| Production | The 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:

| Middleware | Cache time | Personalised (`price_promotion`) | Examples in base SFRA |
|---|---|---|---|
| `applyDefaultCache` | 24 hours | No | `Home-Show`, `Page-Show`, `Page-Include` |
| `applyPromotionSensitiveCache` | 24 hours | Yes | `Product-Show`, `Tile-Show` |
| `applyShortPromotionSensitiveCache` | 1 hour | Yes | `Search-Show`, `Search-ShowAjax` |
| `applyInventorySensitiveCache` | 30 minutes | No | Available 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')`:

```javascript
'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`:

```javascript
// 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](https://developer.salesforce.com/docs/commerce/sfnext/guide/b2c-iscache.html), `relative` expires the page after a period and `daily` at a fixed time:

```html
<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:

```html
<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](https://trailhead.salesforce.com/content/learn/modules/b2c-implement-cache/b2c-implement-cache-troubleshoot) 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](https://developer.salesforce.com/docs/commerce/sfra/guide/b2c-custom-caches.html) 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](https://salesforcecommercecloud.github.io/b2c-dev-doc/docs/current/scriptapi/html/api/class_dw_system_Cache.html)).

### 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:

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

Then define the caches in `caches.json`:

```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()`](https://salesforcecommercecloud.github.io/b2c-dev-doc/docs/current/scriptapi/html/api/class_dw_system_CacheMgr.html) throws if the ID isn't defined in any `caches.json`. The [cartridge development guide](https://www.ozanaltinbas.com/en/blog/sfcc-cartridge-development) 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.

```javascript
'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](https://trailhead.salesforce.com/content/learn/modules/ecdn-b2c-commerce/ecdn-b2c-commerce-cache-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](https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/ecdn.html):

```bash
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](https://developer.salesforce.com/docs/commerce/commerce-api/guide/server-side-web-tier-caching.html), 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](https://www.ozanaltinbas.com/en/blog/sfcc-hooks) covers where hook logic belongs.
- **Custom APIs opt in explicitly.** Per [Custom API caching](https://developer.salesforce.com/docs/commerce/commerce-api/guide/custom-api-caching.html), 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](https://www.ozanaltinbas.com/en/blog/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](https://www.ozanaltinbas.com/en/blog/commerce-cloud-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

| Content | Layer | Mechanism | Starting point |
|---|---|---|---|
| Images, CSS, JS | eCDN and static content cache | Fingerprinted URLs, `Cache-Control` | Long TTL; no routine purges |
| Home, content and landing pages | Page cache | `cache.applyDefaultCache` | 24 hours |
| PDP and product tiles | Page cache, personalised | `cache.applyPromotionSensitiveCache` | 24 hours plus `price_promotion` |
| Search and category results | Page cache, personalised | `cache.applyShortPromotionSensitiveCache` | 1 hour |
| Stock-sensitive fragments | Page cache | `cache.applyInventorySensitiveCache` or tiered `setExpires()` | 30 minutes or availability-based |
| Login state, mini-cart, account | Uncached remote include | `server.middleware.include`, no cache middleware | Not cached |
| Configuration, external API results | Custom cache | `CacheMgr.get(key, loader)` | `expireAfterSeconds` = acceptable staleness |
| Headless product and search data | SCAPI web tier | Page caching on; Shopper Context for personalisation | Per 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](https://www.ozanaltinbas.com/en/service) or [get in touch](https://www.ozanaltinbas.com/en/contact).

## Further reading

- [Caching Strategies for Salesforce B2C Commerce](https://developer.salesforce.com/docs/commerce/sfra/guide/caching-strategies-sk.html): the layers, personalised caching and the Technical report.
- [Content Cache](https://developer.salesforce.com/docs/commerce/sfra/guide/b2c-content-cache.html): page and static cache settings, partitions and invalidation.
- [Custom Caches](https://developer.salesforce.com/docs/commerce/sfra/guide/b2c-custom-caches.html): `caches.json`, limits and `CacheMgr` usage.
- [Server-Side Web-Tier Caching](https://developer.salesforce.com/docs/commerce/commerce-api/guide/server-side-web-tier-caching.html): how SCAPI caches Shopper API responses.
- [Implement Caching in the Web-Server Layer](https://trailhead.salesforce.com/content/learn/modules/b2c-implement-cache/b2c-implement-cache-web-server): Trailhead on page caching, feature switches and remote includes.
