Skip to content
Files SDK
Esc
navigateopen⌘Jpreview
On this page

cache

An LRU/KV cache in front of head(), url(), and small download()s. Repeat reads of an unchanged key are served from memory; writes through the instance invalidate the affected key. Body-transparent, no native dependencies, works on any adapter.

The built-in cache() plugin puts an LRU (or your own KV) in front of the cheap read verbs. A repeat head() or url() - and, opt-in, a small download() - for an unchanged key is served from memory instead of round-tripping to the provider. Any write through the instance (upload, delete, copy, move) invalidates the affected key, so the next read re-fetches.

It writes no object metadata and has no native dependencies, so it works on any adapter. Like the other plugins it runs outside retries - a cache hit skips the retry loop entirely.

import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { cache } from "files-sdk/cache";

const files = createFiles({
  adapter: s3({ bucket: "uploads" }),
  plugins: [cache()],
});

await files.head("a.png"); // miss → provider
await files.head("a.png"); // hit  → memory
await files.upload("a.png", body); // invalidates "a.png"
await files.head("a.png"); // miss → provider again

What gets cached

By default cache() caches the two cheap, body-free verbs - head and url. Pass operations to change the set:

cache({ operations: ["head", "url", "download"] });
  • head caches the metadata only. A hit returns a StoredFile whose body still lazy-fetches on access - exactly the contract an uncached head has - so nothing is buffered up front.
  • url caches the returned string, keyed per url-options signature (so a plain url() and a url({ expiresIn }) cache apart). Each entry is additionally capped at its own expiresIn, so a presigned URL is never handed out past its signature.
  • download is off by default. With "download" enabled, only known-length bodies at or under maxBytes (default 1 MiB) are buffered and cached; anything larger - or of unknown length - streams straight through uncached, so streaming and range downloads keep working. A cached small body is re-served as a fresh, re-readable StoredFile.
cache({
  operations: ["head", "url", "download"],
  maxBytes: 256 * 1024, // only cache downloads ≤ 256 KiB
});

Invalidation

Caching is only safe because writes evict. Every mutation through the instance drops the affected key’s entire record (all of its cached verbs) once the write lands:

Write Invalidates
upload(key) key
delete(key) key
copy(from, to) to (destination)
move(from, to) from and to

Invalidation is keyed by the caller-facing key - never the internal prefixed path - so it lines up with the keys reads are cached under.

Writes the cache can’t see

A change the plugin never observes won’t invalidate: an upload made through a presigned URL, or a mutation straight against the provider. Treat the cache as eventually-consistent and evict by hand when that happens:

await files.invalidateCache("a.png"); // drop one key
await files.invalidateCache(); // drop everything

Stats

cacheStats() returns a fresh { hits, misses } snapshot for tuning your TTL and entry budget; resetCacheStats() starts a new window:

files.cacheStats(); // { hits: 41, misses: 9 }
files.resetCacheStats();

The store

By default the cache is a bounded in-memory LRU keyed by object key, holding maxEntries keys (default 1000) before evicting the least-recently-used. Each key’s record bundles every cached verb together, which is what makes invalidation a single delete.

Pass your own store to share the cache across instances or processes - e.g. a Redis-backed CacheStore that serializes each record:

const files = createFiles({
  adapter: s3({ bucket: "uploads" }),
  plugins: [cache({ store: myRedisStore })],
});

A CacheStore is four methods - get, set, delete, clear - each of which may be sync or async. A distributed store has an inherent read-modify-write race when two different verbs for the same key are first cached at the exact same instant; it’s harmless - it just costs a re-fetch next time.

TTL

Every entry honors a ttl (default 60_000 ms). Set 0 to disable time-based expiry entirely (entries then live until evicted or invalidated):

cache({ ttl: 30_000 });

For url, keep ttl comfortably below your signed-URL expiry. The per-entry expiresIn cap guarantees you never serve a dead URL, but a short ttl keeps the URLs you hand out fresh with plenty of life left.

Ordering

Place cache() first (outermost) so a hit short-circuits before the rest of the pipeline does any work:

plugins: [cache(), encryption(key)];

Put it after a body-transforming plugin only if you deliberately want to cache the transformed bytes (e.g. caching post-compression() output).

Things to keep in mind

  • A cache is eventually-consistent. Out-of-band writes (presigned uploads, direct provider changes) won’t invalidate - call invalidateCache(), and keep the ttl honest.
  • download caching buffers bodies. It’s gated to small, known-length objects for exactly this reason; large and unknown-length downloads always stream through untouched.
  • Bound your memory. With download enabled, size maxEntries and maxBytes together - the product is your ceiling.
  • It’s per-instance by default. The in-memory store lives with the Files instance. Reach for a shared store to cache across processes.

Was this page helpful?