The Spark: Why I Decided to Build a Camera App Without an App
I've already written about the UX decision to go app-free — the brutal conversion funnel, the 18% upload rate, the moment my aunt said "I have to download something? Never mind." That article was about the why. This one is about the how.
Because here's the thing: saying "just use the browser camera" is easy. Actually building a camera experience in a browser that doesn't feel like a 2007 flip phone? That's a different beast entirely. This is the technical story of how I built Picshots — a no-app photo sharing platform where guests scan a QR code and start shooting from their phone browser — and the stack, the dead ends, and the "oh wow that actually works" moments along the way.
The Architecture: Three Moving Parts
At its core, the platform has three technical components that need to work together seamlessly:
- QR Code Generation & Scanning — Each event gets a unique QR code. Guests scan it and land on the event's photo page. No typing URLs, no searching app stores.
- Browser Camera Access — Once on the page, the browser needs to access the phone's camera, capture high-quality photos, and handle all the edge cases (orientation, flash, permissions, different browsers).
- Real-Time Photo Gallery — Photos need to upload and appear in a shared gallery that updates live, so the host can project it on a screen or guests can browse what others have captured.
Let me walk through each one, including the code, the gotchas, and the libraries that saved me months of work.
Part 1: QR Codes — The Zero-Friction Entry Point
The QR code is the entire distribution strategy. No app store, no search, no typing. Just point your phone camera and you're in. But generating QR codes that work reliably across every phone — from a brand-new iPhone 16 to a five-year-old budget Android — is trickier than it looks.
Choosing a QR Library
I evaluated three approaches:
Server-side generation (Node.js +
qrcodepackage): 82 million monthly npm downloads, battle-tested, supports every format. The safe choice.Client-side generation (
qrcode-generatoror Canvas API): No server round-trip, but heavier on the client and inconsistent across browsers.Third-party API (Google Charts, QR Server): Adds an external dependency and latency. No thanks.
I went with server-side generation using the qrcode npm package. Here's the core logic:
const QRCode = require('qrcode');
async function generateEventQR(eventId, eventUrl) {
const qrDataUrl = await QRCode.toDataURL(eventUrl, {
width: 600,
margin: 2,
color: {
dark: '#1A1A2E',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M'
});
return qrDataUrl;
}
Why Error Correction Level Matters
I initially used error correction level L (lowest, ~7% recovery) because it produces smaller, cleaner QR codes. Big mistake. At a dimly-lit wedding reception, guests' phone cameras struggle with low-contrast QR codes. Bumping to level M (~15% recovery) made the codes slightly denser but dramatically more scannable in poor lighting. For events where the QR code gets printed on textured paper or curved surfaces (wine bottles, table tents), I'd even recommend level Q (~25%).
The QR code also needed to survive being printed at different sizes — from a 2-inch table card to a 6-foot projection screen. The width: 600 parameter generates a high-enough resolution SVG/PNG that scales cleanly in both directions without pixelation.
Dynamic QR Codes, Not Static
One architectural decision I'm glad I made early: every QR code encodes a dynamic URL (picshots.app/e/{eventId}), not a static one. This means I can change where that URL points without regenerating the QR code. If I ever need to migrate domains, add tracking parameters, or A/B test different landing experiences, the QR codes keep working. Static QR codes are technical debt you print onto physical cards.
Part 2: The Browser Camera — Making getUserMedia Feel Native
This is where things got interesting. The getUserMedia API has been around since 2015, but using it to build a camera that feels like a native camera app requires solving a cascade of problems that the basic MDN tutorial doesn't mention.
The Basic Setup
Here's the starting point — the minimum viable camera:
async function startCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
});
const video = document.getElementById('camera-preview');
video.srcObject = stream;
video.play();
return stream;
} catch (err) {
console.error('Camera access failed:', err);
// Handle permission denied, no camera, etc.
}
}
The facingMode: 'environment' constraint is critical. Without it, most browsers default to the front-facing selfie camera — useless for taking photos of other people at an event. But here's the catch: facingMode is a constraint, not a guarantee. If the device doesn't have a rear camera (rare but possible with some tablets), the browser will fall back to whatever camera is available. You need to handle that gracefully.
Capturing High-Quality Still Photos
The getUserMedia stream gives you a video feed, not a photo. To capture a still image, you have two options:
Option A: Canvas capture (works everywhere)
function capturePhoto(videoElement) {
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(videoElement, 0, 0);
return canvas.toDataURL('image/jpeg', 0.92);
}
Option B: ImageCapture API (Chromium only, higher quality)
async function capturePhotoHQ(track) {
const imageCapture = new ImageCapture(track);
const blob = await imageCapture.takePhoto({
imageWidth: 1920,
imageHeight: 1080
});
return URL.createObjectURL(blob);
}
I use both. The ImageCapture API produces noticeably sharper photos because it grabs a full-resolution frame directly from the sensor, bypassing the video pipeline. But it's only supported in Chromium-based browsers (Chrome, Edge, Samsung Internet). For Safari and Firefox, I fall back to canvas capture. The quality difference is visible — canvas-captured photos are slightly softer — but it's the difference between "good enough for a wedding" and "good enough for a wedding."
The Orientation Nightmare
This is the bug that ate two weeks of my life. Mobile browsers report video orientation differently depending on the OS, the browser, and the phase of the moon. Photos would come in sideways on iOS Safari, upside down on some Android devices, and correctly on others. The EXIF orientation tags that native camera apps use to signal rotation? getUserMedia doesn't set them.
Here's the orientation correction logic I landed on after four rewrites:
function correctOrientation(canvas, videoElement) {
const ctx = canvas.getContext('2d');
const videoWidth = videoElement.videoWidth;
const videoHeight = videoElement.videoHeight;
// Detect device orientation
const isPortrait = window.innerHeight > window.innerWidth;
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isPortrait && isIOS) {
// iOS Safari reports landscape video in portrait mode
canvas.width = videoHeight;
canvas.height = videoWidth;
ctx.translate(canvas.width, 0);
ctx.rotate(Math.PI / 2);
} else if (isPortrait) {
// Android Chrome usually gets this right, but not always
canvas.width = videoWidth;
canvas.height = videoHeight;
} else {
canvas.width = videoWidth;
canvas.height = videoHeight;
}
ctx.drawImage(videoElement, 0, 0, videoWidth, videoHeight);
}
I'm not going to pretend this is elegant. It's a pile of device-specific hacks held together by user-agent sniffing, which every web developer knows is a sin. But after testing across 30+ device/browser combinations, this is what actually works. Sometimes the right solution is the ugly one.
The Flashlight Surprise
One discovery that genuinely delighted me: you can control the phone's flashlight through the browser. The ImageCapture API exposes a torch capability:
async function toggleFlash(stream, enabled) {
const track = stream.getVideoTracks()[0];
const capabilities = track.getCapabilities();
if (capabilities.torch) {
await track.applyConstraints({
advanced: [{ torch: enabled }]
});
return true;
}
return false; // No flash available
}
At dimly-lit wedding receptions, this feature alone makes the browser camera feel like a real camera. Guests tap a flash icon, the phone's LED lights up, and suddenly their photos aren't grainy messes. It's one of those features that makes users forget they're not in a native app.
Part 3: Real-Time Gallery — Making Photos Appear Instantly
The third piece of the puzzle is the shared gallery. When a guest takes a photo, it needs to appear in the event gallery — ideally in real time — so the host can project it on a screen or other guests can see what's been captured.
Upload Resilience on Terrible WiFi
Wedding venues have notoriously bad internet. Barns, gardens, beach resorts, hotel ballrooms — none of these are known for their gigabit fiber. I needed uploads to work even when the connection drops mid-transfer.
I built a retry queue with exponential backoff:
class UploadQueue {
constructor() {
this.queue = [];
this.processing = false;
this.maxRetries = 5;
}
async add(file, eventId) {
this.queue.push({ file, eventId, retries: 0 });
if (!this.processing) this.process();
}
async process() {
this.processing = true;
while (this.queue.length > 0) {
const item = this.queue[0];
try {
await this.uploadFile(item.file, item.eventId);
this.queue.shift(); // Success, remove from queue
} catch (err) {
item.retries++;
if (item.retries >= this.maxRetries) {
console.error('Upload failed after max retries:', item);
this.queue.shift(); // Give up
} else {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const delay = Math.pow(2, item.retries) * 1000;
await new Promise(r => setTimeout(r, delay));
}
}
}
this.processing = false;
}
async uploadFile(file, eventId) {
const formData = new FormData();
formData.append('photo', file);
formData.append('eventId', eventId);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Upload failed');
return response.json();
}
}
For the real-time gallery updates, I use Supabase's real-time subscriptions. When a photo is uploaded, the backend inserts a row into the photos table, and every connected client receives the new photo via a WebSocket subscription. No polling, no manual refresh — the gallery just updates.
Why Not WebRTC?
You might wonder: if this is about photo sharing, why not use WebRTC for peer-to-peer transfer? I explored this. WebRTC would let guests send photos directly to each other without hitting a server. But for an event photo sharing platform, it's the wrong architecture:
WebRTC needs signaling servers anyway — you still need a server to establish the peer connection, so you're not actually serverless.
NAT traversal is unreliable on venue networks — hotel and venue WiFi often blocks the UDP ports WebRTC needs for STUN/TURN.
The host needs a central gallery — peer-to-peer means photos live on individual devices. The whole point of Picshots is one shared gallery the host controls.
Guests come and go — if the only person who has a photo leaves the event, that photo is gone from the P2P mesh.
WebRTC is brilliant for video calls and file transfers between two consenting peers. For a shared event gallery with a host who needs persistent access to all photos, a client-server model with real-time subscriptions is the right call.
The Stack, Summarized
Here's the full technical stack that powers the no-app photo sharing experience:
Frontend: Vanilla JavaScript (no framework — keep the bundle tiny for fast QR code landing page loads)
Camera: getUserMedia + ImageCapture API with canvas fallback
QR Generation:
qrcodenpm package (82M monthly downloads), server-side, error correction level MQR Scanning: BarcodeDetector API (Chromium) +
html5-qrcodepolyfill (5M monthly downloads) for SafariUploads: Retry queue with exponential backoff, chunked uploads for large files
Real-Time Gallery: Supabase Realtime (WebSocket subscriptions)
Storage: Supabase Storage for photos, Supabase Postgres for metadata
Hosting: Static site on CDN, API on a lightweight Node.js server
What I'd Do Differently
Building in public means being honest about the mistakes. Here are mine:
1. I should have tested on more Android devices earlier. I developed primarily on an iPhone and a Pixel. The first time someone tried Picshots on a $150 Samsung from 2021, the camera preview was 3 FPS and the flash didn't work. I now maintain a device lab of 8 phones spanning iOS and Android at different price points.
2. The orientation fix should have been a standalone library. I wrote orientation correction inline, then rewrote it, then rewrote it again. If I'd extracted it into a small, testable module from day one, I would have saved myself two weeks of debugging. If you're doing anything with getUserMedia and mobile, isolate your orientation logic.
3. I underestimated how much guests care about photo quality. My first prototype used a 640x480 canvas capture with JPEG quality 0.7. The photos looked fine on a phone screen but terrible when the host tried to print them or view them on a laptop. Bumping to 1920x1080 with quality 0.92 made uploads slower but made the product actually usable for its intended purpose — preserving memories.
Try It Yourself
If you're building something that needs browser camera access, here's my advice: start with the simplest possible implementation and test it on real devices immediately. The gap between "works on my machine" and "works at a wedding with 200 guests on terrible WiFi" is enormous, and you won't find it in a simulator.
The web platform is absurdly capable now. Between getUserMedia, ImageCapture, the BarcodeDetector API, service workers, and WebSocket-based real-time subscriptions, you can build experiences that feel native without asking anyone to install anything. The "no app" approach isn't just a UX preference — it's a distribution strategy that removes the single biggest barrier between your users and the value you're providing.
If you want to see how it all comes together, check out how Picshots works or create your own event to try the QR code + browser camera flow yourself. No download required — that's the whole point.
I'm building Picshots in public. Follow along for more technical deep-dives, including how I handle video uploads from the browser, the Supabase schema that powers the real-time gallery, and the analytics pipeline that helps hosts understand which guests are actually taking photos. No corporate blog posts, no PR-filtered success stories — just what actually happens when you try to build something people want.



Top comments (0)