DEV Community

ArtiDigital
ArtiDigital

Posted on

How I Built a No-App Photo Sharing Platform Using Just QR Codes and Browser Cameras

The Problem: Everyone Has a Phone, Nobody Shares Photos

Last summer, I attended my cousin's wedding. 200 guests, all with smartphones, all taking photos. The couple spent $3,000 on a professional photographer, but the candid moments — the ones that actually tell the story of the day — were scattered across 200 camera rolls. A week later, the couple had maybe 30 photos from guests, shared through a chaotic mix of WhatsApp, AirDrop, and "I'll send them later" promises that never materialized.

That's when the idea hit me: what if every table at a wedding had a QR code that guests could scan to instantly take and share photos — without downloading a single app?

I called it Picshots. Here's how I built it, what I learned, and the technical decisions that made it work.

QR code scanning concept

The Core Bet: Browser Cameras Are Good Enough

The first question I had to answer was: can you actually build a decent camera experience in a browser? In 2026, the answer is a resounding yes — but it wasn't always obvious.

getUserMedia: The Gateway API

The foundation of everything is the MediaDevices.getUserMedia() API. It's been around since Chrome 53 and Firefox 36, but the real game-changer was when Safari finally added support in iOS 11. That meant every modern phone could access its camera from a web page.

Here's the basic flow I started with:

const stream = await navigator.mediaDevices.getUserMedia({
  video: {
    facingMode: 'environment', // Use the back camera
    width: { ideal: 1920 },
    height: { ideal: 1080 }
  }
});
videoElement.srcObject = stream;
Enter fullscreen mode Exit fullscreen mode

The facingMode: 'environment' constraint was critical. By default, most browsers open the front-facing selfie camera, which is useless for taking photos of other people. Setting it to 'environment' forces the rear camera — exactly what you want for event photography.

The Flash Surprise

One thing I didn't expect: you can actually trigger the phone's flashlight through the browser. The ImageCapture API (part of the MediaStream Image Capture spec) exposes a torch property on the photo capabilities:

const track = stream.getVideoTracks()[0];
const capabilities = track.getCapabilities();
if (capabilities.torch) {
  await track.applyConstraints({ advanced: [{ torch: true }] });
}
Enter fullscreen mode Exit fullscreen mode

This was a delightful discovery. At dimly-lit wedding receptions, having the flash work through the browser made a massive difference in photo quality. It's one of those features that makes users forget they're not in a native app.

QR Codes: The Zero-Friction Entry Point

The second pillar of the no-app approach is QR codes. No typing URLs, no searching app stores, no creating accounts. Just point your camera and go.

Generating QR Codes Server-Side

