The Bytes Have to Go Somewhere
Nine lessons in, the optimizer is a small marvel. A user drops a 4MB JPEG, the worker pool decodes it once, encodes three responsive widths in two formats, generates a 20-pixel base64 LQIP, and surfaces all of it as Blob objects in browser memory.
That last sentence is the catch. The blobs are in browser memory. Refresh the page and they vanish. Close the tab and they vanish. The user’s investment of CPU time and the entire encode pipeline produces a result that exists only inside a single tab on a single device.
For the demo route, that is fine. For a production application, it is the moment the rubber meets the road and most teams discover the architectural diagram in the previous lesson is missing a substantial chunk of plumbing.
This lesson is that plumbing. Six encoded buffers per image (three widths times two formats), one LQIP base64 string, a handful of metadata fields from Lesson 5 (alt text, dimensions, optional EXIF), all of this needs to leave the browser and land in two places: the encoded buffers go to object storage (R2, S3, GCS - the choice rarely matters), and a single row describing the image goes to your relational database. Both writes have to succeed atomically; a half-uploaded image with a database row that points to missing variants is worse than no image at all.
By the end of this lesson you will have a presigned-PUT endpoint that authenticates the user before issuing URLs, a per-variant upload helper with retry and progress reporting, a transactional commit that writes the database row only after every variant has uploaded successfully, a +page.server.ts load function that reads images back into the render side, and an orphan-cleanup strategy that prevents your bucket from drifting out of sync with your database over time.
The Three Persistence Architectures, and Why Presigned PUT Wins
Before any code, the architectural choice. Three patterns are commonly used to upload encoded variants from the browser to object storage. Two are wrong for this pipeline and one is the right answer.
Pattern A: Server proxy
The browser POSTs every variant as a multipart form to your SvelteKit endpoint. The endpoint receives the bytes, authenticates the user, and writes to the bucket. This works, but it routes every byte through your origin server, which means you pay for ingress bandwidth twice (browser → origin → bucket), and your endpoint has to handle multi-megabyte streaming uploads under load.
For a moderately busy site it adds 30–80% to your origin bandwidth bill and bottlenecks on the function timeout for slow uploaders. The privacy property from Lesson 12 also suffers - the encoded variants now do pass through your origin, even though the original file does not.
Pattern B: Direct unsigned PUT to a public bucket
The browser writes directly to a public-write bucket. This is the architecturally cheapest option and the most catastrophic from a security standpoint. Anything with the bucket URL can write anything, including replacing existing objects with malicious content. Never do this; the only reason to mention it is that some tutorials still recommend it.
Pattern C: Direct PUT with a presigned URL
The browser asks your SvelteKit endpoint for a short-lived signed URL that authorises exactly one PUT to exactly one object key. The endpoint authenticates the user, decides whether they are allowed to upload to that key, and returns the URL.
The browser then PUTs the bytes directly to object storage. The bucket itself stays private; only the holder of a fresh presigned URL can write a single object, and only for a few minutes.
Pattern C is what every production pipeline uses, and it is what this lesson builds. The bytes go directly from the user’s machine to object storage with no origin in between, your origin handles only short JSON requests (signing the URLs and writing the database row), and authentication happens at the URL-issuance step where it belongs.
┌────────────────┐
│ User's Browser│
└───────┬────────┘
│
│ 1. POST /api/uploads/sign
│ { altText, width, height,
│ variants: [{key, contentType, size}] }
▼
┌────────────────┐
│ SvelteKit │
│ +server.ts │
│ │
│ - authenticate│
│ - validate │
│ - sign URLs │
└───────┬────────┘
│
│ 2. { variants: [{key, putUrl}, ...],
│ imageId, commitToken }
▼
┌────────────────┐
│ User's Browser│
│ │
│ PUT each URL │ 3. direct PUT (no origin)
│ with retry + ├─────────────► R2 / S3 / GCS
│ progress │
└───────┬────────┘
│
│ 4. POST /api/uploads/commit
│ { commitToken, imageId,
│ lqip, alt, width, height,
│ uploadedKeys[] }
▼
┌────────────────┐
│ SvelteKit │
│ +server.ts │
│ │
│ - verify keys │
│ - INSERT row │
└────────────────┘ Three round-trips total: sign, upload (one PUT per variant, in parallel), commit. Two of the three are tiny JSON exchanges; the third is the actual data, and it never touches your origin.
The Database Schema
Before signing anything, decide what the row looks like. The schema is small and the columns map directly to fields the pipeline already produces.
-- Postgres example. SQLite, MySQL, and most cloud databases support the
-- same shape with minor type differences (TEXT vs VARCHAR, etc.).
CREATE TABLE images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
base_url TEXT NOT NULL, -- e.g. https://cdn.example.com/images/abc123
alt TEXT NOT NULL, -- enforced ≥ 5 chars at the upload UI (Lesson 12)
decorative BOOLEAN NOT NULL DEFAULT false, -- decorative escape hatch (Lesson 12)
width INTEGER NOT NULL, -- intrinsic width of largest variant
height INTEGER NOT NULL, -- intrinsic height of largest variant
lqip TEXT NOT NULL, -- "data:image/webp;base64,UklGRi..." (~600 bytes)
-- Optional metadata from Lesson 5 (exifr); nullable because not every file has them.
camera TEXT,
taken_at TIMESTAMP WITH TIME ZONE,
gps_lat DOUBLE PRECISION,
gps_lng DOUBLE PRECISION,
-- Audit columns
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
deleted_at TIMESTAMP WITH TIME ZONE -- soft delete
);
CREATE INDEX images_created_by_idx ON images(created_by) WHERE deleted_at IS NULL;
CREATE INDEX images_created_at_idx ON images(created_at) WHERE deleted_at IS NULL; A few decisions in this shape are worth being explicit about.
base_url is the only URL stored. Variant URLs (-400w.webp, -800w.avif, etc.) are derived at render time from the base URL and the shared IMAGE_WIDTHS constant from Lesson 8. Storing each variant URL as its own column would couple the schema to the breakpoint config and break every existing row the next time you add a 2400w variant.
The LQIP is a TEXT column, not a BYTEA. It already has the data:image/webp;base64, prefix; storing it as a string means it serialises naturally in the JSON payload your +page.server.ts returns to the browser, with no driver-specific decoding. At ~600 bytes per row this is comfortably small.
decorative BOOLEAN is the escape hatch from Lesson 12. When decorative = true, the rendering component emits alt="" and adds role="presentation" regardless of what alt contains. This makes the alt-text gate enforceable at the UI layer (every row has a non-empty alt) without breaking genuinely decorative imagery.
Soft delete via deleted_at. Hard-deleting a row before the storage objects have been removed creates an orphan: bytes in your bucket no row points to. A soft-delete column lets you delete the row immediately for the user-facing experience while a background job handles the storage cleanup, which can take seconds or minutes depending on how many variants and which provider.
created_by foreign key. The presigned-URL endpoint will use this column to enforce that one user cannot delete another user’s images. Without it, every authorisation check has to traverse a join from images back to whatever owns them.
The migration that creates this table belongs in your existing migration tool - Drizzle, Prisma, raw SQL through pnpm migrate, whatever the rest of the project uses. The schema is identical regardless.
The Presigned URL Endpoint
The endpoint that issues presigned URLs is a POST /api/uploads/sign route. It receives a JSON request describing the variants the client wants to upload, authenticates the user, validates the request, and returns a parallel array of presigned URLs plus a commit token that ties the eventual database write to this signing event.
// src/routes/api/uploads/sign/+server.ts
import { error, json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import * as v from 'valibot'
import { CDN_BASE, BUCKET_NAME, R2_ENDPOINT } from '$env/static/private'
// Cloudflare R2 speaks the S3 API. Same endpoint code with a different
// `endpoint` URL works for AWS S3, Backblaze B2, MinIO, and most others.
const s3 = new S3Client({
region: 'auto',
endpoint: R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!
}
})
const ALLOWED_CONTENT_TYPES = ['image/webp', 'image/avif'] as const
const MAX_VARIANT_BYTES = 5 * 1024 * 1024 // 5 MB per variant - generous ceiling
const MAX_VARIANTS_PER_IMAGE = 8 // 3 widths × 2 formats + a couple of safety slots
const URL_VALIDITY_SECONDS = 5 * 60 // 5-minute window between sign and PUT
const VariantRequest = v.object({
suffix: v.pipe(
v.string(),
// Strict allow-list. The suffix lands in the object key and the URL,
// so it must not contain anything that could break URL parsing or
// influence storage layout (no slashes, no dots, no shell metacharacters).
v.regex(/^[0-9]{2,4}w\.(webp|avif)$/, 'Invalid variant suffix')
),
contentType: v.picklist(ALLOWED_CONTENT_TYPES),
size: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(MAX_VARIANT_BYTES))
})
const SignRequest = v.object({
variants: v.pipe(v.array(VariantRequest), v.minLength(1), v.maxLength(MAX_VARIANTS_PER_IMAGE))
})
export const POST: RequestHandler = async ({ request, locals }) => {
// 1. Authenticate. `locals.user` is populated by your hook from the session
// cookie. Reject anonymous requests outright; presigned URLs are an
// authenticated capability, not a public service.
if (!locals.user) throw error(401, 'Unauthenticated')
// 2. Parse and validate the request body. Valibot rejects anything that
// does not match the schema; the catch turns the parse error into a 400.
let body: v.InferInput<typeof SignRequest>
try {
body = v.parse(SignRequest, await request.json())
} catch {
throw error(400, 'Invalid request')
}
// 3. Per-user rate limit. Issuing presigned URLs is cheap, but a bot that
// spams them can fill your bucket with zero-byte objects (the sign
// endpoint does not know if the PUT will follow). Cap per-user signs
// at, say, 50 per minute.
if ((await locals.rateLimiter.signRequests(locals.user.id)) === 'exceeded') {
throw error(429, 'Too many sign requests')
}
// 4. Mint an image id once. Every variant uses this id as its key prefix,
// so the database row only needs to remember the prefix.
const imageId = crypto.randomUUID()
// 5. Sign one URL per variant. Promise.all keeps this O(1) round-trips.
const signed = await Promise.all(
body.variants.map(async ({ suffix, contentType, size }) => {
const key = `images/${imageId}-${suffix}`
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
ContentType: contentType,
ContentLength: size,
CacheControl: 'public, max-age=31536000, immutable'
})
const putUrl = await getSignedUrl(s3, command, { expiresIn: URL_VALIDITY_SECONDS })
return { suffix, key, putUrl }
})
)
// 6. Mint a commit token. The token binds (user, imageId, expected keys) so
// the eventual /commit call cannot be made by a different user against
// a different image. Store it in your session cache (Redis / KV / DB).
const commitToken = await locals.cache.issueCommitToken({
userId: locals.user.id,
imageId,
expectedKeys: signed.map((s) => s.key),
expiresAt: Date.now() + URL_VALIDITY_SECONDS * 1000
})
return json({
imageId,
baseUrl: `${CDN_BASE}/images/${imageId}`,
commitToken,
variants: signed.map(({ suffix, key, putUrl }) => ({ suffix, key, putUrl }))
})
} Several details in this endpoint are load-bearing.
The schema validates size and contentType per variant. The presigned URL bakes in both values; a client that requests a URL for a 200KB AVIF and then PUTs a 2MB AVIF will get a 403 from the storage layer because the signature does not match. This is a defence-in-depth mechanism - the client cannot inflate its quota by under-reporting size at sign time.
The suffix regex is an allow-list, not a denylist. Anything outside \d{2,4}w.(webp|avif) is rejected. This blocks path traversal (../../../etc/passwd), unsupported formats (a maliciously crafted suffix=400w.exe), and accidentally large keys.
The commit token is opaque and short-lived. The client cannot forge it, cannot reuse it for a different image, and cannot use it after the URL window expires. The expectedKeys array means the commit endpoint can verify every variant the client claims to have uploaded was actually authorised in the original sign call.
CacheControl is set on the presigned PUT, not just on the bucket default. Setting it here means every uploaded object inherits the immutable header from Lesson 12 without depending on a bucket-level policy that might be changed later or might not exist at all on a fresh deployment.
Per-Variant PUT with Retry and Progress
The client side of the upload is a function that takes a list of { key, putUrl, blob } triples and PUTs each in parallel, with retry on transient failures and progress callbacks the UI can hook into.
// src/lib/upload-client.svelte.ts
export interface VariantUpload {
suffix: string
key: string
putUrl: string
blob: Blob
}
export interface UploadProgress {
suffix: string
loaded: number
total: number
state: 'pending' | 'uploading' | 'retrying' | 'done' | 'failed'
}
export interface UploadOptions {
maxConcurrent?: number
maxRetries?: number
signal?: AbortSignal
onProgress?: (progress: UploadProgress) => void
}
const DEFAULT_OPTS: Required<Omit<UploadOptions, 'signal' | 'onProgress'>> = {
maxConcurrent: 4,
maxRetries: 3
}
// Exponential backoff with jitter. The jitter (a random fraction of the base
// delay) prevents synchronised retries from a fleet of clients all hitting
// the same network blip - without it, a transient outage produces a thundering
// herd as soon as it clears.
function backoffDelay(attempt: number): number {
const base = Math.min(8000, 500 * 2 ** attempt) // 500, 1000, 2000, 4000, 8000
const jitter = Math.random() * base * 0.5
return base + jitter
}
async function putWithProgress(
upload: VariantUpload,
signal: AbortSignal | undefined,
onProgress: ((p: UploadProgress) => void) | undefined
): Promise<void> {
// fetch() does not support upload progress. XMLHttpRequest does, and the
// browser supports both AbortController and progress events on it.
// This is the one place in a modern codebase where XHR is genuinely the
// right tool, until the Streams Upload API ships everywhere.
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', upload.putUrl)
xhr.setRequestHeader('Content-Type', upload.blob.type)
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return
onProgress?.({
suffix: upload.suffix,
loaded: event.loaded,
total: event.total,
state: 'uploading'
})
}
xhr.onload = () => {
// S3-compatible APIs return 200 on a successful PUT; some return 204.
// Treat the whole 2xx range as success and surface anything else as
// a retryable error (the retry loop decides what is actually retryable).
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error(`PUT ${upload.suffix} failed: ${xhr.status} ${xhr.statusText}`))
}
xhr.onerror = () => reject(new Error(`PUT ${upload.suffix} network error`))
xhr.ontimeout = () => reject(new Error(`PUT ${upload.suffix} timed out`))
signal?.addEventListener('abort', () => xhr.abort(), { once: true })
xhr.send(upload.blob)
})
}
export async function uploadVariants(
uploads: VariantUpload[],
options: UploadOptions = {}
): Promise<{ key: string; suffix: string }[]> {
const opts = { ...DEFAULT_OPTS, ...options }
const queue = [...uploads]
const completed: { key: string; suffix: string }[] = []
async function runOne(upload: VariantUpload): Promise<void> {
let lastError: unknown
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
if (options.signal?.aborted) throw new DOMException('Aborted', 'AbortError')
try {
if (attempt > 0) {
options.onProgress?.({
suffix: upload.suffix,
loaded: 0,
total: upload.blob.size,
state: 'retrying'
})
await new Promise((r) => setTimeout(r, backoffDelay(attempt)))
}
await putWithProgress(upload, options.signal, options.onProgress)
completed.push({ key: upload.key, suffix: upload.suffix })
options.onProgress?.({
suffix: upload.suffix,
loaded: upload.blob.size,
total: upload.blob.size,
state: 'done'
})
return
} catch (err) {
lastError = err
// Abort errors are not retryable.
if ((err as Error).name === 'AbortError') throw err
}
}
options.onProgress?.({
suffix: upload.suffix,
loaded: 0,
total: upload.blob.size,
state: 'failed'
})
throw lastError
}
// Bounded concurrency: at most maxConcurrent uploads in flight at once.
const workers: Promise<void>[] = []
for (let i = 0; i < Math.min(opts.maxConcurrent, queue.length); i++) {
workers.push(
(async () => {
while (queue.length > 0) {
const next = queue.shift()
if (!next) return
await runOne(next)
}
})()
)
}
await Promise.all(workers)
return completed
} Three things in this implementation are worth understanding even if you have used similar patterns before.
The XHR-versus-fetch tradeoff is real. fetch() does not expose upload progress events; the only way to wire a progress bar to a multi-megabyte PUT today is XMLHttpRequest.upload.onprogress. The Streams Upload API is the eventual replacement but is not yet universal in 2026. For a download or for a JSON request without progress, prefer fetch; for an upload that is large enough for the user to notice, XHR is correct.
Retry only for genuinely transient failures. The implementation above retries every non-abort error, which is the right default for HTTP PUTs against object storage. Most failures are network blips or signed-URL clock skew, both of which clear within a few seconds. For a stricter implementation, inspect the status code and skip retry on 4xx (the request was rejected; retrying will not help) while retrying on 5xx and network errors. The simpler form is fine for most production traffic.
The progress callback fires on state: 'retrying' as well as 'uploading'. This lets the UI show “Retrying 800w.avif…” instead of “Stuck at 0%”, which substantially improves the perceived reliability of the upload flow on flaky connections.
Wiring the Optimizer to the Upload Client
The optimizer.svelte.ts factory from Lesson 4 already exposes encoded buffers and the LQIP. The integration with the new upload client is small: after the worker emits variants and lqip, the optimizer asks the sign endpoint for URLs, dispatches the parallel PUTs, and posts the commit.
// src/lib/optimizer.svelte.ts - additions for production upload
import { uploadVariants, type UploadProgress } from '$lib/upload-client.svelte'
interface UploadResult {
imageId: string
baseUrl: string
}
interface CommitRequest {
commitToken: string
imageId: string
alt: string
decorative: boolean
width: number
height: number
lqip: string
uploadedKeys: string[]
}
async function uploadToProduction(
variants: Array<{ width: number; webp: ArrayBuffer; avif: ArrayBuffer }>,
lqipDataUrl: string,
meta: { alt: string; decorative: boolean; width: number; height: number },
signal: AbortSignal,
onProgress: (p: UploadProgress) => void
): Promise<UploadResult> {
// 1. Build the variant manifest the sign endpoint expects.
const manifest = variants.flatMap(({ width, webp, avif }) => [
{ suffix: `${width}w.webp`, contentType: 'image/webp' as const, size: webp.byteLength },
{ suffix: `${width}w.avif`, contentType: 'image/avif' as const, size: avif.byteLength }
])
// 2. Sign.
const signRes = await fetch('/api/uploads/sign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variants: manifest }),
signal
})
if (!signRes.ok) throw new Error(`Sign failed: ${signRes.status}`)
const sign: {
imageId: string
baseUrl: string
commitToken: string
variants: Array<{ suffix: string; key: string; putUrl: string }>
} = await signRes.json()
// 3. Map signed URLs back to blobs by suffix. The sign endpoint preserves
// the order, so we could index by position; matching by suffix is more
// defensive - if the server reorders for any reason, the mapping still
// holds.
const bySuffix = new Map(sign.variants.map((v) => [v.suffix, v]))
const uploads = variants.flatMap(({ width, webp, avif }) => {
const webpEntry = bySuffix.get(`${width}w.webp`)!
const avifEntry = bySuffix.get(`${width}w.avif`)!
return [
{
suffix: webpEntry.suffix,
key: webpEntry.key,
putUrl: webpEntry.putUrl,
blob: new Blob([webp], { type: 'image/webp' })
},
{
suffix: avifEntry.suffix,
key: avifEntry.key,
putUrl: avifEntry.putUrl,
blob: new Blob([avif], { type: 'image/avif' })
}
]
})
// 4. Upload all variants in parallel with retry. uploadVariants throws on
// the first variant that exhausts retries, which by design aborts the
// whole image - a database row pointing at a missing variant is a bug
// we want to make impossible.
const completed = await uploadVariants(uploads, { signal, onProgress, maxConcurrent: 4 })
// 5. Commit. The server verifies (a) the commitToken is valid, (b) every
// expected key is in `uploadedKeys`, and (c) the user owning the token
// matches the authenticated user. Only then does it write the row.
const commit: CommitRequest = {
commitToken: sign.commitToken,
imageId: sign.imageId,
alt: meta.alt,
decorative: meta.decorative,
width: meta.width,
height: meta.height,
lqip: lqipDataUrl,
uploadedKeys: completed.map((c) => c.key)
}
const commitRes = await fetch('/api/uploads/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(commit),
signal
})
if (!commitRes.ok) {
// The commit failed but the variants are uploaded. Surface the failure
// so the cleanup job picks them up; do NOT retry the commit here, as
// repeated failures here usually mean an authorisation problem.
throw new Error(`Commit failed: ${commitRes.status}`)
}
return { imageId: sign.imageId, baseUrl: sign.baseUrl }
} The function returns the baseUrl and imageId so the calling component can confirm to the user the image is now persisted. The progress callback drives a per-variant progress UI; six PUTs happening in parallel is a more useful display than a single aggregate bar because individual variants succeeding/retrying is more informative than the average.
The Commit Endpoint and Transactional Integrity
The commit endpoint receives the client’s claim that uploads are done and writes the database row. The critical property is atomicity: either the row is written and points to a complete set of uploaded variants, or no row exists and the storage objects (if any made it up) get cleaned up later. There must never be a row whose base_url resolves to fewer variants than the schema implies.
// src/routes/api/uploads/commit/+server.ts
import { error, json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import * as v from 'valibot'
const CommitSchema = v.object({
commitToken: v.string(),
imageId: v.pipe(v.string(), v.uuid()),
alt: v.pipe(v.string(), v.minLength(5), v.maxLength(500)),
decorative: v.boolean(),
width: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(10_000)),
height: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(10_000)),
lqip: v.pipe(
v.string(),
v.regex(/^data:image\/webp;base64,[A-Za-z0-9+/=]+$/, 'Invalid LQIP'),
v.maxLength(2000)
),
uploadedKeys: v.pipe(v.array(v.string()), v.minLength(1))
})
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthenticated')
let body: v.InferInput<typeof CommitSchema>
try {
body = v.parse(CommitSchema, await request.json())
} catch {
throw error(400, 'Invalid commit payload')
}
// 1. Consume the commit token. consumeCommitToken returns null if the
// token does not exist, has expired, has already been used, or belongs
// to a different user. Any of those is a hard 403.
const token = await locals.cache.consumeCommitToken(body.commitToken, {
userId: locals.user.id,
imageId: body.imageId
})
if (!token) throw error(403, 'Invalid or expired commit token')
// 2. Verify the client's `uploadedKeys` matches the keys we authorised
// in the original sign call. This prevents a client from uploading
// different objects than it asked to sign.
const expected = new Set(token.expectedKeys)
const uploaded = new Set(body.uploadedKeys)
if (expected.size !== uploaded.size) throw error(400, 'Variant set mismatch')
for (const key of expected) {
if (!uploaded.has(key)) throw error(400, `Missing variant: ${key}`)
}
// 3. Optionally HEAD each object to verify it actually exists. This costs
// one extra request per variant and prevents the very narrow window
// where a malicious client claims to have uploaded without actually
// doing so. For most production loads, the signed-URL contract is
// already strong enough; uncomment if your threat model demands it.
//
// await Promise.all(
// body.uploadedKeys.map((key) => assertObjectExists(BUCKET_NAME, key))
// );
// 4. Insert the row. The transaction wrapper is unnecessary for a single
// INSERT, but if you also write to a separate `image_audit` table or
// increment a per-user counter, wrap them all in `db.transaction`.
await locals.db.image.create({
data: {
id: body.imageId,
baseUrl: `${process.env.CDN_BASE}/images/${body.imageId}`,
alt: body.alt.trim(),
decorative: body.decorative,
width: body.width,
height: body.height,
lqip: body.lqip,
createdById: locals.user.id
}
})
return json({ imageId: body.imageId })
} The five guards in this endpoint are deliberately layered. The token check ensures only the user who initiated the upload can commit it. The variant-set check ensures the client uploaded exactly what it asked to sign.
The valibot schema ensures the metadata is in range. The optional HEAD check (commented out) closes the residual window where a malicious client could claim a successful upload without actually uploading. The database create is the last step, and it only runs when every preceding check has passed.
If any guard fails, no row is written. The variants the client did upload become orphans, and the cleanup job below removes them on its next pass. That is acceptable: a small amount of orphan storage from failed uploads is cheaper than the alternative of either rejecting marginally-valid uploads or leaving partially-committed rows that point at missing variants.
Cancellation Mid-Upload
The user can navigate away, close the tab, or click “Cancel” while the PUTs are in flight. The pipeline must stop the in-flight work and not leave the database in a half-state.
// In the page or upload component:
let abortController = $state<AbortController | null>(null)
async function startUpload(file: File): Promise<void> {
const ac = new AbortController()
abortController = ac
try {
await optimizer.squashAndUpload(file, { signal: ac.signal })
} finally {
if (abortController === ac) abortController = null
}
}
function cancelUpload(): void {
abortController?.abort()
abortController = null
} Inside optimizer.squashAndUpload, the same signal is forwarded to every fetch call (sign, commit) and to uploadVariants. The XHR-based upload listens to the abort event and calls xhr.abort(). Any variant that was mid-PUT becomes an orphan; the commit endpoint never runs because the abort throws before reaching that step. The cleanup job catches the orphans on its next sweep.
navigator.sendBeacon is sometimes proposed for “fire-and-forget cleanup on page unload”, but for image uploads this is the wrong tool - beacons are limited to 64KB and cannot carry a several-megabyte AVIF. The right model is “abort on unload, let the cleanup job catch up”, which is what the abort-controller pattern above implements.
Reading Images Back: The +page.server.ts Load Function
The render side of the pipeline reads the database row in a SvelteKit +page.server.ts load function and passes the data to the page component. The page then uses the LazyImage from Lesson 9 to render the variants.
// src/routes/products/[slug]/+page.server.ts
import { error } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ params, locals }) => {
const product = await locals.db.product.findUnique({
where: { slug: params.slug, deletedAt: null },
include: {
heroImage: true,
gallery: { where: { deletedAt: null }, orderBy: { createdAt: 'asc' } }
}
})
if (!product) throw error(404, 'Not found')
// Build the JSON-LD on the server so the bot sees it in the initial HTML,
// not after a hydration round-trip.
const heroJsonLd = {
'@context': 'https://schema.org',
'@type': 'ImageObject',
contentUrl: `${product.heroImage.baseUrl}-1600w.webp`,
thumbnailUrl: `${product.heroImage.baseUrl}-400w.webp`,
caption: product.heroImage.alt,
width: product.heroImage.width,
height: product.heroImage.height
}
return { product, heroJsonLd }
} The page component receives the data and renders the appropriate component per pipeline:
<!-- src/routes/products/[slug]/+page.svelte -->
<script lang="ts">
import LazyImage from '$lib/components/LazyImage.svelte'
let { data } = $props()
</script>
<svelte:head>
{@html `<script type="application/ld+json">${JSON.stringify(data.heroJsonLd)}</script>`}
</svelte:head>
<LazyImage
src={data.product.heroImage.baseUrl}
alt={data.product.heroImage.decorative ? '' : data.product.heroImage.alt}
width={data.product.heroImage.width}
height={data.product.heroImage.height}
lqip={data.product.heroImage.lqip}
priority={true}
/>
{#each data.product.gallery as image (image.id)}
<LazyImage
src={image.baseUrl}
alt={image.decorative ? '' : image.alt}
width={image.width}
height={image.height}
lqip={image.lqip}
/>
{/each} The decorative flag is applied at the render site: a decorative image emits alt="", regardless of what string is in the database. The string still exists in the row (the upload UI required it), but the rendering layer respects the editor’s “this is decorative” intent.
Image Lifecycle: Updates, Deletes, and Orphans
Three lifecycle events happen after an image is committed: an update (rare, but does happen - re-uploading at a different quality, or replacing the alt text), a delete (the user removes the image from a product), and the existence of orphans (objects in the bucket that no row references).
Updates rarely change the storage objects. Most edits are metadata-only - alt text changes, decorative flag toggles. Those are pure database updates against the existing row. If the bytes themselves need to change (a quality re-encode, a re-crop), the cleanest approach is to upload a new image with a new imageId, point the parent record at the new id, and let the old id flow through the deletion path. This avoids ever having a “version” concept in the storage layer; every URL always resolves to the same bytes forever, which is what Lesson 12’s immutable cache headers depend on.
Deletes are soft at the database level. Setting deleted_at = now() removes the row from user-visible queries but keeps it in the database for auditing. The variants in storage remain until the cleanup job removes them.
Orphan cleanup is a scheduled job. It compares the set of object keys in the bucket against the set of imageIds in the (live + soft-deleted) database, and removes any object whose imageId is not in the database. It also removes objects belonging to soft-deleted rows older than the retention window (typically 30 days, matching most “trash” UX patterns).
// scripts/cleanup-orphans.ts - run nightly via a cron or Cloudflare Worker
import { db, bucket } from './deps'
const RETENTION_DAYS = 30
const RETENTION_MS = RETENTION_DAYS * 24 * 60 * 60 * 1000
async function listAllObjectKeys(): Promise<Set<string>> {
const keys = new Set<string>()
let continuationToken: string | undefined
do {
const page = await bucket.list({ prefix: 'images/', continuationToken })
for (const obj of page.contents ?? []) keys.add(obj.key)
continuationToken = page.nextContinuationToken
} while (continuationToken)
return keys
}
async function listLiveImageIds(): Promise<Set<string>> {
const cutoff = new Date(Date.now() - RETENTION_MS)
const rows = await db.image.findMany({
where: { OR: [{ deletedAt: null }, { deletedAt: { gt: cutoff } }] },
select: { id: true }
})
return new Set(rows.map((r) => r.id))
}
async function main(): Promise<void> {
const allKeys = await listAllObjectKeys()
const liveIds = await listLiveImageIds()
const orphans: string[] = []
for (const key of allKeys) {
// Keys are "images/<uuid>-<width>w.<ext>". Extract the uuid.
const match = key.match(/^images\/([0-9a-f-]{36})-/)
if (!match) continue // unexpected key shape - skip rather than delete
if (!liveIds.has(match[1])) orphans.push(key)
}
console.log(`[cleanup] Found ${orphans.length} orphan objects`)
// Batch delete in groups of 1000 (the S3 batch-delete limit).
for (let i = 0; i < orphans.length; i += 1000) {
const batch = orphans.slice(i, i + 1000)
await bucket.deleteObjects({ keys: batch })
console.log(`[cleanup] Deleted batch ${i / 1000 + 1}`)
}
// Hard-delete database rows whose grace period has elapsed.
const hardDeleted = await db.image.deleteMany({
where: { deletedAt: { lt: new Date(Date.now() - RETENTION_MS) } }
})
console.log(`[cleanup] Hard-deleted ${hardDeleted.count} rows past grace period`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
}) A few details worth pinning down.
The cleanup is idempotent. Running it twice with no intervening changes deletes nothing the second time. This is the property that makes it safe to run on a frequent cron - five minutes after a deployment, nightly during low-traffic windows, on demand from an admin panel.
Skip-on-unknown-shape rather than delete-on-unknown-shape. The match check returns early on any object key that does not look like the expected pattern. This protects against a future schema change, a hand-uploaded test object, or any other key the cleanup logic does not understand. Bias toward leaving objects alone if they cannot be classified.
The grace period is real, not theoretical. Users delete things by accident. A 30-day window gives an admin path to restore a row by clearing deleted_at, at which point the variants are still in the bucket because the cleanup job has not yet got to them. Without the window, “undo a delete” requires a re-upload from somewhere, which usually means reaching into a backup tape that the team has not actually tested in months.
Edge Cases at Upload Time: HEIC, SVG, Animated, RAW
The optimizer pipeline assumes the user uploads JPEG, PNG, or WebP. A modern phone camera ships HEIC by default. A logo upload is probably SVG. A meme is probably an animated WebP or GIF. A photographer’s portfolio might include RAW. Each of these requires a deliberate decision before the pipeline runs.
HEIC - phones ship HEIC by default since iOS 11; most modern Android cameras now do too. @jsquash does not include a HEIC decoder. There are two reasonable strategies. First, configure the file picker accept="image/*" and let the user’s camera app convert to JPEG on export - this works on iOS but not all Android variants. Second, integrate @jsquash/heif (a separate package) and treat HEIC as a fourth source format in detectFormat. The latter is more code but avoids surprising users whose phones produce HEIC by default.
SVG should bypass the encode pipeline entirely. SVGs are already optimal, do not benefit from raster compression, and break when forced through decode → encode. Detect by MIME (image/svg+xml) at upload time, run the file through SVGO or svgo-loader server-side, and store as a single SVG asset rather than the variant set. The render component for SVGs is a plain <img>; no LazyImage machinery is required.
// In src/lib/optimizer.svelte.ts - early return for non-raster sources
async function squash(file: File): Promise<Blob | null> {
const mime = file.type.toLowerCase()
if (mime === 'image/svg+xml') {
// SVGs go through a separate sanitisation + minification path.
// Do NOT run them through @jsquash; they are vector code, not pixels.
throw new Error('SVG handling: use the SVG-specific upload path, not the raster pipeline.')
}
if (mime === 'image/gif') {
// GIFs are almost always animated. Animated WebP / AVIF are
// far smaller, but encoding them is a different pipeline.
// Reject at intake until the animated pipeline is in place.
throw new Error('Animated images are not yet supported. Use a video format instead.')
}
// ... existing JPEG/PNG/WebP pipeline ...
} Animated images - animated WebP and animated AVIF exist and @jsquash can encode them, but the pipeline as built treats every input as a single frame. Detecting animation requires inspecting the file header (the ANIM chunk in WebP, the meta box in AVIF).
The right answer for most production sites is to reject animated input at upload time and direct the user to use a video format (<video> with autoplay+muted+loop produces a better-compressed, more accessible result than any animated image).
The right answer for sites that genuinely need GIFs (a sticker pack, an emoji panel) is a separate pipeline that preserves animation; it is out of scope for this lesson.
RAW - DNG, NEF, CR2, ARW. These are 30–60MB single-image files that the browser cannot decode without a specialist library, and even then the user almost never wants the encoded result to be RAW. They want the developed JPEG or HEIC their phone or processing software produces. Reject RAW at intake with a clear message.
The general rule
Detect format at intake, route the source through the correct pipeline, and reject anything that does not have a sensible pipeline rather than silently producing an unexpected result downstream.
Common Mistakes and Anti-Patterns
Routing every byte through your own server
Pattern A from earlier - POST every variant as multipart to your origin, then write to the bucket. This pattern costs you 30–80% extra bandwidth, function-execution time per upload, and worst-case timeouts on slow connections. Use presigned PUTs unless you have a specific reason not to (typically a strict compliance requirement that demands every byte pass through your audit log).
Skipping the commit token
A naive sign endpoint that returns presigned URLs and trusts the client to also call the commit endpoint without any further authentication is exposed to a class of replay attacks. The token binds (user, imageId, expected keys) and is consumed exactly once; without it, a malicious client can forge a commit for an upload someone else initiated.
Allowing the client to choose the imageId
The signing endpoint mints the imageId server-side. A client-supplied id can collide with an existing row, can leak information through a chosen id, and can be used to overwrite another user’s image if your bucket has any cross-user write capability. Always generate the id on the server.
Trusting client-reported variant size
The presigned URL bakes in Content-Length. If the sign endpoint accepts the client’s claimed size without bound, a malicious client can ask to sign 200MB uploads. The bucket will reject them at PUT time, but you have already paid for the URL signing and the rate-limit slot. Validate size against MAX_VARIANT_BYTES in the schema.
Hard-deleting rows before the variants are gone
The user clicks “delete”, the row vanishes from the database, but the cleanup job has not yet run. Any URL that was already cached in someone else’s browser still resolves successfully against the bucket, but a +page.server.ts query no longer finds the row. The site silently displays broken images for the duration of the cache TTL, then a 404 from the load function. Soft-delete with a grace period prevents this.
Letting lqip be optional in the row
The schema above declares lqip TEXT NOT NULL. A NULL lqip means the rendering component has nothing to render before the full image arrives, which defeats the whole point of Lesson 9. Make it required at the schema level so a backfill is forced if you ever ship code that produces a row without one.
Forgetting to revoke URL.createObjectURL after upload completes
The optimizer holds object URLs for the original file and (in single-mode) the optimized blob. After a successful upload, the page typically navigates somewhere that does not need them, but the URLs persist in browser memory until explicitly revoked. The cleanup paths from Lesson 7 already cover this; if you are integrating the upload client into a custom flow, make sure URL.revokeObjectURL runs in the success branch as well as on cancel.
Allowing the upload of any image format your codec can decode
The decoder accepts JPEG, PNG, WebP, and (with @jsquash/heif) HEIC. The encoder produces WebP and AVIF. If the rendering component expects exactly the AVIF/WebP/fallback shape, the upload UI must not accept any source the encoder cannot produce a complete variant set for. SVG, animated GIF, and RAW are the three most common surprises; reject them at the file-picker level (accept) and again in detectFormat.
Performance and Scaling
The presigned-URL pattern scales remarkably well because the per-upload work on your origin is small and bounded. The sign endpoint signs 6–8 URLs and writes a token to a cache; the commit endpoint validates the token and writes one row. Both are sub-100ms operations even under heavy load.
The expensive part - moving the bytes - happens directly between the user and the bucket, which is also the part the bucket provider is built to scale.
The cleanup job is the operational element that needs occasional attention. For a site with a million images, listing every object key on every run is wasteful (ListObjectsV2 paginates, but you pay for every list call).
The pragmatic tuning is to scope the cleanup to objects newer than the previous run plus a small overlap, with a full-bucket sweep weekly. Most providers also offer lifecycle rules - “delete objects with this prefix older than N days” - which can replace the cleanup job entirely if your retention policy is simple enough.
The database column to be careful of is lqip. A 600-byte string per row times a million rows is 600MB of data, larger than any other column on the table. Keep lqip out of any covering index; SELECT it only when the rendering page actually needs it. For list views that render placeholders (search results, gallery thumbnails), include lqip in the projection. For aggregate queries (count, sum, group-by), exclude it.
The rate-limit numbers are worth pinning to your actual traffic. A single legitimate user uploads 1–5 images per session in most consumer apps; 50 sign requests per minute is a generous ceiling. A bulk-import flow that creates 200 images at once should use a different code path (typically a batch sign endpoint that signs 10 imageId × 6 variants in one call, with a separate higher rate limit).
Conclusion
The encoded variants and the LQIP from Lesson 9 are the data that this lesson finally puts into a place they can be served from. The presigned-PUT pattern moves the bytes directly between the user’s browser and the bucket; your origin handles only the short JSON exchanges that authenticate the user, mint the URLs, and write the database row. Two endpoints, one sign and one commit, plus a cleanup job - that is the entire server surface area for image uploads in this architecture.
The integrity property the commit token gives you is the most important one to internalise. A row in the database always points to a complete set of uploaded variants; a partial upload either commits successfully (and is therefore a complete row) or fails before the row is written (and the orphans are cleaned up later).
There is no third state where a row points at a half-uploaded image. The rendering side of the pipeline can therefore assume every base URL it reads from the database resolves to every variant it needs, which is the assumption the LazyImage and ResponsiveImage components have been making since Lesson 8.
The lifecycle story closes the loop. Soft-delete with a grace period gives users an undo path; the cleanup job removes the storage cost of expired soft-deletes; orphans from cancelled or failed uploads are caught on the same pass. None of this is exotic infrastructure - a nightly cron or a Cloudflare Worker on a schedule is enough - but skipping it means storage cost climbs forever while the database stays small, and that gap eventually shows up in a cost review.
The two remaining lessons in this track are the build-time pipeline (enhanced:img for committed assets, the alternative source row in the decision matrix) and the SEO/accessibility layer that decorates whatever ends up in the database with the alt text, slug-based filenames, and structured data that make it findable.
Key Takeaways
- Three persistence patterns exist; the only one that scales is presigned PUT: the browser asks the origin to sign URLs, then PUTs each variant directly to the bucket. Your origin handles only short JSON exchanges, never the variant bytes.
- The database row stores
base_url,alt,decorative,width,height, andlqipas the canonical set. Variant URLs are derived from the base URL plus the sharedIMAGE_WIDTHSconstant - never stored as separate columns. - The presigned URL bakes in the content type and size. A schema check in the sign endpoint validates both before signing, so a malicious client cannot inflate its quota by under-reporting.
- The commit token binds (user, imageId, expected keys) for one-time use. Without it, a successful sign call could be replayed or hijacked. With it, only the originator can write the database row that closes the upload.
- Per-variant uploads run with bounded concurrency, exponential-backoff retry with jitter, and per-variant progress callbacks. XHR (not fetch) is the right tool for upload progress in 2026.
- Cancellation is an
AbortControllerfrom start to finish: the same signal flows into every fetch and into the XHR. Mid-upload aborts orphan a few variants; the cleanup job catches them. - Soft-delete plus a grace period decouples user-visible deletion from storage cleanup. Hard-deleting before the cleanup job runs produces broken-image races. A 30-day window is the typical retention.
- Orphan cleanup is a scheduled job that compares bucket contents to live database ids, deletes anything unreferenced, and is idempotent. Skip-on-unknown-shape rather than delete-on-unknown-shape protects against future schema changes and stray test objects.
- Edge-case sources (HEIC, SVG, animated, RAW) need explicit handling at intake. Reject what the pipeline cannot produce a complete variant set for; route SVGs through a separate minification path that preserves them as vector.
- The
lqipcolumn is the largest field on the table by size; include it in the SELECT for views that render placeholders, exclude it from aggregate queries.
Further Reading
- AWS S3: Generating presigned URLs
- Cloudflare R2: Presigned URLs
- MDN: XMLHttpRequest upload progress
- MDN: AbortController
- Valibot documentation - schema validation for the sign and commit endpoints
- @jsquash/heif on GitHub - adds HEIC decoding to the source-format set
See Also
- Lesson 7: Batch-Processing Dozens of Images in Parallel - the worker pool that produces the encoded buffers this lesson uploads.
- Lesson 9: Lazy Loading, Priority Hints, and Blur-Up - the LQIP that this lesson stores in the database row, and the render-time component that reads it back.
- Lesson 11: SvelteKit’s Built-In Image Tools - the build-time alternative pipeline for committed assets, which uses none of the persistence machinery in this lesson.
- Lesson 14: Testing the Pipeline - the Playwright integration tests that exercise this lesson’s upload flow end-to-end, including the alt-text gate and the commit-token contract.
- Lesson 15: The Proof of Work - Lighthouse Audits - the audit that proves the persistence layer’s
Cache-Control: immutableheaders actually move LCP for returning visitors.