- Published on
PnPjs Caching Strategies for SPFx Web Parts
Caching is the single highest-impact performance optimisation available to SPFx developers, yet most web parts fetch fresh data on every render, every page load, and every time the user navigates away and returns.
PnPjs v3 ships with a built-in caching behaviour that stores responses in the browser's storage layer β but applying it blindly to every call is almost as bad as not using it at all.
This article covers the four caching strategies you need, when to use each, and how to invalidate the cache when you need to force a refresh.
πΊοΈ How PnPjs Caching Works
The Caching behaviour intercepts GET requests. Before hitting the network, it checks the configured storage (session or local) for a cached response keyed by the full request URL β including all query string parameters. If a valid, non-expired entry exists, it returns that immediately. If not, it makes the network call and stores the response before returning it.
import { spfi, SPFx } from '@pnp/sp';
import { Caching } from '@pnp/queryable';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
// Caching applied globally to all GET calls via this instance
const sp = spfi().using(
SPFx(this.context),
Caching({ store: 'session' })
);
Key details:
- Only GET requests are cached β POST, PATCH, DELETE always hit the network
- Cache key = the full request URL (including
$select,$filter,$orderby) - Default TTL = 5 minutes for
session, 60 minutes forlocal sessionstorage is cleared when the browser tab closes;localpersists across sessions
ποΈ Strategy 1 β Global Session Cache (Fast, Safe Default)
Apply Caching({ store: 'session' }) globally to the sp instance. All GET calls are automatically cached for the browser session.
Best for: Reference data that does not change during a working day β site metadata, list schemas, configuration lists, navigation items, taxonomy terms.
// onInit
this._sp = spfi().using(
SPFx(this.context),
Caching({ store: 'session' })
);
Session storage is cleared when the tab closes β users always start fresh each session, so stale data from yesterday is never a problem. This is the safest global caching default.
Tradeoff: Users will not see list data changes made in another tab or by another user until they open a new browser tab.
ποΈ Strategy 2 β Local Storage for Persistent Reference Data
Use local storage for data that changes very rarely β taxonomy term sets, site branding configuration, feature flags loaded from a SharePoint list.
import { CachingPessimisticRefresh } from '@pnp/queryable';
// Long-lived cache for configuration data that changes weekly at most
const configSp = spfi().using(
SPFx(this.context),
Caching({
store: 'local',
expireFunc: () => {
const expiry = new Date();
expiry.setHours(expiry.getHours() + 24); // 24-hour TTL
return expiry;
}
})
);
// Config list β rarely changes, safe to cache for 24 hours
const config = await configSp.web.lists
.getByTitle('SiteConfig')
.items
.select('Key', 'Value')();
Tradeoff: Persistent across sessions β users may see stale configuration until the TTL expires. Only use for data where a 24-hour lag is acceptable.
ποΈ Strategy 3 β Selective Per-Call Caching
The most precise approach: apply no global caching, and explicitly cache only the calls that benefit from it. Real-time or user-generated data is never cached; reference data gets an appropriate TTL.
import { Caching } from '@pnp/queryable';
// No global caching on the base instance
const sp = spfi().using(SPFx(this.context));
// Cache taxonomy terms for 60 minutes β they change rarely
const terms = await sp.termStore.sets
.using(Caching({
store: 'session',
expireFunc: () => new Date(Date.now() + 60 * 60 * 1000)
}))
.getById('your-term-set-id')
.terms();
// No caching on task items β user-generated, must be fresh
const tasks = await sp.web.lists
.getByTitle('MyTasks')
.items
.filter(`AssignedToId eq ${userId}`)();
This is the recommended approach for web parts that mix reference data with real-time user content.
ποΈ Strategy 4 β Pessimistic Refresh (Stale-While-Revalidate)
CachingPessimisticRefresh returns the cached value immediately (fast) and simultaneously fires a background network request to update the cache. The next render gets the fresh data.
import { CachingPessimisticRefresh } from '@pnp/queryable';
const sp = spfi().using(
SPFx(this.context),
CachingPessimisticRefresh('session')
);
// Returns cached value immediately, updates cache in background
const announcements = await sp.web.lists
.getByTitle('Announcements')
.items
.select('Id', 'Title', 'Body')
.top(5)();
Best for: Announcements, news, or dashboard summaries β data that is useful even if slightly stale, but should refresh in the background so the next load is current.
Tradeoff: The first load after cache expiry is fast (stale data) but slightly out of date. The following load is current. Not appropriate for financial data, approvals, or anything where stale data causes decisions.
β»οΈ Cache Invalidation β Forcing a Refresh
After a user creates or updates a list item, the cached GET responses for that list are stale. PnPjs does not automatically invalidate cache entries on write β you need to do it manually.
Option 1 β Clear a specific cache key:
import { PnPClientStorage } from '@pnp/core';
const storage = new PnPClientStorage();
// The cache key is the full request URL β reconstruct it to clear it
const cacheKey = `https://contoso.sharepoint.com/sites/dev/_api/web/lists/getbytitle('Announcements')/items?$select=Id,Title,Body&$top=5`;
storage.session.delete(cacheKey);
Option 2 β Clear all PnPjs cache entries (nuclear option):
// PnPjs prefixes all cache keys with 'PnP_'
Object.keys(sessionStorage)
.filter(key => key.startsWith('PnP_'))
.forEach(key => sessionStorage.removeItem(key));
Option 3 β Re-fetch without cache after a write:
// Temporarily bypass cache for the post-write refresh
const freshSp = spfi().using(SPFx(this.context)); // No Caching behaviour
await freshSp.web.lists.getByTitle('Announcements').items.add({ Title: 'New post' });
// Fetch fresh data (no cache), then update state β the cached version is now stale
const fresh = await freshSp.web.lists
.getByTitle('Announcements')
.items
.select('Id', 'Title', 'Body')
.top(5)();
setAnnouncements(fresh);
Option 3 is the cleanest for CRUD-heavy web parts: maintain two sp instances β one with caching for reads, one without for post-write refreshes.
π Strategy Decision Table
| Data Type | Change Frequency | Recommended Strategy |
|---|---|---|
| Site/web metadata | Rarely | Global session cache |
| Taxonomy terms | Rarely | Local cache, 24h TTL |
| Configuration lists | Weekly | Local cache, 24h TTL |
| Navigation items | Weekly | Session cache |
| Announcements / news | Daily | Pessimistic refresh |
| User-generated list items | Frequently | No cache or per-call selective |
| Real-time data (approvals, tasks) | Continuously | No cache |
β Summary
Caching({ store: 'session' })globally is a safe default for reference data β cleared on tab close, never stale across sessions.- Use
localstorage for data that changes weekly or less β site config, taxonomy, navigation. - Apply caching selectively per-call for web parts that mix reference and real-time data β not every list call benefits equally.
CachingPessimisticRefreshis the best experience for content that is useful stale β return immediately, refresh in background.- Invalidate the cache after writes by maintaining a second
spinstance without theCachingbehaviour, or by manually clearing PnPjs cache keys fromsessionStorage.
Happy coding!
Author
Ravichandran@Hi_Ravichandran