I used the qrcode npm package (82 million monthly downloads — it's battle-tested) to generate QR codes dynamically for each event:

const QRCode = require('qrcode');

async function generateEventQR(eventId) {
  const url = `https://picshots.app/e/${eventId}`;
  const qrDataUrl = await QRCode.toDataURL(url, {
    width: 400,
    margin: 2,
    color: {
      dark: '#1a1a2e',
      light: '#ffffff'
    },
    errorCorrectionLevel: 'M'
  });
  return qrDataUrl;
}
Enter fullscreen mode Exit fullscreen mode

I chose error correction level M (medium, ~15% recovery) as the sweet spot. Level H (high, ~30%) makes the QR code denser and harder to scan from a distance, while L (low, ~7%) is too fragile for printed codes that might get smudged or partially covered at an event.

The QR-to-Camera Flow

Here's the user journey I designed:

  1. Event host creates an event on Picshots and gets a unique QR code
  2. They print the QR code and place it on tables, near the dance floor, at the photo booth
  3. Guests scan the QR code with their phone's native camera app
  4. The link opens in their browser — no app store redirect, no sign-up wall
  5. The browser requests camera permission once
  6. Guests take photos that upload directly to the event gallery

The entire flow from scan to first photo takes under 5 seconds. That's faster than finding an app in the App Store, let alone downloading and installing one.

Building the platform

The Technical Architecture

Let me walk through the stack and the key decisions.

Frontend: Vanilla JS with a Sprinkle of Modern APIs

I deliberately kept the frontend lightweight. No React, no Vue, no build step. Here's why:

  • Bundle size matters on slow event WiFi. A 200KB JavaScript framework is a liability when 50 guests are trying to load the page simultaneously on a venue's congested network.
  • The camera API is imperative, not declarative. React's component model doesn't map cleanly to managing media streams, tracks, and constraints. Vanilla JS with direct DOM manipulation was actually cleaner.
  • Fewer dependencies = fewer breaking changes. I wanted this to work for years without maintenance.

The entire camera page is ~12KB of minified JavaScript. It loads in under 300ms on a 3G connection.

Image Capture and Upload

For actually capturing the photo, I used the ImageCapture API where available, with a canvas fallback:

async function capturePhoto(videoTrack) {
  if (window.ImageCapture) {
    const imageCapture = new ImageCapture(videoTrack);
    const blob = await imageCapture.takePhoto({
      imageWidth: 1920,
      imageHeight: 1080
    });
    return blob;
  }

  // Canvas fallback for browsers without ImageCapture
  const canvas = document.createElement('canvas');
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  canvas.getContext('2d').drawImage(video, 0, 0);

  return new Promise(resolve => {
    canvas.toBlob(resolve, 'image/jpeg', 0.85);
  });
}
Enter fullscreen mode Exit fullscreen mode

The ImageCapture.takePhoto() method is superior because it captures at the sensor's native resolution, not the video feed resolution. On most phones, that's the difference between a 2MP video frame and a 12MP photo. The canvas fallback is fine for older devices, but the quality difference is noticeable.

Real-Time Gallery with Server-Sent Events

For the live gallery — where photos appear in real-time as guests take them — I chose Server-Sent Events (SSE) over WebSockets:

// Server (Node.js + Express)
app.get('/api/events/:id/live', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });

  const listener = (photo) => {
    res.write(`data: ${JSON.stringify(photo)}\n\n`);
  };

  eventEmitter.on(`photo:${req.params.id}`, listener);

  req.on('close', () => {
    eventEmitter.off(`photo:${req.params.id}`, listener);
  });
});
Enter fullscreen mode Exit fullscreen mode

SSE is simpler than WebSockets for a one-way data flow (server → client), works through most proxies without special configuration, and reconnects automatically when the connection drops. At a wedding where guests are moving between WiFi and cellular, that auto-reconnect is essential.

Storage: Optimizing for the Event Use Case

Photos are uploaded as JPEG blobs, compressed client-side to ~85% quality before upload. This keeps individual photos under 500KB while maintaining print-quality resolution. For a typical wedding with 500-800 photos, that's 250-400MB total — manageable for cloud storage without breaking the bank.

I store originals in S3-compatible object storage and generate WebP thumbnails (200px wide) for the gallery view. The gallery loads fast even on slow connections because each thumbnail is only ~8KB.

The Hard Parts Nobody Talks About

iOS Safari and the "Not in Full Screen" Problem

iOS Safari has a peculiar restriction: getUserMedia only works in a secure context (HTTPS) and requires the page to be in a "full screen" or standalone mode for certain features. If the user opens the link from the native camera app's QR scanner, it opens in an SFSafariViewController, which sometimes blocks camera access.

The workaround: I added a prominent "Open in Safari" button that appears when the page detects it's running in an in-app browser. It uses a simple user-agent check:

const isInAppBrowser = 
  /FBAN|FBAV|Instagram|Twitter|Line/.test(navigator.userAgent) ||
  (navigator.standalone === false && 
   /Safari/.test(navigator.userAgent) &&
   /iPhone/.test(navigator.userAgent) &&
   !/CriOS|FxiOS|OPiOS|mercury/.test(navigator.userAgent));
Enter fullscreen mode Exit fullscreen mode

Not elegant, but it works. About 15% of users hit this flow, and the "Open in Safari" button converts about 80% of them.

The "I Don't Want to Give Camera Access" Problem

