---
title: cache
description: An LRU or KV cache in front of head(), url(), and small download()s - repeat reads of an unchanged key are served from memory, writes invalidate the key.
---

The built-in `cache()` plugin puts an LRU (or your own KV) in front of the cheap read verbs. A repeat [`head()`](/docs/api/head) or [`url()`](/docs/api/url) - and, opt-in, a small [`download()`](/docs/api/download) - for an unchanged key is served from memory instead of round-tripping to the provider. Any write **through the instance** ([`upload`](/docs/api/upload), [`delete`](/docs/api/delete), [`copy`](/docs/api/copy), [`move`](/docs/api/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](/docs/retries) - a cache hit skips the retry loop entirely.

```ts lineNumbers
import { createFiles } from "files-sdk";
import { r2 } from "files-sdk/r2";
import { cache } from "files-sdk/cache";

const files = createFiles({
  adapter: r2({ 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
```

:::note
`invalidateCache()`, `cacheStats()`, and `resetCacheStats()` are contributed by the plugin's `extend`, so they only appear on the **type** when you construct with [`createFiles`](/docs/plugins/api#createfiles) (identical to `new Files()` at runtime).
:::

## What gets cached

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

```ts
cache({ operations: ["head", "url", "download"] });
```

- **`head`** caches the [`FileInfo`](/docs/api/stored-file) it returns - metadata only, so nothing is buffered.
- **`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. A `url()` without `expiresIn` is capped at `defaultUrlExpiresIn` (default `3600`, the SDK-wide default) - the adapter signs such calls with its own default, which the plugin can't see, so set this to match if you've configured a shorter `defaultUrlExpiresIn` on the adapter. When the URL states its own lifetime (`X-Amz-Expires` on S3 and S3-compatible adapters, `X-Goog-Expires` on GCS) and it's shorter, that wins.
- **`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](/docs/api/download) keep working. A cached small body is re-served as a fresh, re-readable `StoredFile` with its own copy of the bytes and metadata, so a caller mutating one read can't change the next.

```ts
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):

| Write            | Invalidates         |
| ---------------- | ------------------- |
| `upload(key)`    | `key`               |
| `delete(key)`    | `key`               |
| `copy(from, to)` | `to` (destination)  |
| `move(from, to)` | `from` **and** `to` |

The key is dropped both before the write runs and after it settles, whether it succeeded or failed: a write that timed out or errored may still have landed. A read miss that overlaps a write to the same key still returns what it fetched, but never caches it, so a slow `download()` that started before an `upload()` can't re-populate the old bytes after the upload invalidated them. That guard covers reads and writes through the same `cache()`; another process sharing the `store` is covered by the drop after its own write and by the `ttl`.

Invalidation is keyed by the **caller-facing** key - never the internal [prefixed](/docs/prefixes) path - so it lines up with the keys reads are cached under. In the store, each key is scoped by the [`namespace`](#the-store) and the instance `prefix` (`<namespace>/<prefix>/<key>`), so instances sharing one store only see each other's entries when they share both.

A provider [storage event](/docs/events) invalidates too: with [`events()`](/docs/plugins/events) wired to the bucket's notifications on the same instance, an upload through a presigned URL or a change made outside the SDK drops that key's record as the event arrives.

### Writes the cache can't see

Without storage events, a change the plugin never observes won't invalidate: an upload made through a [presigned URL](/docs/api/signed-upload-url), or a mutation straight against the provider. Treat the cache as **eventually-consistent** and evict by hand when that happens:

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

`invalidateCache()` with no key calls the store's `clear()`, so on a shared `store` it drops every namespace's entries, not only this instance's.

## Stats

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

```ts lineNumbers
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.

:::warning
Worst-case memory is roughly `maxEntries * maxBytes` once `download` caching is on. The default set (`head` + `url`) stores only small metadata, so the entry count is the only thing to size.
:::

Pass your own `store` to share the cache across instances or processes - e.g. a Redis-backed `CacheStore` that serializes each record. A custom store needs a `namespace` naming the bucket it caches, and `cache()` throws `Invalid` without one: the plugin can't tell which bucket an adapter points at, so it can't keep two buckets' entries apart on its own.

```ts lineNumbers
const files = createFiles({
  adapter: r2({ bucket: "uploads" }),
  plugins: [cache({ store: myRedisStore, namespace: "uploads-prod" })],
});
```

Every `cache()` that shares a store **and** a namespace (and the same `prefix`) serves the others' cached bytes, URLs, and metadata. Give each bucket its own namespace, and reuse one only across instances or processes that address the same bucket.

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

```ts
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.

```ts
cache({
  ttl: 30_000,
  defaultUrlExpiresIn: 900, // the adapter signs url() calls without expiresIn for 15 minutes
});
```

TTL and expiry run off `clock` (default `Date.now`, in ms). Inject a fake for deterministic expiry in tests:

```ts
let now = 0;
const files = createFiles({ adapter, plugins: [cache({ clock: () => now })] });
now += 61_000; // every default-ttl entry is now stale
```

## Ordering

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

```ts
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).

The one plugin that belongs **in front of** it is [`signedUrlPolicy()`](/docs/plugins/signed-url-policy):

```ts
plugins: [signedUrlPolicy({ maxExpiresIn: 60 }), cache()];
```

Plugins run in array order, so a policy placed after `cache()` rewrites `url()` options the cache never sees. The cache would then key and cap each entry by the caller's `expiresIn` rather than the shorter one the policy actually signed, and could serve a 60-second URL for up to an hour. Only URLs that state their own lifetime (S3-style and GCS) are caught either way.

## Things to keep in mind

- **A cache is eventually-consistent.** Out-of-band writes (presigned uploads, direct provider changes) only invalidate when [storage events](/docs/events) reach the instance - otherwise 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` (with a `namespace`) to cache across processes.
- **One `cache()` per instance.** Installing the same `cache()` on an instance with a different adapter or `prefix` throws `Invalid` at construction. Read-only views from [`files.readonly()`](/docs/readonly) still share it. To share entries between instances, give each its own `cache()` with the same `store` and `namespace`.
