Introduction
IndexedDB never leaves the browser it was written in. s3nd is the piece that carries it across.
Move a local-first app's data from one device to another, through your own bucket.
import { createBucket } from 's3nd'
const store = createBucket({ bucket: 'my-bucket', prefix: 'snapshots' })
// On the old device: hand the user a code.
const code = store.codes.create() // "K7QP2M4X"
await store.putSnapshot(code, state, { app: 'notes', version: 3, expiresIn: 3600 })
// On the new device: they type it in.
const snapshot = await store.getSnapshot(store.codes.normalize(typed), { maxVersion: 3 })
snapshot?.data // → the state, ready to write back into IndexedDBThe problem
Local-first is a good trade. Keep everything in IndexedDB and the app is instant, works offline, and nobody's notes sit on your server by default.
Then the user gets a new phone.
IndexedDB is scoped to one origin in one browser profile on one device. There is no sync, no export, no "sign in and it appears". Clearing site data wipes it. Safari evicts it after seven days without a visit. From the user's side this reads as "the app lost my data" — which is, functionally, what happened.
Building the way out is where local-first apps stall: you need a place to put the bytes, a way for the second device to find them, and enough structure that a restore does not corrupt the app.
What s3nd does
It is the server-side piece of exactly that. Your app hands its local state to your own API; that route stores a snapshot in a bucket you control, under a short sync code; the other device sends the code back and gets the state.
- Credentials stay on your server. The browser only ever talks to your API. No CORS policy on the bucket, no signing in the client, no S3 keys shipped to a phone.
- Snapshots are self-describing. What lands in the bucket carries the app name, your schema version, the device that wrote it and its timestamps — so a restore can refuse data it cannot read instead of corrupting the database.
- Codes are made to be typed. Crockford base32, no
characters people confuse, folded on the way back in:
oil5and0IL5find the same snapshot. - Concurrent writes are detectable. Two devices writing the same key is a race;
ifMatchturns it into an error you can handle rather than data you silently lose. - Compression is on by default. An IndexedDB dump is repetitive JSON, so gzip routinely cuts it by 5–10× — which is what keeps it under your runtime's request limit.
What it is not
It is not a sync engine. There is no operational transform, no CRDT, no per-record merge. A snapshot is the whole state at a point in time, and restoring replaces what is on the receiving device. That is the right model for "move my data to my new phone" and the wrong one for "two people edit the same document live". If you need the latter, you want a CRDT library — and s3nd can still be where you park its state.
It is not a general S3 client. No list(), no bucket administration, no lifecycle rules. It
hands you its underlying client through store.client, so the AWS SDK is always one command away.
It is not encryption. Your server can read every snapshot it stores. If that is not acceptable for your data, encrypt it in the browser and hand s3nd the ciphertext — see end-to-end encrypted sync.
Where to go next
- Quick start — the two routes, and the client half around them.
- Snapshots — the envelope, compression, schema versions, expiry.
- Move to a new device — the flow end to end.