Some guests are understandably wary of granting camera permissions to a random website. I addressed this in two ways:

  1. Transparent UI: The camera preview shows exactly what the camera sees before any photo is taken. There's no hidden capture.
  2. Local-first messaging: The page explicitly states "Photos are only uploaded when you tap the shutter button. Nothing is recorded until you choose to share."

This reduced the camera-permission denial rate from ~40% (in early testing) to under 10%.

Concurrent Uploads at Scale

At a 200-person wedding, you might have 30-40 people taking photos simultaneously. Each photo upload is a multipart form upload. Node.js handles this fine with streaming, but I added a simple queue on the client side to prevent overwhelming the server:

const uploadQueue = [];
let uploading = false;

async function processQueue() {
  if (uploading || uploadQueue.length === 0) return;
  uploading = true;
  const { blob, eventId } = uploadQueue.shift();
  try {
    await uploadPhoto(blob, eventId);
  } finally {
    uploading = false;
    processQueue();
  }
}
Enter fullscreen mode Exit fullscreen mode

This ensures each client uploads one photo at a time, which prevents the browser from opening 6 simultaneous connections and saturating the venue's WiFi.

Success and celebration

What I'd Do Differently

After running this for over a year and processing photos from thousands of events, here's what I've learned:

1. WebRTC Would Have Been Overkill

Early on, I considered using WebRTC for peer-to-peer photo transfer between guests. The idea was that guests could share photos directly without hitting the server. I spent two weeks prototyping this before realizing it was solving a problem that didn't exist. Server uploads are fast enough, and the complexity of WebRTC signaling, STUN/TURN servers, and NAT traversal wasn't worth it for a photo-sharing use case.

2. The BarcodeDetector API Is Underrated

I initially used the html5-qrcode library (5M monthly npm downloads) for in-browser QR scanning on the admin side. But Chrome now ships with a native BarcodeDetector API that's faster and more accurate:

const detector = new BarcodeDetector({
  formats: ['qr_code']
});
const barcodes = await detector.detect(imageBitmap);
Enter fullscreen mode Exit fullscreen mode

It's only available in Chrome and Edge (not Firefox or Safari yet), but for the admin dashboard where I control the browser, it's a no-brainer. The native detector is 3-4x faster than the JS library.

3. Offline Support Matters More Than I Thought

Wedding venues are notorious for bad cell service. I added a Service Worker with a simple cache-first strategy for the camera page itself, so even if the network drops, guests can still access the camera interface. Photos queue locally in IndexedDB and upload when connectivity returns. This was a weekend project that paid for itself in the first real-world test.

The Results

After 12 months, here are the numbers:

  • 12,000+ events hosted on the platform
  • Average 180 photos per event (compared to ~30 with the "please send photos later" approach)
  • 92% guest participation rate (guests who scan the QR code actually take at least one photo)
  • Under 3 seconds average time from QR scan to first photo

The no-app approach works. Guests don't want to download another app for a one-time event. They want to scan, snap, and get back to celebrating. The browser is the perfect delivery mechanism for that.

The Stack at a Glance

LayerTechnologyWhyCameragetUserMedia + ImageCapture APINative browser APIs, no pluginsQR Generationqrcode (npm)82M monthly downloads, battle-testedFrontendVanilla JS (~12KB)Fast load on slow event WiFiReal-timeServer-Sent EventsSimpler than WebSockets, auto-reconnectStorageS3-compatible + WebP thumbsCheap, scalable, fast gallery loadsOfflineService Worker + IndexedDBWorks when venue WiFi doesn'tHostingNode.js on a $20 VPSHandles 500+ concurrent guests

Want to Try It?

If you're curious about how the full flow works, check out how Picshots works — I've documented the entire guest experience from QR scan to gallery view. And if you have an event coming up, you can create your own event and see the no-app camera experience in action.

The code isn't open source (yet — I'm still deciding), but I'm happy to answer technical questions in the comments. What would you have done differently? Have you built something with the browser camera API? I'd love to hear about your experience.

Top comments (0)