# Uploading multi-gigabyte galleries without touching the server

How Lensdrop moves wedding-sized photo galleries from the browser straight to Cloudflare R2, and survives hotel Wi-Fi doing it.

Photographers don't upload files, they upload *shoots*. A single wedding is 40–80 GB of RAW-adjacent JPEGs, and the person uploading it is usually on hotel Wi-Fi at 1 AM. When I started building [Lensdrop](https://lensdrop.app), the first architectural decision was also the most important one: **the app server never touches the bytes.**

## Why the server can't be in the path

The naive design (browser uploads to your API, API forwards to storage) fails at this scale three different ways:

- **Memory and time limits.** Serverless functions cap request bodies and execution time long before 40 GB.
- **Double bandwidth.** Every byte crosses the network twice, and you pay for both trips.
- **Fragility.** One dropped connection at 92% means starting over. Nobody re-uploads a wedding twice.

The fix is old and boring, which is why I trust it: presigned URLs. The server's only job is to *authorize* the upload, not to carry it.

## Multipart, presigned, direct from the browser

R2 speaks the S3 API, so multipart uploads work out of the box. The flow:

1. The browser tells the server what it wants to upload (name, size, type).
2. The server creates a multipart upload and signs a URL *per part*.
3. The browser PUTs parts straight to R2, in parallel.
4. The server completes the upload once all ETags are in.

```ts
// server: sign one URL per ~50MB part
const command = new UploadPartCommand({
  Bucket: env.R2_BUCKET,
  Key: objectKey,
  UploadId: uploadId,
  PartNumber: partNumber,
});
const url = await getSignedUrl(r2, command, { expiresIn: 3600 });
```

On the client I use [Uppy](https://uppy.io) with its AWS S3 plugin rather than hand-rolling the orchestration. Uppy handles the part queue, parallelism, retries, and, critically, pause/resume state.

## Surviving the network drop

Resumability is the whole feature. Multipart uploads make it almost free: R2 keeps every part that already landed, so resuming means asking the server which parts exist and continuing from there.

```ts
// client: Uppy picks up where the connection died
const uppy = new Uppy({ autoProceed: true }).use(AwsS3, {
  shouldUseMultipart: (file) => file.size > 100 * 1024 * 1024,
  createMultipartUpload,
  signPart,
  listParts,        // <- the resume magic
  completeMultipartUpload,
});
```

A photographer can close the laptop mid-upload, open it in the morning, and the gallery finishes without re-sending a byte that already made it. The `listParts` endpoint is maybe thirty lines of code and it's the difference between a tool people trust and one they don't.

## What the server actually does

Stripped of data transfer, the app server becomes a small coordinator: it authenticates the photographer, authorizes keys in the bucket, signs URLs, and records gallery metadata. All of it is fast, cheap, and stateless.

The same philosophy carries through to delivery. Galleries are served by a Cloudflare Worker at the edge, with watermarked previews for everyone and originals unlocked by a per-gallery PIN. Metadata is cached in two tiers (Upstash Redis at the edge, TanStack Query in the browser), so the origin is barely involved there either.

## Takeaways

- Push bytes to the edge of your architecture; keep your server in the control plane.
- Resumability isn't a nice-to-have for large uploads, it *is* the product.
- Boring primitives (presigned URLs, multipart, ETags) outlast clever ones.

If you're building something similar and want to compare notes, [email me](mailto:benydishon@gmail.com).

Last updated on Jul 10, 2026.