Skip to main content
Back to selected work
2026Featured

Zero-cost photo upload pipeline

Built a direct-to-storage batch upload system where photographers can process hundreds of private school photos in the browser without server image work.

  • Next.js
  • Web Workers
  • R2
Role
Solo product engineer
Timeline
April 2026 onward
Product type
B2B2C school photography SaaS
Team
Solo build with AI coding assistants under my direction and review

Tech stack

  • Next.js
  • React
  • TypeScript
  • Web Workers
  • Canvas API
  • Zustand
  • Zod
  • Postgres
  • Cloudflare R2
  • MinIO
  • Vitest

Responsibilities

  • System architecture

    Designed the control-plane / data-plane split so uploads never touch the application server's CPU or memory.

  • Upload UX

    Built the large-batch drop experience with one batch toast, parallel uploads, and weighted monotonic progress.

  • Web Worker image processing

    Implemented the singleton Canvas worker that resizes and watermarks previews with bounded memory.

  • Storage security

    Owned presign authorization, server-named keys, and forged-key rejection at registration.

  • Test strategy

    Covered the trust boundary with integration and unit tests across upload, storage, and watermarking.

01 - System shape

Problem, decision, result

Uploading had to protect private originals, avoid server image work, preserve tenant boundaries, and make a large drop feel like one operation.

Problem

Parents must only see watermarked, downsized previews; originals are inventory for paid delivery.

Decision

Split originals and previews into separate storage objects and register only preview keys into gallery assets.

Result

Galleries surface the proof path, while originals remain behind the paid signed-link delivery flow.

Problem

Server-side transforms would push hundreds of large images through a Cloudflare Worker runtime.

Decision

Use the server as the control plane and object storage as the data plane through short-lived presigned PUT URLs.

Result

Upload volume no longer scales application CPU, memory, or image-transform spend.

Problem

A browser can create bytes, but it cannot decide tenant ownership.

Decision

Server-name keys under creator and gallery prefixes, then re-check ownership and key prefix at registration.

Result

Forged cross-tenant writes are rejected before any asset row becomes visible.

02 - Architecture

The server authorizes; storage carries the bytes

Read left to right: the browser and object storage carry the image bytes, the app server only authorizes and records, and the database is the final gate on what becomes visible.

Photographer browser

Carries the image bytes. Processes and uploads pixels, but is never trusted to decide ownership.

  • Drop a batch of photos
  • Watermark previews in a Web Worker
  • PUT originals straight to storage
  • PUT previews straight to storage
  • Report weighted upload progress

Control plane (app server)

Holds the authority. Authorizes each upload and names the keys, but never streams a single pixel.

  • Receive the presign request
  • Verify actor and gallery ownership
  • Return server-named storage keys
  • Register the asset after upload

Durable systems

Owns visibility. Storage keeps the bytes; the database decides what a gallery is actually allowed to show.

  • Private originals
  • Watermarked previews
  • Actor-bound DB transaction
  • Assets table behind RLS

03 - Decision

Why the browser does the watermarking

Client-side Canvas in a WorkerChosen

Why plausible

One implementation path for local MinIO and production R2, no transform fees, and no Worker CPU pressure.

Outcome

Chosen because photographers are watermarking their own proofs, while paid originals stay behind separate signed delivery links.

WASM image library in the Worker

Why plausible

Could keep processing deterministic without a separate image service.

Outcome

Rejected as more operational surface than Canvas for JPEG previews, without changing the trust model.

sharp locally plus Cloudflare Images in production

Why plausible

A stronger enforcement point if uploaders become untrusted or HEIC/RAW support becomes necessary.

Outcome

Kept as a documented one-to-two-day exit plan because it adds transform cost and runtime-specific code paths.

04 - Trust boundary

What the browser may claim vs. what the server verifies

Client-side processing is acceptable only because visibility is created later, at registration, inside the server-controlled boundary.

Browser may claim

Selected filename, MIME type, and size.

Server verifies

Zod shape, content-type allowlist, 50 MB max file size, and 100 files per presign batch.

Browser may claim

Requested gallery target.

Server verifies

Authenticated actor and gallery ownership before presigning.

Browser may claim

Uploaded preview bytes.

Server verifies

Preview objects are not visible until registration writes an asset row.

Browser may claim

Storage key returned from the client.

Server verifies

The key must decode to the same creator as the target gallery.

05 - Implementation

Registration is the visibility boundary

The tab can upload bytes to a presigned URL, but it cannot forge tenant ownership by posting an arbitrary key.

Forged-key rejection at registrationsrc/server/admin/upload-service.ts
01export async function registerUploadedAsset(input: RegisterAssetInput) {02  // The tab can PUT bytes to a presigned URL, but the key it hands back is untrusted.03  const { creatorId: expectedCreatorId } = await validateUploadGallery(input);04  const keyCreatorId = extractCreatorIdFromKey(input.storageKey);05 06  if (keyCreatorId !== expectedCreatorId) {07    throw new Error("storageKey creator prefix does not match the gallery's creator");08  }09 10  // Only now, inside the verified boundary, does the asset become visible.11  return insertAsset(input, expectedCreatorId);12}

06 - Flow and evidence

From drop to visible asset

  1. 01

    The browser posts gallery and file metadata to the presign route.

  2. 02

    The server verifies the actor, gallery ownership, limits, and content types, then returns original and preview PUT URLs with server-named keys.

  3. 03

    The original streams directly to object storage while a singleton Web Worker creates the watermarked JPEG preview.

  4. 04

    The browser uploads the preview and asks the server to register the asset.

  5. 05

    The server re-verifies ownership, rejects forged prefixes, inserts the asset row, writes audit data, and records billing counters best-effort.

Folder structure

docs/// Design record and exit plansrc/├── app/api/upload/// Presign API boundary├── server/│   ├── storage/// Storage contract│   ├── admin/// Registration boundary│   └── creator/// Registration boundary├── workers/// Watermark worker pipeline├── lib/watermark/// Watermark worker pipeline├── stores/// Upload state and orchestration└── components/shared/upload/// Upload state and orchestrationtests/├── integration/│   ├── upload/// Integration coverage│   └── storage/// Integration coverage└── unit/    ├── watermark/// Unit coverage    └── upload/// Unit coverage

07 - Outcome

What changed

No server-side image transform path

The application server authorizes and records uploads instead of proxying or transforming hundreds of image files.

Preview/original privacy boundary

Watermarked previews and paid originals became separate storage objects with different visibility paths.

Fail-closed registration

Actor checks, tenant-prefixed keys, prefix validation, and RLS-backed transactions prevent cross-tenant asset visibility.

Large-batch UX without tab pressure

A singleton Worker, parallel original PUTs, one batch toast, and weighted monotonic progress keep big drops understandable.