Last summer, I was at my cousin's wedding. 200 guests, one professional photographer, and exactly zero way for anyone else to share the photos they were taking on their phones. The bride spent weeks chasing people through WhatsApp groups and Facebook messages, trying to collect the candid shots everyone promised to send. She got maybe 30 photos out of what must have been thousands taken that night.
That moment stuck with me. I'm a developer, and I kept thinking: there has to be a better way. Not another app to download. Not another account to create. Something so simple that even your tech-averse uncle could use it after his third glass of wine.
So I built Picshots — a no-app photo sharing platform that works entirely through QR codes and browser cameras. No downloads, no sign-ups, no "check your email for a verification code." Just scan, snap, and the photos land in a shared gallery. Here's exactly how I built it, what I learned, and the technical decisions that made it work.
The Core Problem: Friction Kills Participation
Here's a stat that shaped every decision I made: for every additional step between a guest and their first photo upload, you lose roughly 40% of potential participants. I didn't pull that from a research paper — I tested it. I built a prototype that required guests to enter their name before taking a photo. Then I removed the name field. The difference? A 3x increase in photos captured.
The math is brutal:
- App Store → find app → download → install → open → create account → verify email → find event → take photo = ~5% participation
- Scan QR → camera opens → take photo → done = ~90% participation That 85% gap is the difference between a dead gallery and one with 500+ photos by the end of the night. The no-download approach isn't a nice-to-have — it's the entire product.
The Tech Stack: What Powers a Browser-Based Photo Platform
Before diving into the code, here's the stack I landed on after several iterations:
LayerTechnologyWhy
Camera AccessMediaDevices.getUserMedia()Works in every modern browser, no polyfills needed
QR Generationqrcode (npm, 82M monthly downloads)Battle-tested, supports SVG output for crisp printing
QR Scanninghtml5-qrcode (npm, 5M monthly downloads)Pure JS, no WASM, works on mobile browsers
Image UploadPresigned S3 URLsBypasses server bottlenecks on large files
Real-time GallerySupabase RealtimeWebSocket-based, no polling, scales to thousands of concurrent viewers
FrontendNext.js + TailwindSSR for SEO pages, CSR for the camera experience
HostingVercel + S3 + SupabaseEdge functions for QR redirects, S3 for photos, Supabase for metadata
Let me walk through each piece and the decisions behind them.
Step 1: Accessing the Camera Without an App
The getUserMedia API is the unsung hero of this entire project. It's been available in browsers since 2015, but most people don't realize how capable it is. Here's the core camera initialization code:
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
});
const video = document.getElementById('camera-preview');
video.srcObject = stream;
await video.play();
Three things I learned the hard way:
-
HTTPS is non-negotiable.
getUserMediaonly works onlocalhostor HTTPS. If you're testing on a device over your local network, you need a self-signed cert or a tunnel like ngrok. I wasted an afternoon debugging this before remembering it's a browser security requirement. -
iOS Safari has quirks. On iOS,
getUserMediamust be triggered by a user gesture (tap/click). You can't auto-open the camera on page load. I added a prominent "Open Camera" button that's impossible to miss, and the tap satisfies Safari's requirement. -
The
facingMode: 'environment'constraint is a suggestion, not a command. Some Android browsers ignore it and default to the front camera. I added a camera toggle button as a fallback — it's saved me from countless "why am I looking at my own face?" support messages.
Why WebRTC Matters for Browser Camera Access
While getUserMedia handles camera access, WebRTC (Web Real-Time Communication) is the underlying framework that makes the entire browser-based media pipeline possible. WebRTC provides three core APIs that power the no-app camera experience:
-
getUserMedia— accesses the camera and microphone (this is part of the WebRTC spec) -
RTCPeerConnection— enables peer-to-peer audio/video/data transfer -
RTCDataChannel— allows arbitrary data transfer between peers Even though Picshots uses a client-server upload model for photos, WebRTC'sgetUserMediais the foundation. The media constraints API (width,height,facingMode,frameRate) that I use to configure the camera are all part of the WebRTC specification. Without WebRTC standardizing these APIs across browsers, we'd be back to Flash plugins and ActiveX controls — the dark ages of web media.
I also explored using WebRTC data channels for peer-to-peer photo sharing between guests on the same venue WiFi, which would bypass the server entirely for local transfers. The RTCDataChannel API supports reliable, ordered data delivery — perfect for file transfers. I prototyped this:
// WebRTC P2P photo transfer prototype
const peerConnection = new RTCPeerConnection(config);
const dataChannel = peerConnection.createDataChannel('photos', {
ordered: true,
maxRetransmits: 3
});
dataChannel.onopen = () => {
// Send photo blob directly to another guest
dataChannel.send(photoBlob);
};
The P2P approach worked in testing but introduced complexity around NAT traversal (requiring STUN/TURN servers) and connection management as guests arrived and left. For now, the client-server model is more reliable, but WebRTC data channels remain on the roadmap for venues with poor internet connectivity.
Step 2: QR Codes — The Zero-Friction Entry Point
QR codes are the bridge between the physical event and the digital gallery. Every event on Picshots gets a unique QR code that, when scanned, opens the camera directly in the guest's browser. No typing URLs, no searching for the event — just point and shoot.
I used the qrcode npm package (82 million monthly downloads — it's basically the standard at this point) to generate QR codes server-side:
import QRCode from 'qrcode';
const eventUrl = `https://picshots.app/e/${eventId}`;
const qrSvg = await QRCode.toString(eventUrl, {
type: 'svg',
errorCorrectionLevel: 'H', // High — survives up to 30% damage
margin: 2,
width: 400,
color: {
dark: '#1a1a2e',
light: '#ffffff'
}
});
Key decisions here:
- SVG over PNG: SVGs scale infinitely without pixelation. Event hosts print these QR codes on everything from table cards (2×2 inches) to welcome banners (4×6 feet). A PNG would look terrible at banner size.
- Error correction level H: This allows the QR code to remain scannable even if up to 30% of it is damaged or obscured. At a wedding, QR codes get wine spilled on them, folded, or partially covered by centerpieces. Level H has saved countless scans.
-
Short URLs matter: The less data in a QR code, the larger and more scannable each module (those little squares) becomes. I use short event IDs (
/e/abc123) rather than long UUIDs to keep the QR code clean and scannable from a distance. For the scanning side, I usehtml5-qrcode(5 million monthly downloads) for the rare case where someone needs to scan a QR code from within the browser — for example, if a host wants to join their own event from a laptop. It's pure JavaScript, no WebAssembly, and works reliably on mobile browsers.
Step 3: Capturing and Uploading Photos
Once the camera is running, capturing a photo is straightforward — grab a frame from the video stream and draw it to a canvas:
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.toBlob('image/jpeg', 0.85);
}
The upload pipeline is where things get interesting. I use presigned S3 URLs to bypass the server entirely during upload:
- Client requests a presigned URL from the API
- Client uploads directly to S3 using that URL
- S3 triggers a Lambda that generates thumbnails and stores metadata in Supabase
- Supabase Realtime pushes the new photo to all connected gallery viewers This architecture means my server never touches a single byte of image data. A 10MB photo from an iPhone 15 Pro Max goes straight from the guest's browser to S3. The server just handles metadata — event IDs, timestamps, and thumbnail URLs.
The real-time gallery update is powered by Supabase Realtime, which uses WebSockets under the hood. When a new photo row is inserted into the photos table, every connected client gets the update within milliseconds. At a wedding with 200 guests all watching the live gallery on a projector, the photos appear almost instantly after someone snaps them.
Step 4: The Hard Parts Nobody Talks About
iOS Safari and the "Page Reload" Problem
iOS Safari aggressively kills background tabs to save memory. If a guest switches to WhatsApp to reply to a message and comes back 30 seconds later, Safari may have killed the camera stream. The page reloads, and suddenly they're staring at the event landing page instead of the camera.
My fix: I store the camera state in sessionStorage. If the page reloads and detects a previous camera session, it auto-reopens the camera without requiring another QR scan. It's a small detail, but it's the difference between a guest taking 3 photos and taking 15.
Orientation Lock on Mobile
When a guest rotates their phone from portrait to landscape mid-capture, the video stream dimensions change. If you're not handling the resize event on the video element, your canvas capture will be stretched or cropped. I learned this the hard way when the first batch of test photos came back looking like funhouse mirrors.
video.addEventListener('resize', () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
});
Concurrent Upload Limits
Browsers limit concurrent connections to the same origin (usually 6). At a wedding with 200 guests all uploading photos simultaneously, you can hit this limit fast. Presigned S3 URLs solve this because each upload goes to a unique URL — effectively bypassing the per-origin connection limit. I also added a simple upload queue with a concurrency limit of 3 to avoid overwhelming the device's network stack.
Step 5: The Gallery Experience
The gallery is where the magic happens. All photos appear in a responsive grid, sorted by capture time, with a subtle fade-in animation. Hosts can project the gallery on a screen at the venue, and guests can watch photos appear in real time throughout the night.
I built the gallery with a few key features:
- Lazy loading with blur-up placeholders: Thumbnails load first as tiny (20×20) blurred images, then resolve to full resolution. On a gallery with 500+ photos, this keeps the initial page load under 2 seconds.
- Infinite scroll with virtualization: Only ~20 photos are in the DOM at any time. As you scroll, photos are recycled. Without this, a 500-photo gallery would bring even a flagship phone to its knees.
-
Download all as ZIP: After the event, hosts can download every photo as a single ZIP file. This is generated server-side using
archiverand streamed directly from S3 — no temporary files on disk.
The Results: What 12,000+ Events Taught Me
Since launching, Picshots has been used at over 12,000 events — weddings, birthday parties, corporate galas, baby showers, you name it. Here's what the data shows:
- 92% guest participation rate — meaning 92% of guests who scan the QR code take at least one photo
- Average of 8.3 photos per guest — people don't just take one and leave; they come back throughout the night
- Under 3 seconds from scan to first photo — the no-download, no-signup flow delivers on its promise
-
Zero app store reviews to manage — because there's no app. Bug fixes ship instantly to every user.
The no-app approach turned out to be a superpower I didn't fully appreciate at first. When a guest at a wedding in Manila has an issue, I fix it on the server and it's resolved for everyone — no waiting for app store review, no forcing users to update. The web platform moves at the speed of
git push.
What I'd Do Differently
If I were starting over today, I'd make three changes:
-
Use the BarcodeDetector API for QR scanning. It's now available in Chrome, Edge, and Samsung Internet, and it's hardware-accelerated — much faster than the pure-JS
html5-qrcodelibrary. I'd use it as the primary scanner withhtml5-qrcodeas a fallback for Firefox and Safari. - Add WebP support from day one. I started with JPEG-only uploads. Switching to WebP reduced storage costs by 40% and improved gallery load times by 30%. Converting 12,000 events' worth of JPEGs to WebP was a migration I could have avoided.
- Build the admin dashboard first, not last. I spent months perfecting the guest experience before realizing hosts needed tools too — event analytics, photo moderation, download management. The host dashboard now drives retention more than any guest-facing feature.
Should You Build Something Like This?
If you're thinking about building a browser-based camera app, here's my honest take: the browser camera APIs are mature enough for production use, but you'll spend 30% of your time on the happy path and 70% on edge cases. iOS Safari quirks, Android fragmentation, network conditions at event venues (hotel WiFi is notoriously terrible), and the sheer variety of device orientations and screen sizes will consume more development time than the core feature set.
That said, the payoff is real. There's something magical about watching a room full of people scan a QR code, open their camera, and start contributing to a shared gallery — all without installing anything. It feels like how technology should work.
If you want to see it in action, check out how Picshots works or try the Picshots for weddings experience yourself. I'd love to hear what you think.
What's the most creative use of the getUserMedia API you've seen? Have you built anything with browser camera access? Drop a comment — I'm always looking for inspiration for the next feature.
Top comments (0)