Case study · 2026 · Personal project

A virtual try-on mobile app on free GPUs: React Native, FastAPI and diffusion models with graceful fallbacks

Take a photo of yourself, pick a garment, and get a photoreal image of you wearing it — then turn that image into a 3D model you can rotate. The interesting constraint: no GPU of my own, so every model call runs on shared, quota-limited hardware and has to fail gracefully.

React NativeExpoFastAPIDiffusion modelsComputer VisionTypeScripti18n / RTL
Role
Sole developer — mobile app, backend, ML integration, deployment
Stack
Expo 54 · React Native 0.81 · TypeScript · FastAPI · SQLite · MediaPipe · OpenCV
Scale
41 API routes · 157 backend tests + 103 app tests · 3 languages with full RTL
Status
Working build used through Expo Go — not on the stores

What it does

Architecture

No model runs on my server. The backend calls public Hugging Face Spaces, which means free GPU time — and a shared quota that can run out at any moment. The whole design follows from that.

2D: a synchronous call with a silent fallback

/tryon is a plain synchronous request: an asyncio.Semaphore(4) caps concurrency and the blocking inference runs in a threadpool. The default model is Leffa (VITON-HD weights, 40 steps, fixed seed so a retry gives the same image); IDM-VTON is the fallback for upper-body garments.

When the GPU quota is exhausted the Space returns an error string. The backend parses it into a typed QuotaExhausted exception, silently moves to the next verified free model that supports the garment category, and reports both model (the one actually used) and requested_model in the response. If nothing is left, the user is told the exact time the quota resets — parsed out of the upstream message — instead of "something went wrong".

3D: a job, a ladder and a cache

A 360° build takes one to three minutes, so it cannot be a request. POST /spin starts a background job (at most two at once), and the app polls every 3 seconds with a 6-minute deadline. Engines are tried in order of quality, and TRELLIS.2 has its own ladder: 1536 px first, then 1024 px if the quota rejects it. Finished HD models are cached per source image with a quality version, and a stale HD model is served when quota blocks a rebuild — an older model beats a spinner.

Before 3D, the image is cleaned on the CPU: non-local-means denoise, FSRCNN ×2 super-resolution, then an unsharp mask (fail-open — if any step breaks, the original goes through). The resulting GLB is compressed with Draco and WebP textures; on my test model that took it from 18.3 MB to 3.9 MB, which is the difference between usable and not on mobile data.

Bugs that taught me something

One user's body photo shown to the next account. The photo URL was identical for every account and the auth token travelled only in headers, so the image library's disk cache happily served account A's photo to account B on the same phone. Fix: scope the URI with the user id, and add a session-generation counter, bumped on every sign-in and sign-out, that makes any in-flight async handler from the previous session bail out.

Rate limiting behind a tunnel. Every request arrives from the tunnel's IP, so per-IP limits throttle everyone at once, and X-Forwarded-For can be forged. Fix: a per-email throttle (8 attempts a minute) on login, register and change-password, and X-Forwarded-For ignored unless the deployment explicitly says it sits behind a trusted proxy.

A disk leak with a three-minute fuse. A 360° build could finish after its history row had been evicted by the 30-look cap, leaving an orphan 3D file forever. The attach step now checks the affected row count, and a sweeper removes leftovers after a 15-minute grace period.

Sideways phone photos. Phones store orientation in EXIF instead of rotating pixels, and the models ignore EXIF. Baking the orientation into the pixels on upload was the single biggest quality fix in the project.

A file called .jpg that wasn't. MediaPipe dispatches on the file extension; PNG bytes behind a .jpg name crashed it. Every body photo is now re-encoded to a true baseline JPEG, which also strips the EXIF metadata (location included).

Third-party API drift. One Space describes booleans as strings in its own API description, another rejects keyword arguments, and gradio_client renamed its token parameter between major versions (detected at runtime with inspect). When you build on someone else's demo, adapters and contract tests are not optional.

Security and privacy

bcrypt passwords, HS256 JWTs with a machine-generated secret in a 0600 file, streamed uploads capped at 12 MB and verified as decodable images, path-traversal guards on every file route, and private data (body photos, wardrobe, history) served only with a bearer token. Swagger is off by default. Account deletion removes the database rows and the photos and any shared composites. The Hugging Face token lives only in the process environment.

Running it

Three processes under pm2 — the backend, a tunnel and the Expo dev server — plus three cron jobs: a tunnel watchdog that restarts the tunnel only when the local backend is healthy but the public URL is not (and rewrites the app's config with the new URL), a health monitor that sends a Telegram alert on state changes only, and a nightly backup that uses SQLite's backup API and keeps 14 archives.

What I would do next

The ceiling is the shared free GPU quota, which 2D and 3D compete for. The next step is a paid GPU endpoint behind the same adapter interface — the Replicate adapters are already written and inactive — and an EAS build for TestFlight and the Play Store internal track.

Keep reading