Custom cache handler for Next.js with support for Google Cloud Storage and file-based caching. Designed for Pantheon's Next.js hosting platform.
Each badge shows whether the tests on main pass against the newest Next.js 15 and 16 releases.
- Dual Cache Handlers: Support for both GCS (production) and file-based (development) caching
- Next.js 16
use cacheSupport: Handlers for the newcacheHandlers(plural) API - Tag-Based Invalidation: Efficient O(1) cache invalidation using tag mapping
- Edge Cache Clearing: Automatic CDN cache invalidation on Pantheon infrastructure
- Build-Aware Caching: Automatically invalidates route cache on new builds
- Static Route Preservation: Preserves SSG routes during cache clearing
- Edge-Runtime Safe: Ships an edge-safe entry so a globally-configured cache handler doesn't break edge routes or edge middleware (see Edge runtime safety)
npm install @pantheon-systems/nextjs-cache-handler// cacheHandler.ts
import { createCacheHandler } from '@pantheon-systems/nextjs-cache-handler';
const CacheHandler = createCacheHandler({
type: 'auto', // Auto-detect: GCS if CACHE_BUCKET exists, else file-based
});
export default CacheHandler;// next.config.mjs
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const nextConfig = {
cacheHandler: path.resolve(__dirname, "./cacheHandler.mjs"),
cacheMaxMemorySize: 0, // Turns off Next's built-in in-memory cache so this handler is the source of truth
};
export default nextConfig;The GCS and file handlers are Node-only — they import fs and
@google-cloud/storage. When a cache handler is configured globally (via
cacheHandler / cacheHandlers in next.config, as Pantheon's build adapter
does zero-touch), Next.js bundles it into edge routes and edge middleware
too. If the entry point pulled in fs, those edge builds would fail with
edge runtime does not support Node.js 'fs' (or Can't resolve 'net').
To avoid that, the package ships a separate edge-safe entry exposing the same
API backed by no-op handlers, and maps it through the edge-light, worker,
workerd, and browser export conditions.
Next's edge compiler resolves the edge-safe entry; the Node server resolves the
real handlers. No configuration is needed — importing @pantheon-systems/nextjs-cache-handler
just works in both runtimes.
⚠️ Do not add this package totranspilePackages. Transpiling a package makes Next's edge compiler bundle its source and ignore theedge-lightexport condition, which drags the Node handlers back into the edge bundle and reintroduces thefs/netbuild failure. Leave it as a normal (externalized) dependency.
Edge routes/middleware never persist to the shared cache, so the edge no-op handlers are correct: reads miss and writes are dropped at the edge, while the Node server performs real caching.
Creates a cache handler based on the provided configuration.
interface CacheHandlerConfig {
/**
* Handler type selection:
* - 'auto': Automatically detect based on environment (GCS if CACHE_BUCKET is set, otherwise file)
* - 'file': Use file-based caching (local development)
* - 'gcs': Use Google Cloud Storage (production/Pantheon)
*/
type?: 'auto' | 'file' | 'gcs';
}| Variable | Description | Required |
|---|---|---|
CACHE_BUCKET |
GCS bucket name for storing cache | Required for GCS handler |
OUTBOUND_PROXY_ENDPOINT |
Edge cache proxy endpoint (Pantheon infrastructure) | Optional (enables edge cache clearing) |
CACHE_DEBUG |
Enable debug logging (true or 1) |
Optional |
CACHE_TAGS_FLUSH_INTERVAL_MS |
How often each server process writes the tag-to-keys mapping to GCS (default 5000, minimum 1000). See Tag-Based Invalidation. |
Optional |
CACHE_USE_CACHE_MEMORY_MB |
Memory each server process keeps for 'use cache' entries it has read or written, evicting the least recently used (default 32, 0 disables; the memoryMb option of createUseCacheHandler takes precedence). A hit from memory resolves without a GCS round trip, which keeps it in the static part of a cached navigation; tag and revalidate expiry apply as for GCS hits. |
Optional |
CACHE_INVALIDATION_DELETE_CONCURRENCY |
Deletes in flight at once when a new build deletes the earlier builds' entries in route-cache/ (in the background), and when clearSharedCache() runs (default 50, clamped to 1–200). Raising it shortens the background delete; lowering it eases GCS throttling. See Build Invalidation. |
Optional |
CACHE_BUILD_INVALIDATION |
off stops a new build from deleting route-cache/ (it still records the build, purges the CDN and removes earlier builds' keys from the tags map), for when the delete itself is the problem. Entries from earlier builds are still never served; they only take up storage until a deploy runs without off. Any other value, or unset, deletes as usual. |
Optional |
CACHE_GCS_API_ENDPOINT |
Base URL of a GCS emulator for the GCS handlers to use instead of GCS, such as http://localhost:4443 for fake-gcs-server; the integration tests use it. STORAGE_EMULATOR_HOST does not work for this: the storage library builds API and upload URLs from it differently, so no value serves both. Requests to it are sent without credentials, so it is ignored, with an error, on Cloud Run (when K_SERVICE is set). |
Optional (testing only) |
CACHE_TAGS_REFRESH_INTERVAL_MS |
How often each server process reads revalidations made by other processes (default 1000, minimum 100). revalidateTag() schedules a second CDN purge for one interval plus up to 1 s (the interval again, capped at 1 s) after the first, 2 s by default. The use cache handler also reloads tag timestamps at most once per interval, in the background. See Revalidations across processes. |
Optional |
Factory function that returns the appropriate cache handler class based on configuration.
import { createCacheHandler } from '@pantheon-systems/nextjs-cache-handler';
// Auto-detect based on environment
const CacheHandler = createCacheHandler();
// Force file-based caching
const FileCacheHandler = createCacheHandler({ type: 'file' });
// Force GCS caching
const GcsCacheHandler = createCacheHandler({ type: 'gcs' });Returns cache statistics for the current environment. Every entry is read, so on a large cache pass { limit } to read at most that many entries per cache type (fetch, route, image).
import { getSharedCacheStats } from '@pantheon-systems/nextjs-cache-handler';
const stats = await getSharedCacheStats(); // or getSharedCacheStats({ limit: 100 })
console.log(stats);
// {
// size: 10,
// keys: ['fetch:abc123', 'route:_index'],
// entries: [
// { key: 'fetch:abc123', tags: ['posts'], type: 'fetch', lastModified: 1234567890 }
// ]
// }Counts the objects in each cache and reads the size of the tag-to-keys mapping and the recorded
build, without reading any entry. On GCS that is one list request per 1,000 objects plus two small
reads, so it suits a large cache where getSharedCacheStats() would download every object. Route
objects are also counted by the build that wrote them (GCS only): any key other than
buildMeta.buildId is left over from an earlier build and never served, and unstamped counts
entries written before 0.13.4. Rejects when the cache can't be read, rather than reporting zeros.
import { getSharedCacheCounts } from '@pantheon-systems/nextjs-cache-handler';
const counts = await getSharedCacheCounts();
// {
// route: { objects: 1912, byGeneration: { 'a1B2c3:dpl-9': 1890, unstamped: 22 } },
// fetch: { objects: 340 },
// image: { objects: 57 },
// tagsMap: { bytes: 48211 },
// buildMeta: { buildId: 'a1B2c3:dpl-9', timestamp: 1791201540000, builtAt: 1791201480000 }
// }Each call lists the whole cache. If you expose it through a route, require authentication: it reveals how the cache is structured, and unauthenticated callers could run up list requests.
Clears all cache entries (preserving static SSG routes).
import { clearSharedCache } from '@pantheon-systems/nextjs-cache-handler';
const clearedCount = await clearSharedCache();
console.log(`Cleared ${clearedCount} cache entries`);For advanced use cases, you can import the handlers directly:
import { FileCacheHandler, GcsCacheHandler } from '@pantheon-systems/nextjs-cache-handler';
// Use directly in your configuration
export default FileCacheHandler;Next.js 16 introduces the 'use cache' directive with a new cacheHandlers (plural) configuration. This package provides handlers for it.
createUseCacheHandler returns the handler class. Next.js's cacheHandlers
(plural) API expects each entry's default export to be an instance with
callable .get/.set/... methods — Next does not call new on it. So you must
instantiate the class with new and export the instance:
// use-cache-handler.mjs
import { createUseCacheHandler } from '@pantheon-systems/nextjs-cache-handler/use-cache';
const UseCacheHandler = createUseCacheHandler({
type: 'auto', // Auto-detect: GCS if CACHE_BUCKET exists, else file-based
memoryMb: 32, // Per-process in-memory entries for the GCS handler (default 32, 0 disables)
});
// `new`: Next.js calls methods on this value and won't instantiate it.
export default new UseCacheHandler();If you forget the
new, Next.js builds will hang (~60s) and then fail because.get/.set/...are undefined on the class itself.
// next.config.mjs
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const nextConfig = {
// Existing handler for ISR, routes, fetch cache
cacheHandler: path.resolve(__dirname, "./cache-handler.mjs"),
// Handler for 'use cache' directive
cacheHandlers: {
default: path.resolve(__dirname, "./use-cache-handler.mjs"),
},
// Only affects Next's built-in cache for the `cacheHandler` above. The
// `'use cache'` handler's own memory is set with `memoryMb` in its handler file.
cacheMaxMemorySize: 0,
cacheComponents: true,
};
export default nextConfig;Factory function that returns the appropriate use-cache handler. Accepts the same type option ('auto', 'file', 'gcs'), plus memoryMb for the GCS handler's per-process memory (overrides CACHE_USE_CACHE_MEMORY_MB).
Returns statistics for use cache entries, similar to getSharedCacheStats().
import { getUseCacheStats } from '@pantheon-systems/nextjs-cache-handler';
const stats = await getUseCacheStats();import { UseCacheFileHandler, UseCacheGcsHandler } from '@pantheon-systems/nextjs-cache-handler';The handler distinguishes between two cache types:
- Fetch Cache: Stores data from
fetch()calls with caching enabled - Route Cache: Stores rendered pages and route data
The handler maintains a tag-to-keys mapping for efficient O(1) cache invalidation:
// When setting cache with tags
await cacheHandler.set('post-1', data, { tags: ['posts', 'blog'] });
// When invalidating by tag
await cacheHandler.revalidateTag('posts');
// All entries tagged with 'posts' are invalidatedGCS allows roughly one write per second per object name, and the mapping is a
single object (cache/tags/tags.json) shared by every server process. The GCS
handler therefore:
- keeps one write buffer per process, not per handler instance (Next.js
constructs a cache handler for every request), and writes the mapping at most
once per
CACHE_TAGS_FLUSH_INTERVAL_MS(plus up to 25% random jitter, so instances that start together spread out), flushing from inside the request that made the interval elapse, so it does not depend on background timers; - skips the write entirely when the mapping did not change (for example an ISR regeneration of an already-mapped page);
- writes with an
ifGenerationMatchprecondition, so concurrent processes merge their updates instead of overwriting each other, and re-reads on a conflict; - never writes on
revalidateTag(): reads overlay the process's pending updates on the stored mapping; - on a failed write (including
429 rateLimitExceeded) keeps the updates queued and retries with exponential backoff (up to 60s). It logs one warning when a failure streak starts and one when it recovers. If updates have been pending for more than 10 minutes of failed writes, the batch is dropped with an error.
A freshly cached page can therefore be absent from the mapping for up to the
flush interval. A revalidateTag() on another process in that window cannot
purge it, so the process that flushes the key does: after each flush it purges
the keys whose tags were revalidated after they were cached. The CDN can then
serve such a page stale for up to one flush interval. Raise CACHE_TAGS_FLUSH_INTERVAL_MS if a site that runs many
instances still logs 429 warnings during cold-cache bursts; lower it (minimum
1000) if the mapping lag matters more than write headroom.
Two situations still lose pending updates, and a lost update means a later
revalidateTag() will not purge the CDN for that page: writes failing for
more than 10 minutes (the pending batch is then dropped, logged as an error), and a
process exiting with updates still queued. The shared object accepts one write per
second in total, so a service that runs more than about 10 instances through a
cold-cache event will see updates lag and, if many instances stop within the same
few seconds, some of them exit with updates pending. Raise the flush interval, or
keep max instances near that figure, for such sites. Next.js exits on SIGTERM before the
handler can flush. If your app sets NEXT_MANUAL_SIG_HANDLE and handles the
signal itself, call flushSharedTagsMapping() from this package before exiting.
It retries a failed write until a deadline ({ timeoutMs }, default 8000 ms, inside
Cloud Run's 10 s grace period), since several instances shutting down together take
turns on the shared object.
Call it from process-level code such as a custom server.js, which shares the
module instance Next.js loaded the cache handler into. Code that Next.js bundles
(route handlers, instrumentation.ts) may get its own copy of the package with
an empty buffer registry, so a call from there does not flush anything. The function
returns the number of buffers it flushed. A 0 from a shutdown hook that should
have had pending updates means it ran in the wrong module instance.
Next.js records a revalidation in a per-process manifest, and checks cached
pages, route handlers and fetch entries against it. The GCS handler also
stores each revalidation in cache/tags/revalidations.json, and every process
applies the stored ones to its own manifest before serving from the cache,
reading the object at most once per CACHE_TAGS_REFRESH_INTERVAL_MS. Cache
reads wait for that read until 2 s after it started, then serve with what the
process already knows, so a read that arrives during a slow one waits only for
the time left. A slow read logs one warning per streak, and the recovery
message reports how many cache reads were served without it.
revalidateTag() and revalidatePath() therefore:
- store the revalidation (waiting up to 250 ms so revalidations from the same moment share one write, then retrying a failed write for up to 5 s);
- purge the CDN;
- purge the CDN again once every process has applied it (two refresh intervals, 2 s by default), so a process that had not refreshed yet cannot put the old page back in the CDN.
A process that had not refreshed yet can put the old page back in the CDN between steps 2 and 3. So each process, when it reads a revalidation that another process made, purges the CDN for the pages it served from its cache after that revalidation. The purge starts inside the request that read it (which has CPU) without holding up its cache reads. A process that served none of those pages does not send a purge, and never purges the whole site. (Revalidations it finds on its first read are skipped, because it had not served any pages before then.) The second purge in step 3 is a backstop, for a process that served the old page and then received no further requests.
A server action that revalidates responds after step 2; step 3 runs on a timer.
Second purges that come due within 1 s of each other are sent together, each
path and key once, so a burst of revalidations sends about one purge per second
per process. A process that gets no CPU between requests (Cloud Run with
request-based CPU allocation) sends an overdue second purge at its next
request; one that stops before then loses it unless the app calls
flushSharedTagsMapping() at shutdown, which sends it (waiting until it is due,
within its deadline). A route handler responds first; Next.js finishes the
revalidation after the response. If the revalidation could not be stored within
5 s, it is retried on later cache reads and the second purge is scheduled once
it is stored; flushSharedTagsMapping() also stores such revalidations at
shutdown.
revalidations.json holds one entry per revalidated tag. At each deploy, the
first process of the new build removes the entries older than the previous
build: by then only fetch() and unstable_cache entries can be older than
those revalidations (route and use cache entries from an earlier build are
not served), so it first deletes the fetch entries the tags
mapping lists under those tags. The object therefore holds roughly the
revalidations made since the previous deploy. Each build records when its
prerenders were written (builtAt in build-meta.json, less a minute's
margin), so the first deploy after upgrading does not prune anything; pruning starts at
the one after. A fetch entry missing from the tags mapping (one whose mapping
update was lost) is not deleted and is served again once its revalidation is
pruned.
The revalidations object accepts about one write per second across all
processes. Next.js gathers every revalidateTag() and revalidatePath() call
made while handling one request and passes them to the handler together, one
call per cacheLife profile, so they share a write. Revalidations that reach a
process within 250 ms of each other share one too. A CMS that sends one webhook
per changed item, many per second, makes processes take turns on the object
and delays each revalidation; send the changed items in one request instead:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(request: Request) {
// Verify the webhook's signature or shared secret first.
const { tags } = (await request.json()) as { tags: string[] };
for (const tag of tags) {
revalidateTag(tag, 'max');
}
return Response.json({ revalidated: tags.length });
}The file handler (local development, or type: 'file') keeps revalidations in
its own process: it does not share them through .next/cache, and it does not
purge the CDN. Several processes sharing one .next/cache directory can each
serve an entry another process revalidated until they regenerate it. Use the
GCS handler for more than one process.
Path purges use the URL the page is served at: with basePath, with the slash
trailingSlash: true adds, and, for a Pages Router page, also its
/_next/data/<buildId>/…json data route and, under a default locale (i18n),
its URL without the locale prefix. These settings are read from the build
output (required-server-files.json and BUILD_ID).
clearEdgeCachePaths() gives the paths you pass the trailing slash the site
serves them with: added with trailingSlash: true, removed without it, and
drops any query string or fragment, since the CDN keys a page by its path alone.
clearEdgeCachePaths() purges only the paths you pass. To purge a Pages Router
page yourself, also pass its data route (/_next/data/<buildId>/blog.json) and,
for a default-locale page, both URLs (/en/blog as well as /blog).
The use cache GCS handler writes its tag timestamps (use-cache/_tags.json)
synchronously on updateTags() so other instances see revalidations promptly,
but serializes concurrent calls, merges with the stored timestamps under the
same generation precondition, and backs off on failure.
When deployed on Pantheon, the cache handlers automatically clear the CDN edge cache when cache entries are invalidated. This is triggered by:
revalidateTag()calls (clears matching surrogate keys and paths)revalidatePath()calls (clears the specific path from the CDN)- ISR regeneration storing a page (clears that page's paths)
- A new build, when a starting process detects it (clears the whole CDN)
- Another instance's revalidation, once this process reads it (clears the paths this process served from cache since)
Edge cache clearing is enabled when the OUTBOUND_PROXY_ENDPOINT environment variable is set (automatically configured on Pantheon). revalidateTag() and revalidatePath() wait for the first purge, and a new build's full purge finishes before the process serves its first request; the other purges run in the background. A failed purge never fails the cache operation.
On each new build, the handler automatically:
- Detects the new build ID
- Invalidates the route cache (Full Route Cache)
- Preserves the data cache (Fetch Cache)
This matches Next.js's expected behavior where route cache is invalidated on each deploy but data cache persists.
On the GCS handler, a new build makes requests wait only for a full CDN purge and two small
writes: the clean-up job (route-cache-cleanup.json, next to build-meta.json), then the new
build ID in build-meta.json. Deleting the earlier builds' entries runs in the background, so the
first request after a deploy is answered in normal time however large route-cache/ is. That is
safe because each route entry is stamped with the build that wrote it, and an entry from any other
build is a cache miss (the build's prerender is used if there is one, otherwise the page is
rendered again). Entries written before 0.13.4 carry no build and miss once. Fetch and image
entries are not tied to a build and are kept across deploys.
The background delete lists route-cache/ 1,000 objects at a time with at most
CACHE_INVALIDATION_DELETE_CONCURRENCY deletes in flight, so memory stays flat. It skips entries
written by the running build, including ones cached since the deploy, and each delete is
conditional on the object being the version listed, so an entry rewritten in between is kept.
Only one instance at a time deletes. The job object is also a lock, taken with a conditional write and renewed every 15 s (also between batches of deletes, so a slow page doesn't outlast it) with the last object name reached and the counts so far. If the holder stops, the lock lapses after 60 s and the next instance to check resumes from that name. Instances check when they start and at most once a minute on route-cache reads. A newer deploy replaces the job, and an instance still working on the old one stops at its next renewal (within about 15 s), or before it deletes from the next page it lists. Another instance of the same build noting a failed CDN purge on the job does not stop the holder. On Cloud Run with request-based billing an instance gets almost no CPU between requests, so the delete only progresses while requests arrive, and a paused holder loses its lock the same way.
When the delete finishes, the job lists route-cache/ again and removes from the tags map
(cache/tags/tags.json) the route keys whose entries are not the running build's; fetch keys stay.
Each key is decided as the listing reaches its entry, the decided keys are saved in
route-cache-cleanup-removals.json next to the job, then removed in one write. The new build is
serving meanwhile, and a page it caches after its key was decided would lose the key, so once the
removals are stored the job lists route-cache/ again and adds back, with the tags the entry holds
now, the keys of pages cached since, by this build or by a newer one that replaced the job. A run
that stops before the add-back is stored leaves the saved removals, and the next run stores them
again and adds back again. Then the job deletes the saved removals and itself, and logs a warning:
Route cache clean-up for build <id> finished: deleted <n> objects of earlier builds (<listed> listed, <kept> of this build kept, <changed> rewritten during the delete kept), <failed> failed, in <ms>ms over <runs> run(s).
With CACHE_BUILD_INVALIDATION=off it logs … finished: nothing deleted (CACHE_BUILD_INVALIDATION=off), tags map pruned, in <ms>ms over <runs> run(s) instead.
The counts cover every run that worked on the job; a run killed without releasing the lock counts
only up to its last renewal.
While it runs, the job logs its progress as a warning at most once every 5 minutes:
Route cache clean-up for build <id>: deleted <n> objects of earlier builds so far (<listed> listed, <kept> of this build kept), up to <object>, in run <run>,
then … pruning the tags map, in run <run> once the delete is done.
A failed full CDN purge is logged as an error and retried by later runs of the job, which stays until the purge succeeds or 5 runs have tried, when it gives up with an error. The first retry waits 1 minute after the failed purge, and after each failed retry the next waits 1, 2, 4, then 8 minutes, so the fifth comes about 16 minutes after the failed purge; a retry also waits for an instance to check the job, at most once a minute on a route-cache read.
A listing that fails with throttling, a timeout, a server error or a network error is retried
twice (after 1 s, then 2 s); deletes that fail that way are retried once per page. What still
fails is logged with the failure counts by status (503 x8, 403 x2) and what to change: delete
permission on the bucket (an error, since every deploy fails the same way until it is fixed), or
a lower CACHE_INVALIDATION_DELETE_CONCURRENCY for throttling. A listing that still fails stops
the run, which releases the lock with its progress for a later instance. Entries left behind by a
failed delete, or written by another revision during a rollout, are not served and are deleted at
a later deploy.
If the build ID can't be read (no .next/BUILD_ID or build manifest in the working directory),
the process gets a generation of its own, so the check is skipped with a warning instead of deleting
the route cache and purging the CDN on every cold start. Such a process can't tell builds apart: a deploy
neither deletes the route cache nor purges the CDN, and it serves route entries from any build.
Start the server from the directory that contains .next.
Enable debug logging to see detailed cache operations by setting the CACHE_DEBUG environment variable:
# Enable debug logging
CACHE_DEBUG=true npm run start
# Or
CACHE_DEBUG=1 npm run startThe cache handler uses four log levels:
| Level | When Shown | Use Case |
|---|---|---|
debug |
Only when CACHE_DEBUG=true |
Verbose operational logs (GET, SET, HIT, MISS) |
info |
Only when CACHE_DEBUG=true |
Important events (initialization, cache cleared) |
warn |
Always | Recoverable issues that might need attention |
error |
Always | Errors that affect cache operations |
When debug logging is enabled, you'll see output like:
[GcsCacheHandler] Initializing cache handler
[GcsCacheHandler] GET: /api/posts
[GcsCacheHandler] HIT: /api/posts (route)
[GcsCacheHandler] SET: /api/users (fetch)
[EdgeCacheClear] Cleared 3 paths in 45ms
[GcsCacheHandler] Revalidated tags posts, blog; 5 cache keys affected
-
Ensure you're logged into npm with access to the
@pantheon-systemsscope:npm login --scope=@pantheon-systems
-
Verify your login:
npm whoami
-
Update the version in
package.json:# Patch release (0.1.0 -> 0.1.1) npm version patch # Minor release (0.1.0 -> 0.2.0) npm version minor # Major release (0.1.0 -> 1.0.0) npm version major
-
Build and test:
npm run build npm test -
Publish to npm:
npm publish --access public
The
--access publicflag is required for scoped packages to be publicly accessible.
After publishing, verify the package is available:
npm view @pantheon-systems/nextjs-cache-handlerOr install it in a test project:
npm install @pantheon-systems/nextjs-cache-handlerMIT