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:
appcatches the case where a code from one of your products is typed into another.versionis what lets a restore refuse data it cannot read. See below.deviceandcreatedAtare 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 JSONTurn 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 | |
|---|---|
data | What you passed to putSnapshot(). |
app, version, device | Whatever was stamped on write. |
createdAt, expiresAt | Date objects. |
etag | Pass it back as ifMatch on the next write. See two devices. |
size | Stored size, after compression. |
key, path, bucket | Where 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.