---
title: compression
description: Transparently compress object bodies at rest with gzip, deflate, or deflate-raw - reads decompress automatically, no native dependencies.
related:
  - /guides/client-side-encryption-s3-r2
---

The built-in `compression()` plugin compresses every body **at rest** and decompresses it on the way back out - transparently, for single and [bulk](/docs/bulk) calls alike. It's a textbook [wrap plugin](/docs/plugins#wrap-intercepting-operations): it transforms the body on `upload`, reverses it on `download`, and round-trips its bookkeeping through the object's `metadata`.

It uses only the [Compression Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Compression_Streams_API), so it has **no native dependencies** and runs anywhere the SDK does - Node, Bun, Deno, edge runtimes, and the browser. It works on any adapter that [supports metadata](/docs/api/upload).

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

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

await files.upload("notes.txt", "a".repeat(10_000)); // stored gzipped
await (await files.download("notes.txt")).text(); // the original 10k string
```

## How it works

1. On `upload`, the body is compressed with the configured algorithm (gzip by default).
2. If the compressed form is **smaller**, it's stored, and the algorithm plus the original byte length are recorded in the object's `metadata`. If it **wouldn't shrink** - already-compressed inputs like JPEG, ZIP, or encrypted blobs - the original bytes are stored verbatim and marked `identity`, so the plugin never inflates your storage.
3. On `download`, the recorded algorithm decompresses the body back to the original bytes and hands you a normal [`StoredFile`](/docs/api/stored-file) - with its `size` reporting the **original** length and the internal metadata fields hidden.

`head` and `list` report the same original size without decompressing anything. They return metadata only; `download` is what reads the body back through the plugin, so the bytes you get are the original bytes, never the stored compressed ones.

One exception: on adapters whose listing carries no metadata (S3 and the S3-compatible adapters, R2 over HTTP included), a `list()` item can't be recognized as compressed, so it reports the **stored** (compressed) size. `download()` still decompresses it, and `head()` and `download()` report the original size as usual.

Because the algorithm is stored per object, reads always decompress with the right one. Changing the `format` option later never breaks objects written under the old format. An object whose marker names an algorithm this version can't decode (a newer writer's, or a hand-edited one) fails `download()` with `Unsupported`, which is never retried.

## Choosing a format

`compression()` defaults to gzip. Pass `format` to pick another:

```ts lineNumbers
import { compression } from "files-sdk/compression";

compression(); // gzip (default)
compression({ format: "deflate" }); // zlib-wrapped deflate
compression({ format: "deflate-raw" }); // bare deflate, no framing
```

:::note
Brotli is intentionally not offered. It isn't part of the Compression Streams standard, so supporting it would mean a native dependency and break the plugin's run-anywhere promise. The three formats above are the ones every platform ships.
:::

## Ordering

Compression should run **before** encryption, so it sees plaintext - encrypted bytes are effectively random and don't compress. Put it **earlier** in the array:

```ts
plugins: [compression(), encryption(key)];
```

Because reads unwind the [onion](/docs/plugins#wrap-intercepting-operations) in reverse, a download automatically runs decrypt → decompress. You never hand-manage the symmetry.

## Things to keep in mind

:::warning
The plugin **buffers the entire body in memory** to compare the compressed and original sizes. It's unsuitable for unknown-length streams.
:::

- **[Resumable uploads](/docs/resumable) throw.** Compressed output isn't byte-for-byte stable across runtimes or versions (Node and Bun already produce different bytes for the same input), so a session resumed in another process could splice two different compressed streams into an object that can't be decompressed. An `upload` with a `control` is refused with a permanent `FilesError` before any bytes move, and [`files.capabilities`](/docs/capabilities) reports `resumable: false`. `multipart: true` without a `control` still splits a large body into parts within one call.
- **Range downloads throw.** A byte range of the original maps to no fixed slice of the compressed bytes, so a [`download`](/docs/api/download) with a `range` is refused.
- **`url()` and `signedUploadUrl()` throw.** A presigned GET hands out compressed bytes with no `Content-Encoding`, so a client receives them as-is and can't read them; a presigned PUT would silently bypass compression and store uncompressed bytes. Both fail closed - upload and download through the instance instead.
- **It narrows [`files.capabilities`](/docs/capabilities).** `signedUrl.supported`, `signedUpload.supported`, and `rangeRead` (as well as `resumable`, above) read `false` while the plugin is installed, whatever the adapter can do, and a `range` or `control` is refused before any plugin runs. The [gateway](/docs/ui/server/gateway) branches on them, so with the default `downloadMode: "auto"` it streams downloads through the instance (decompressing on the way) instead of redirecting to a signed URL the plugin refuses, and it proxies uploads through the instance instead of presigning them. A `Range` request gets the non-range treatment: `416` by default (except `bytes=0-`, which gets the full body), or the full body with `onUnsupportedRange: "ignore"`.
- **`copy` and `move` just work.** They operate on the stored bytes server-side, and the algorithm marker rides along in the object's metadata, so the copy still decompresses.
- **Mixed buckets are safe.** On read, objects without this plugin's marker (pre-existing data, or anything written elsewhere) pass straight through unchanged, so you can enable it on a bucket that already holds plain objects.
- **[Storage events](/docs/events) carry no `size`.** With [`events()`](/docs/plugins/events), every provider event has its `size` cleared: an event carries no metadata, so the plugin can't tell a compressed object (whose stored size isn't the caller's) from a plain one.
- **It needs metadata support.** The algorithm and original size are stored as object metadata, so the adapter must [support metadata](/docs/api/upload) - an `upload` to one that doesn't throws before any bytes move.

## What it stores in metadata

Each object this plugin writes carries two `fscmp_`-prefixed metadata fields: `fscmp_alg` (the algorithm, or `identity` when stored verbatim) and `fscmp_size` (the original, uncompressed byte length). They're stripped from what `download`, `head`, and `list` return, so your own metadata is all you see.
