---
title: tiering
description: Route operations between a hot and a cold adapter by size, prefix, or age. Uploads land in the right store, reads transparently find them again, and tier() moves objects between stores - body-transparent, no native dependencies.
---

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`](/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](/api/download), [`url()`](/api/url), and [`signedUploadUrl()`](/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 { s3 } from "files-sdk/s3";
import { tiering } from "files-sdk/tiering";

const files = createFiles({
  adapter: s3({ bucket: "hot" }), // hot tier
  plugins: [
    tiering({
      cold: s3({ 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
```

<Callout>
  `tierOf()` and `tier()` are contributed by the plugin's `extend`, so they only
  appear on the **type** when you construct with
  [`createFiles`](/plugins/api#createfiles) (identical to `new Files()` at
  runtime).
</Callout>

## 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.
- **`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, keys sorted within the page (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 or deduplicated.

## 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;
- `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.

## 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.

## Merged listing

[`list()`](/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()`](/api/list) walks the whole namespace across both stores.

Two things to know: a page can hold up to the sum of both tiers' page sizes (each tier's `limit` is applied per tier), and keys are sorted **within** a page but - because the tiers paginate independently - not globally across pages. For a single fully-ordered enumeration, list an adapter directly.

## Ordering and prefixes

- **Place it last (innermost).** Body-transforming plugins like [`encryption()`](/plugins/encryption) and [`compression()`](/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.

## 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 and isn't size-routed or deduplicated.
- **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.
