The Problem (Picshots)
Last month, I was at a friend's wedding. 200 guests, all with smartphones, and the couple wanted one thing: a shared photo album everyone could contribute to without downloading anything. No app store. No sign-up. No "create an account to view these photos." Just point, shoot, and share.
I looked at existing solutions. Google Photos shared albums require a Google account. Wedding-specific apps like The Guest push you through app store installs. WhatsApp groups compress images to oblivion. Dropbox file requests still need the Dropbox app for a smooth mobile experience.
So I built something different: a web app that uses QR codes for instant access and the browser's camera API for photo capture. Zero installs. Zero accounts. One QR code.
The Architecture
The stack is deliberately boring — I wanted this to work reliably, not impress Hacker News:
Frontend: Vanilla HTML/CSS/JS with the
html5-qrcodelibrary for scanning and the MediaDevices API for camera accessBackend: Node.js with Express, handling file uploads via Multer
Storage: Local filesystem with a cron job to purge photos after 30 days
QR Generation: The
qrcodenpm package, generating codes server-sideDeployment: A $6/month DigitalOcean droplet running behind Nginx with Let's Encrypt SSL
The flow is dead simple: the host generates a QR code from the dashboard, prints it or displays it on a screen, guests scan it with their phone camera, and they're instantly on the capture page — no typing URLs, no app installs.
QR Code Implementation
Generating QR codes is the easy part. The qrcode package handles everything:
const QRCode = require('qrcode');
async function generateEventQR(eventId) {
const url = `https://snapshare.app/e/${eventId}`;
const qrDataUrl = await QRCode.toDataURL(url, {
width: 600,
margin: 2,
color: {
dark: '#1a1a2e',
light: '#ffffff'
},
errorCorrectionLevel: 'H' // 30% damage recovery
});
return qrDataUrl;
}
A few things I learned the hard way:
Error correction level matters. I started with 'L' (7% recovery) and had guests at an outdoor evening event struggling because the printed QR code got slightly smudged. Bumping to 'H' (30%) made the codes slightly denser but dramatically more reliable in the real world.
URL length vs. QR density. The longer your URL, the denser the QR code. I kept event IDs to 8-character nanoids instead of UUIDs, which kept the URLs short and the QR codes scannable from across a room.
Dynamic vs. static QR codes. I generate QR codes dynamically per event rather than pre-generating them. Each event gets a unique URL with a short-lived token embedded, so even if someone leaks the QR code, it expires with the event.
Browser Camera Integration
This is where things got interesting. The MediaDevices API (getUserMedia) is powerful but temperamental across browsers and devices.
The Basic Setup
async function initCamera() {
const constraints = {
video: {
facingMode: 'environment', // back camera
width: { ideal: 1920 },
height: { ideal: 1080 }
}
};
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
videoElement.srcObject = stream;
} catch (err) {
// Fallback: some browsers don't support facingMode
const fallbackStream = await navigator.mediaDevices.getUserMedia({
video: true
});
videoElement.srcObject = fallbackStream;
}
}
The Real-World Problems
iOS Safari and the "green dot" paranoia. Starting with iOS 14, Safari shows a prominent green dot when the camera is active. Some guests thought they were being recorded continuously. I added a clear UI indicator — a pulsing red circle with "Camera active — photo captured only when you tap" — which eliminated the confusion.
Android Chrome and autofocus. On mid-range Android devices, the camera would hunt for focus endlessly, draining battery. I added a one-shot autofocus trigger on tap-to-capture:
async function capturePhoto() {
const track = stream.getVideoTracks()[0];
const capabilities = track.getCapabilities();
if (capabilities.focusMode?.includes('single-shot')) {
await track.applyConstraints({
advanced: [{ focusMode: 'single-shot' }]
});
}
// Small delay for focus to settle
await new Promise(r => setTimeout(r, 300));
canvasContext.drawImage(videoElement, 0, 0);
const blob = await canvasToBlob(canvas);
await uploadPhoto(blob);
}
The orientation nightmare. Photos taken in portrait mode would appear rotated when displayed. The EXIF orientation data from getUserMedia is inconsistent across browsers. My solution: read the image on a canvas, detect the actual dimensions, and rotate server-side if needed. Not elegant, but it works everywhere.
Check out explore events. Check out our create your event.### QR Code Scanning (The Other Direction)
For the host to scan guest-submitted QR codes (for moderation or linking), I used html5-qrcode:
const scanner = new Html5Qrcode('reader');
scanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: 250 },
(decodedText) => {
window.location.href = decodedText;
scanner.stop();
},
(errorMessage) => {
// Ignore — scanning is continuous
}
);
The key insight: set fps: 10 not 30. Higher FPS burns CPU and battery for no benefit — QR codes don't move.
The No-App Philosophy
The "no-app" part isn't just a feature — it's the entire product philosophy. Here's what I mean:
No app store friction. The average person installs zero new apps per month. Asking 200 wedding guests to install something is a non-starter. A URL behind a QR code has zero friction.
No account creation. Every sign-up form loses 60-80% of users. My platform uses event-based tokens: the QR code URL contains a short-lived JWT that authenticates the guest to that specific event. No email, no password, no social login.
No data retention anxiety. Photos auto-delete after 30 days. Guests know their photos aren't being mined for training data or sold to advertisers. The privacy model is: "your photos, your event, then gone."
Progressive Web App as the sweet spot. I added a service worker so returning guests get a slightly faster load, but I deliberately didn't push "Add to Home Screen." The whole point is that you shouldn't need to.
The Backend: Keep It Dumb
The backend does exactly three things:
- Generate QR codes for new events
- Accept photo uploads via multipart form data
- Serve photos with short-lived signed URLs
// Event creation endpoint
app.post('/api/events', async (req, res) => {
const eventId = nanoid(8);
const token = jwt.sign({ eventId, role: 'guest' },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
const url = `https://snapshare.app/e/${eventId}?t=${token}`;
const qrCode = await QRCode.toDataURL(url, {
errorCorrectionLevel: 'H'
});
await db.events.insert({
id: eventId,
created: new Date(),
expires: new Date(Date.now() + 30 * 86400000)
});
res.json({ eventId, qrCode, url });
});
No user management. No complex permissions. No real-time features (those came later, and honestly, they weren't worth the complexity for the use case).
Challenges That Almost Broke Me
1. iOS WebRTC permissions are per-session. Every time a guest switches away from Safari and comes back, the camera permission prompt fires again. There's no way around this — it's an OS-level security decision. I added a prominent "Tap to re-enable camera" button that appears when the stream disconnects.
2. Large photo uploads on slow connections. Wedding venues often have terrible cell reception. A 12MP photo is 3-8MB. On EDGE-speed connections, that's a 2-minute upload. I added client-side compression:
function compressImage(file, maxWidth = 1920, quality = 0.85) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ratio = Math.min(maxWidth / img.width, 1);
canvas.width = img.width * ratio;
canvas.height = img.height * ratio;
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(resolve, 'image/jpeg', quality);
};
img.src = URL.createObjectURL(file);
});
}
This brought uploads down to 200-500KB with negligible quality loss for social sharing.
3. The "someone took a photo of the floor" problem. Without moderation, photo albums fill with accidental shots. I added an optional blur-detection pass using the Canvas API to flag likely-bad photos, but ultimately the best solution was giving the event host a simple moderation dashboard.
What I'd Do Differently
Use S3 from day one. Local filesystem storage works until you need to scale or back up. Migrating 50GB of photos from a DigitalOcean volume to S3-compatible storage mid-project was not fun.
Add a loading skeleton immediately. The camera initialization takes 1-3 seconds on most devices. Without a skeleton UI, guests thought the page was broken. A simple pulsing placeholder would have saved me dozens of "it's not working" messages.
You might also like our wedding guest camera. You might also find our create your event useful.Test on older devices earlier. I developed on an iPhone 15 and Pixel 8. The first real-world test was at a family gathering where my aunt's iPhone 8 couldn't maintain a stable video stream. Turns out, requesting 1080p on older devices causes the stream to stutter. Adding a capability check and falling back to 720p fixed it.
The Results
Over three months and 15 events (weddings, birthday parties, corporate gatherings), the platform handled:
4,200+ photos uploaded
800+ unique guests (zero app installs)
Average session time: 2 minutes 40 seconds
Photo upload success rate: 94% (the 6% failures were almost entirely network timeouts on poor connections)
The best feedback came from a bride who said: "My 78-year-old grandmother figured it out in 30 seconds. She's never used anything but the Phone and Messages apps."
The Code
The full project is open source. The key libraries used:
qrcode— QR code generation (82M+ monthly downloads on npm)html5-qrcode— Browser-based QR scanning (5M+ monthly downloads)express+multer— Backend and file handlingjsonwebtoken— Guest authentication tokensVanilla JS — No framework, deliberately
Key Takeaways
- QR codes are underrated infrastructure. They're free, universally supported, and bridge the physical-digital gap better than anything else.
- The browser camera API is production-ready — if you handle the edge cases. Test on real devices, not just Chrome DevTools device emulation.
- "No app" is a superpower. The conversion rate from "sees QR code" to "uploads photo" was over 80%. Compare that to app install funnels.
- Compress on the client, store on the server, purge on a schedule. This simple data flow eliminated 90% of the complexity I initially over-engineered.
- Build for the least technical user. If a 78-year-old grandmother can use it, your UX is right.
If you're building something similar, start with a single HTML file that opens the camera and uploads to a basic endpoint. You'll have a working prototype in an afternoon. Everything else — the QR codes, the event management, the gallery view — is just polish on top of that core loop.
Have you built something with QR codes or the browser camera API? I'd love to hear about your experience in the comments.


Top comments (0)