SWE Case Study

Refract

A privacy-first media library: the server never stores media bytes. Three-tier metadata storage with HEIC progressive loading via Web Worker.

React 19VitePrismaVercel KV/BlobGemini
Scroll to explore

Snapshot

At a Glance

Role

Founder & Engineer

Sole developer

Duration

6 mo

Oct 2025 – Mar 2026

Capacity

200K items

POWER tier metadata limit

Storage

0 bytes

Server stores zero media — only metadata

01 — Context

The Constraint

Refract is a media-library product built over roughly six months (October 2025 – March 2026) and pre-launch as of authoring. Photo collections on consumer cloud drives — Google Drive, Dropbox — accumulate without organization. Existing AI-tagging products solve that, but they require shipping every byte of every photo to a third-party server: a DMCA surface, a breach surface, and a storage cost passed back to the user.

The load-bearing decision sits one level above the feature set: the server never stores media bytes. Only metadata — file IDs, names, MIME types, thumbnail URLs already issued by the user's drive provider. Original media stays on the drive provider or local device. That single boundary eliminates whole classes of legal, operational, and security risk before any feature is built on top of it.

Everything else in the system — the storage layer, the AI tagging pipeline, the auth surface — is downstream of that constraint. Zero server-side media bytes is not a marketing posture; it is the engineering envelope every other component had to fit inside.

02 — What I Built

System Shape

The frontend is a React 19 + Vite 6 single-page app — explicitly not Next.js, a decision worth flagging now and revisiting in Section 04. TypeScript 5.8 strict, ES2022 target. The backend is a set of Vercel serverless functions under /api. Authentication is Google OAuth 2.0 only; both Drive and Dropbox data flow downstream of a single Google identity, which keeps the consent surface to one provider.

Refract system architecture: Vite SPA + Google OAuth + Drive/Dropbox APIs + Gemini client-side + Vercel serverless API + Prisma Postgres + Vercel KV/Blob + Stripe + UploadThing

The metadata layer is three tiers. PostgreSQL via Prisma Accelerate (Prisma 7.4) is the canonical relational store — schemas, user records, tier quotas, the slow-but-correct source of truth. Vercel KV holds a hot index per user (~200 bytes/user) so the most-recent-library query returns from cache in one round trip. Vercel Blob holds gzip-compressed JSON chunks of one thousand items each — the actual library payload, partitioned for bandwidth rather than queried with SQL. KV points at Blob; Blob points at the user's drive provider; nothing in the chain holds a media byte.

The AI layer runs in the browser. The @google/genai SDK (1.30) calls gemini-2.5-flash for image analysis and gemini-2.0-flash for tag generation, both from client-side. The user's Gemini quota, the user's machine, the user's photos — server stays uninvolved. UploadThing 7 handles the asset boundary for direct file uploads (when the user opts into local-file workflows), with tier quotas of 10/250/1000 items for FREE/PRO/POWER. Stripe handles the subscription side. Sentry instruments both the SPA and the serverless functions.

Other engineering on Refract

Dropbox OAuth code+PKCE flow with cursor-based incremental syncClient-side Gemini search with IndexedDB tag cache (cost-bound to user quota)3-tier Stripe billing (FREE / PRO / POWER) + Customer Portal + promo codesTest-mode auth: DEV_AUTH_BYPASS local + E2E_TEST_AUTH_SECRET (Google never hit in dev or E2E)Paste-link import — parses raw Drive/Dropbox URLs into metadata recordsCollections (PRO+) with position-based ordering, tier limits 50/500Two player implementations (standard + ethereal-glass redesign) sharing the same shuffle queue

03 — Hard Parts

What Was Hard

Three-tier metadata storage (ADR-2)

Library metadata can grow to two hundred thousand items at the POWER tier. A single Postgres query scanning that shape does not return in human-acceptable time once a user is past the first few thousand photos; a Redis-only cache wouldn't survive cold reads on a user who'd been inactive for a month.

Settled on three tiers: Vercel KV as a hot index keyed by user (about 200 bytes per user, holds pointers and an LRU head); Vercel Blob as cold storage of gzip-compressed JSON chunks at one thousand items per chunk; /tmp as a dev fallback so the local loop doesn't require provisioned infra. A registry pattern on top of KV gives O(1) file-ID lookups without loading any chunk.

The chunk granularity (1,000) is a guess. Too small means more round trips per library scroll; too big means wasted bandwidth on an item the user never sees. Settled empirically against the test fixture — past that, optimizing further would need user-data telemetry, and the privacy envelope means there isn't much of that to draw on. Open tradeoff.

HEIC progressive loading via Web Worker (ADR-6)

HEIC is the default photo format on iOS. Browsers don't decode it natively. Conversion is CPU-heavy enough to freeze a tab if you do it on the main thread for a bulk library scroll.

Built progressive load: the thumbnail Drive already issues shows instantly; a background Web Worker (heicWorker.ts) runs heic2any against the original; the full-resolution JPEG replaces the thumbnail when ready. A worker pool throttles concurrency so a thousand-photo bulk load doesn't spawn a thousand workers and lock the tab.

Tradeoff is explicit: heic2any ships as roughly 150kB extra in the bundle, paid by every user — including Android users who'll never encounter a HEIC file. iOS is a known target audience for a media-library product, so the bytes are accepted. A conditional dynamic import was considered; the cost there moves to first-HEIC-encounter latency for the user who actually has them, which was the worse failure mode.

04 — What I'd Change

In Hindsight

The Vite/Vercel split feels off-pattern. The rest of the suite from the same product line ships on Next.js, and Refract started as a Vite SPA back when Next.js's static-SPA story was rough — the build pipelines didn't want to cooperate for a heavily client-side app. With Next 15 in hand, would build it as a Next.js app from the start: shared eject and deploy patterns across the portfolio, a unified RSC + streaming option for any future server-touching surface, one dependency tree to keep current instead of two.

The cost of the current shape is paid every time a cross-product utility is written — auth helpers, Stripe webhook plumbing, Sentry config — each one has to support two build pipelines. Not expensive per utility; expensive in aggregate, and the tax compounds the longer the codebase lives.

Built at Ramiro Labs.