---
title: tiering
description: Route operations between a hot and a cold adapter by size, prefix, or age - reads transparently find objects and tier() moves them between stores.
---

The built-in `tiering()` plugin spreads one logical bucket across two adapters - a **hot** tier for what you reach for often and a **cold** tier for what you rarely touch. An [`upload`](/docs/api/upload) lands in the tier your `route` function picks; every read transparently finds it again. The hot tier is the instance's own adapter (reached through the rest of the pipeline); the cold tier is a second adapter you pass in.

It's **body-transparent**: it never buffers or transforms bytes - a cross-tier copy streams straight through - so streaming, [range downloads](/docs/api/download), [`url()`](/docs/api/url), and [`signedUploadUrl()`](/docs/api/signed-upload-url) all keep working. It has **no native dependencies** and works on any pair of adapters.

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

const files = createFiles({
  adapter: r2({ bucket: "hot" }), // hot tier
  plugins: [
    tiering({
      cold: r2({ bucket: "cold" }),
      // archives go cold; everything else stays hot
      route: ({ key }) => (key.startsWith("archive/") ? "cold" : "hot"),
    }),
  ],
});

await files.upload("photo.jpg", body); // → hot
await files.upload("archive/2019.zip", zip); // → cold
await files.download("archive/2019.zip"); // transparently read from cold
```

:::note
`tierOf()` and `tier()` 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).
:::

## The route function

`route` is handed `{ key, size? }` and returns `"hot"` or `"cold"`. It's called once per logical operation:

- on `upload`, with `size` set to the body's declared byte length **when it's known up front** (a string, `Blob`, `ArrayBuffer`, or typed array - a streaming body has no declared length);
- on every other decision (reads, `delete`, the destination of `copy` / `move`, `signedUploadUrl`, and locating an object), with `size` **omitted**.

Route by **prefix** and the decision is a pure function of the key, so reads land on the right tier first try with zero overhead:

```ts
route: ({ key }) => (key.startsWith("cold/") ? "cold" : "hot");
```

Route by **size** and writes go by size, but reads can't recompute it - so turn on [`fallback`](#fallback) and a read that misses the guessed tier checks the other:

```ts
tiering({
  cold,
  route: ({ size }) =>
    size !== undefined && size > 5_000_000 ? "cold" : "hot",
  fallback: true,
});
```

## What each verb does

- **`upload`** routes by `route({ key, size })` and writes to that tier.
- **`download` / `head` / `url` / `exists`** consult the routed tier (with [`fallback`](#fallback), `url()` signs against whichever tier holds the key).
- **`delete`** removes the routed tier's copy.
- **`copy` / `move`** locate the source, route the destination by its key, and use a native same-tier op when both keys land in one tier - or **stream the bytes across** (preserving content type and metadata) when they differ. A `move` then deletes the source.
- **`list`** merges a page from each tier into one result, in key order (see [merged listing](#merged-listing)).
- **`signedUploadUrl`** signs against the tier `route({ key })` picks. The resulting direct upload bypasses the plugin, so it can't be size-routed, and with `fallback` the other tier's stale copy isn't evicted.

## fallback

By default routing is **deterministic**: every operation touches exactly the one tier `route` names, with no extra round-trip. That's the right mode for prefix / key-based routing.

Set `fallback: true` to treat an object's tier as **discoverable** rather than fixed:

- a read that misses the routed tier retries the other tier;
- `url()` first checks which tier holds the key and signs against that one - a presigning adapter mints a URL without checking the object exists, so it can't discover a miss by itself, and a URL for the wrong tier would be a dead link;
- `delete` removes the key from **both** tiers;
- an `upload` evicts the key from the other tier, so a re-upload that flips tiers leaves exactly one copy.

Turn it on whenever the tier isn't a pure function of the key - `size`-based routing, or when you move objects between tiers with [`tier()`](#moving-objects-between-tiers). The cost is at most one extra round-trip on a cold read, and one or two `exists` probes on each `url()`.

## Moving objects between tiers

Two methods land on the instance via `extend`:

```ts
await files.tierOf("photo.jpg"); // "hot" | "cold" | undefined
await files.tier("photo.jpg", "cold"); // stream it to cold, drop the hot copy
```

`tier(key, target)` is the lever for **age-based** transitions, which can't be a write-time decision (a new object's age is zero). Run it on a schedule: list, check each object's `lastModified`, and tier down what's gone cold.

```ts lineNumbers
for await (const file of files.listAll()) {
  const age = Date.now() - (file.lastModified ?? 0);
  if (age > THIRTY_DAYS) {
    await files.tier(file.key, "cold");
  }
}
```

Because `tier()` moves objects to a tier `route` wouldn't pick from the key alone, pair it with `fallback: true` so reads still find what you've moved.

`tier()` runs directly against the two tiers' adapters rather than through the instance, so [hooks](/docs/usage#hooks) and the other plugins on the instance (`audit()`, `cache()`, `usage()`, …) don't see the move. The stored bytes and metadata move verbatim, so body-transforming plugins keep working; invalidate caches or record audits yourself if you need them. On a [read-only](/docs/readonly) instance, `tier()` throws `ReadOnly` instead of moving anything (`tierOf()` still works).

## Merged listing

[`list()`](/docs/api/list) returns objects from **both** tiers. It fetches a page from each, deduplicates (hot wins), sorts by key, and paginates the two tiers independently behind one composite cursor - so [`listAll()`](/docs/api/list) walks the whole namespace across both stores.

Keys come back in global key order across pages. A page may hold fewer than `limit` entries - entries past what both tiers have covered are held back for the next page - or up to the sum of both tiers' limits (each tier's `limit` is applied per tier).

## Ordering and prefixes

- **Place it last (innermost).** Body-transforming plugins like [`encryption()`](/docs/plugins/encryption) and [`compression()`](/docs/plugins/compression) wrap `tiering()` and transform the op on the way in, so they apply to **both** tiers:

  ```ts
  plugins: [encryption(key), tiering({ cold, route })];
  ```

- **Address objects by caller-facing keys.** The cold adapter does **not** receive the instance `prefix`, so configure its own bucket / container and avoid a client `prefix` on a tiering instance.
- **Instance defaults apply to both tiers.** The `timeout`, `retries`, and `signal` you pass to the constructor govern the cold tier (and `tierOf()` / `tier()`) as well as the hot tier, so a hung cold backend is cut off rather than stalling the operation.

## Things to keep in mind

- **The cold tier is a real store.** Cold reads pay its latency; a hot→cold `tier()` or cross-tier `copy` transfers the bytes between adapters.
- **Presigned uploads bypass routing.** A `signedUploadUrl()` upload lands directly in the routed tier: it isn't size-routed, and with `fallback` the other tier's stale copy isn't evicted.
- **[Conditional operations](/docs/conditional-operations) are refused.** Cross-tier routing can't preserve one native compare-and-set, so every conditional mode throws before any I/O, and [`files.capabilities`](/docs/capabilities) reports every `conditional` flag as `false`.
- **Capabilities are what both tiers can do.** `files.capabilities` intersects the hot and cold adapters: booleans are ANDed, the weaker `delimiter` and `signedUrl.expiry` win, and the smaller `maxExpiresIn` applies. The core gates on that up front, so an option the cold tier can't honor (`range`, `metadata`, `cacheControl`, a `delimiter`) is refused on every call, not only on cold-routed keys. `serverSideCopy` reads `false`, since a cross-tier copy streams through the process, and `events` is the hot tier's format, or `false` with `fallback: true`.
- **Without `fallback`, routing must be stable per key.** If an object can live in a tier the key wouldn't route to (size-based routing, or a `tier()` move), enable `fallback` or reads will miss it.
