How big can a snapshot be
A snapshot goes through your server, so your runtime sets the ceiling.
A snapshot travels from the browser to your API, and only then to S3. Your runtime's request limit is therefore the ceiling:
| Runtime | Max request body |
|---|---|
| AWS Lambda (synchronous invoke, API Gateway, function URL) | 6 MB |
| Vercel Serverless Functions | 4.5 MB |
| Netlify Functions | 6 MB |
Next.js Pages Router API routes (bodyParser) | 1 MB by default, configurable |
| Long-running Node server (Express, Fastify, Docker…) | whatever you configure |
That is the compressed size, and compression is doing a lot of work here.
What gzip buys you
An IndexedDB dump is the same keys repeated over every record, which is the best case for gzip. In practice a dump compresses 5–10×, so a 4.5 MB limit accommodates something like 20–45 MB of raw JSON — tens of thousands of ordinary records. Most local-first apps never come close.
Measure yours rather than guessing:
const result = await store.putSnapshot(code, state)
console.log(result.size) // bytes actually storedFailing early
Set maxSize below your platform's limit. s3nd checks it before sending anything, so an
oversized snapshot costs a comparison instead of a truncated request:
const store = createBucket({
bucket: process.env.S3_BUCKET,
maxSize: 4 * 1024 * 1024, // under Vercel's 4.5 MB
})try {
await store.putSnapshot(code, state)
} catch (error) {
if (isS3ndError(error) && error.code === 'FILE_TOO_LARGE') {
return Response.json({ error: 'This database is too large to transfer in one piece' }, { status: 413 })
}
throw error
}When you outgrow it
Split by object store. One snapshot per store, under keys derived from the same code, plus a small manifest. Each piece stays small, a failed piece is cheap to retry, and the receiving device can show real progress:
await store.putSnapshot(`${code}/manifest`, { stores: ['notes', 'attachments'], version: SCHEMA_VERSION })
await store.putSnapshot(`${code}/notes`, notes)
await store.putSnapshot(`${code}/attachments`, attachments)Leave the attachments out of the JSON. Base64 inside a snapshot inflates binary by a third and defeats gzip. Store blobs as objects and reference them by key — see attachments beside the data.
Presign the upload. For a genuinely large database, have the browser talk to S3 directly so
the bytes never touch your runtime. That needs CORS on the bucket, which is the thing s3nd
otherwise saves you from — worth it above a certain size, not before. Today you build it over
store.client; v0.2 makes it first-class, along with multipart.
Storage cost
Snapshots are small and S3 is cheap, but a transfer snapshot that nobody deletes is a bill that only grows. Put a lifecycle rule on the prefix and stop thinking about it:
{
"Rules": [
{
"ID": "expire-transfer-snapshots",
"Status": "Enabled",
"Filter": { "Prefix": "snapshots/" },
"Expiration": { "Days": 1 },
},
],
}Per-account backups want a different rule, or none — keep them under their own prefix so the two policies do not collide.