s3nd

Snapshots

The envelope, compression, schema versions and expiry.

A snapshot is your application's local state at a point in time, wrapped in an envelope that says what it is.

await store.putSnapshot('K7QP2M4X', state, { app: 'notes', version: 3, device: 'Pixel 8' })

The envelope

What actually lands in the bucket, before gzip:

{
  "s3nd": 1, // envelope format — s3nd's, not your data's
  "app": "notes",
  "version": 3, // your schema version
  "device": "Pixel 8",
  "createdAt": "2026-08-27T12:00:00.000Z",
  "expiresAt": "2026-08-27T13:00:00.000Z",
  "data": {/* whatever you passed */},
}

Every field but s3nd, createdAt and data is optional, and every one of them earns its place on the receiving side:

  • app catches the case where a code from one of your products is typed into another.
  • version is what lets a restore refuse data it cannot read. See below.
  • device and createdAt are what you show the user before overwriting their database: "Restore 214 notes from Pixel 8, saved 6 minutes ago?" is a very different prompt from "Restore?".

Compression

putSnapshot() gzips by default. This is not a micro-optimisation: an IndexedDB dump is repetitive JSON — the same keys on every record — and gzip routinely cuts it by 5 to 10×. That compression is most of what keeps a real database under the request limit of whatever runs your API.

await store.putSnapshot(code, state, { compress: false }) // stores readable JSON

Turn it off when you want to be able to read the object straight out of the bucket while debugging. getSnapshot() detects which one it is reading, so both round-trip and you can change your mind without migrating anything.

Schema versions

Pass your own schema version on write, and the highest one this build understands on read:

await store.putSnapshot(code, state, { version: SCHEMA_VERSION })

const snapshot = await store.getSnapshot(code, { maxVersion: SCHEMA_VERSION })

A snapshot written by a newer build throws SNAPSHOT_TOO_NEW. That is the dangerous direction: the user updated the app on their laptop, sent a snapshot, and the phone still runs last month's build. Without the check, that phone reads fields it does not know about, drops them, and writes the truncated version back — data loss that looks like a successful restore.

The other direction is yours to handle. A snapshot at version 1 arriving in a version 3 app is an ordinary migration; run the same upgrade path you already run against a local database.

const snapshot = await store.getSnapshot(code, { maxVersion: SCHEMA_VERSION })

if (snapshot && snapshot.version !== SCHEMA_VERSION) {
  snapshot.data = migrate(snapshot.data, snapshot.version ?? 0, SCHEMA_VERSION)
}

Expiry

await store.putSnapshot(code, state, { expiresIn: 60 * 60 })

After that, getSnapshot() returns null — the same answer as a code that was never used, which is exactly what you want to tell the user. An expired snapshot is never handed over, even while the object is still in the bucket.

s3nd does not delete it. Removing the object is a job for an S3 lifecycle rule on the prefix, which runs server-side, costs nothing, and keeps working when your application is not:

{
  "Rules": [
    {
      "ID": "expire-transfer-snapshots",
      "Status": "Enabled",
      "Filter": { "Prefix": "snapshots/" },
      "Expiration": { "Days": 1 },
    },
  ],
}

There is no default expiry. A transfer code should have a short one; a per-account backup should have none. Since s3nd cannot tell which you are doing, it does not guess — passing expiresIn on a transfer is your job.

Reading one back

const snapshot = await store.getSnapshot<AppState>(code, { maxVersion: SCHEMA_VERSION })

getSnapshot is generic, so snapshot.data comes back as your own type rather than unknown. That is a convenience, not a validation — nothing checks the bytes against that type. If the snapshot can come from an older build of your app, or from anywhere you do not fully control, parse it before you trust it.

Field
dataWhat you passed to putSnapshot().
app, version, deviceWhatever was stamped on write.
createdAt, expiresAtDate objects.
etagPass it back as ifMatch on the next write. See two devices.
sizeStored size, after compression.
key, path, bucketWhere it lives.

Snapshots are not a sync engine

Restoring replaces the receiving device's state wholesale. There is no per-record merge, and nothing reconciles two divergent databases. For "move my data to my new phone" that is the correct and simplest model. For "two people edit the same list at the same time" it is not — reach for a CRDT, and use s3nd to store its state if you like.

On this page