<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ArtiDigital</title>
    <description>The latest articles on DEV Community by ArtiDigital (@morpheus1537).</description>
    <link>https://dev.to/morpheus1537</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4073293%2F098f6d5b-d1c3-49d4-8a9a-2b1f73208f69.png</url>
      <title>DEV Community: ArtiDigital</title>
      <link>https://dev.to/morpheus1537</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/morpheus1537"/>
    <language>en</language>
    <item>
      <title>How I Built a No-App Photo Sharing Platform Using Just QR Codes and Browser Cameras</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Tue, 11 Aug 2026 21:06:12 +0000</pubDate>
      <link>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-54h5</link>
      <guid>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-54h5</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That moment stuck with me. I'm a developer, and I kept thinking: &lt;em&gt;there has to be a better way&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://picshots.app" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt; — 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Problem: Friction Kills Participation
&lt;/h2&gt;

&lt;p&gt;Here's a stat that shaped every decision I made: &lt;strong&gt;for every additional step between a guest and their first photo upload, you lose roughly 40% of potential participants&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;The math is brutal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App Store → find app → download → install → open → create account → verify email → find event → take photo = &lt;strong&gt;~5% participation&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Scan QR → camera opens → take photo → done = &lt;strong&gt;~90% participation&lt;/strong&gt;
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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Tech Stack: What Powers a Browser-Based Photo Platform
&lt;/h2&gt;

&lt;p&gt;Before diving into the code, here's the stack I landed on after several iterations:&lt;/p&gt;

&lt;p&gt;LayerTechnologyWhy&lt;/p&gt;

&lt;p&gt;Camera AccessMediaDevices.getUserMedia()Works in every modern browser, no polyfills needed&lt;/p&gt;

&lt;p&gt;QR Generationqrcode (npm, 82M monthly downloads)Battle-tested, supports SVG output for crisp printing&lt;/p&gt;

&lt;p&gt;QR Scanninghtml5-qrcode (npm, 5M monthly downloads)Pure JS, no WASM, works on mobile browsers&lt;/p&gt;

&lt;p&gt;Image UploadPresigned S3 URLsBypasses server bottlenecks on large files&lt;/p&gt;

&lt;p&gt;Real-time GallerySupabase RealtimeWebSocket-based, no polling, scales to thousands of concurrent viewers&lt;/p&gt;

&lt;p&gt;FrontendNext.js + TailwindSSR for SEO pages, CSR for the camera experience&lt;/p&gt;

&lt;p&gt;HostingVercel + S3 + SupabaseEdge functions for QR redirects, S3 for photos, Supabase for metadata&lt;/p&gt;

&lt;p&gt;Let me walk through each piece and the decisions behind them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Accessing the Camera Without an App
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;getUserMedia&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;facingMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;environment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Use back camera on mobile&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;camera-preview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things I learned the hard way:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS is non-negotiable.&lt;/strong&gt; &lt;code&gt;getUserMedia&lt;/code&gt; only works on &lt;code&gt;localhost&lt;/code&gt; or 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS Safari has quirks.&lt;/strong&gt; On iOS, &lt;code&gt;getUserMedia&lt;/code&gt; must 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;facingMode: 'environment'&lt;/code&gt; constraint is a suggestion, not a command.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Why WebRTC Matters for Browser Camera Access
&lt;/h3&gt;

&lt;p&gt;While &lt;code&gt;getUserMedia&lt;/code&gt; handles camera access, &lt;strong&gt;WebRTC&lt;/strong&gt; (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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getUserMedia&lt;/code&gt;&lt;/strong&gt; — accesses the camera and microphone (this is part of the WebRTC spec)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;RTCPeerConnection&lt;/code&gt;&lt;/strong&gt; — enables peer-to-peer audio/video/data transfer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;RTCDataChannel&lt;/code&gt;&lt;/strong&gt; — allows arbitrary data transfer between peers
Even though Picshots uses a client-server upload model for photos, WebRTC's &lt;code&gt;getUserMedia&lt;/code&gt; is the foundation. The media constraints API (&lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt;, &lt;code&gt;facingMode&lt;/code&gt;, &lt;code&gt;frameRate&lt;/code&gt;) 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;code&gt;RTCDataChannel&lt;/code&gt; API supports reliable, ordered data delivery — perfect for file transfers. I prototyped this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WebRTC P2P photo transfer prototype&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;peerConnection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataChannel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;peerConnection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDataChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;photos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxRetransmits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;dataChannel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onopen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Send photo blob directly to another guest&lt;/span&gt;
  &lt;span class="nx"&gt;dataChannel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photoBlob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: QR Codes — The Zero-Friction Entry Point
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I used the &lt;code&gt;qrcode&lt;/code&gt; npm package (82 million monthly downloads — it's basically the standard at this point) to generate QR codes server-side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qrcode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://picshots.app/e/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qrSvg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;svg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;errorCorrectionLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;H&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// High — survives up to 30% damage&lt;/span&gt;
  &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#1a1a2e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;light&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key decisions here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SVG over PNG:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error correction level H:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short URLs matter:&lt;/strong&gt; The less data in a QR code, the larger and more scannable each module (those little squares) becomes. I use short event IDs (&lt;code&gt;/e/abc123&lt;/code&gt;) rather than long UUIDs to keep the QR code clean and scannable from a distance.
For the scanning side, I use &lt;code&gt;html5-qrcode&lt;/code&gt; (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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Capturing and Uploading Photos
&lt;/h2&gt;

&lt;p&gt;Once the camera is running, capturing a photo is straightforward — grab a frame from the video stream and draw it to a canvas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;capturePhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upload pipeline is where things get interesting. I use &lt;strong&gt;presigned S3 URLs&lt;/strong&gt; to bypass the server entirely during upload:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Client requests a presigned URL from the API&lt;/li&gt;
&lt;li&gt;Client uploads directly to S3 using that URL&lt;/li&gt;
&lt;li&gt;S3 triggers a Lambda that generates thumbnails and stores metadata in Supabase&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;code&gt;photos&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The Hard Parts Nobody Talks About
&lt;/h2&gt;

&lt;h3&gt;
  
  
  iOS Safari and the "Page Reload" Problem
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;My fix: I store the camera state in &lt;code&gt;sessionStorage&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Orientation Lock on Mobile
&lt;/h3&gt;

&lt;p&gt;When a guest rotates their phone from portrait to landscape mid-capture, the video stream dimensions change. If you're not handling the &lt;code&gt;resize&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Concurrent Upload Limits
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: The Gallery Experience
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I built the gallery with a few key features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lazy loading with blur-up placeholders:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infinite scroll with virtualization:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Download all as ZIP:&lt;/strong&gt; After the event, hosts can download every photo as a single ZIP file. This is generated server-side using &lt;code&gt;archiver&lt;/code&gt; and streamed directly from S3 — no temporary files on disk.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Results: What 12,000+ Events Taught Me
&lt;/h2&gt;

&lt;p&gt;Since launching, &lt;a href="https://picshots.app" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt; has been used at over 12,000 events — weddings, birthday parties, corporate galas, baby showers, you name it. Here's what the data shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;92% guest participation rate&lt;/strong&gt; — meaning 92% of guests who scan the QR code take at least one photo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Average of 8.3 photos per guest&lt;/strong&gt; — people don't just take one and leave; they come back throughout the night&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Under 3 seconds from scan to first photo&lt;/strong&gt; — the no-download, no-signup flow delivers on its promise&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero app store reviews to manage&lt;/strong&gt; — 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 &lt;code&gt;git push&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were starting over today, I'd make three changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use the BarcodeDetector API for QR scanning.&lt;/strong&gt; It's now available in Chrome, Edge, and Samsung Internet, and it's hardware-accelerated — much faster than the pure-JS &lt;code&gt;html5-qrcode&lt;/code&gt; library. I'd use it as the primary scanner with &lt;code&gt;html5-qrcode&lt;/code&gt; as a fallback for Firefox and Safari.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add WebP support from day one.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the admin dashboard first, not last.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Should You Build Something Like This?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;should&lt;/em&gt; work.&lt;/p&gt;

&lt;p&gt;If you want to see it in action, check out &lt;a href="https://picshots.app/how-it-works" rel="noopener noreferrer"&gt;how Picshots works&lt;/a&gt; or try the &lt;a href="https://picshots.app/for/weddings" rel="noopener noreferrer"&gt;Picshots for weddings&lt;/a&gt; experience yourself. I'd love to hear what you think.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>buildinginpublic</category>
    </item>
    <item>
      <title>How I Built a No-App Photo Sharing Platform Using Just QR Codes and Browser Cameras</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Tue, 11 Aug 2026 20:05:13 +0000</pubDate>
      <link>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-2obn</link>
      <guid>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-2obn</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That moment stuck with me. I'm a developer, and I kept thinking: &lt;em&gt;there has to be a better way&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://picshots.app" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt; — 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Problem: Friction Kills Participation
&lt;/h2&gt;

&lt;p&gt;Here's a stat that shaped every decision I made: &lt;strong&gt;for every additional step between a guest and their first photo upload, you lose roughly 40% of potential participants&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;The math is brutal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;App Store → find app → download → install → open → create account → verify email → find event → take photo = &lt;strong&gt;~5% participation&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Scan QR → camera opens → take photo → done = &lt;strong&gt;~90% participation&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tech Stack: What Powers a Browser-Based Photo Platform
&lt;/h2&gt;

&lt;p&gt;Before diving into the code, here's the stack I landed on after several iterations:&lt;/p&gt;

&lt;p&gt;LayerTechnologyWhy&lt;/p&gt;

&lt;p&gt;Camera AccessMediaDevices.getUserMedia()Works in every modern browser, no polyfills needed&lt;br&gt;
QR Generationqrcode (npm, 82M monthly downloads)Battle-tested, supports SVG output for crisp printing&lt;br&gt;
QR Scanninghtml5-qrcode (npm, 5M monthly downloads)Pure JS, no WASM, works on mobile browsers&lt;br&gt;
Image UploadPresigned S3 URLsBypasses server bottlenecks on large files&lt;br&gt;
Real-time GallerySupabase RealtimeWebSocket-based, no polling, scales to thousands of concurrent viewers&lt;br&gt;
FrontendNext.js + TailwindSSR for SEO pages, CSR for the camera experience&lt;br&gt;
HostingVercel + S3 + SupabaseEdge functions for QR redirects, S3 for photos, Supabase for metadata&lt;/p&gt;

&lt;p&gt;Let me walk through each piece and the decisions behind them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: Accessing the Camera Without an App
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;getUserMedia&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;facingMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;environment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Use back camera on mobile&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;camera-preview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Three things I learned the hard way:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS is non-negotiable.&lt;/strong&gt; &lt;code&gt;getUserMedia&lt;/code&gt; only works on &lt;code&gt;localhost&lt;/code&gt; or 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS Safari has quirks.&lt;/strong&gt; On iOS, &lt;code&gt;getUserMedia&lt;/code&gt; must 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;facingMode: 'environment'&lt;/code&gt; constraint is a suggestion, not a command.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://media1.giphy.com/media/v1.Y2lkPWFiNGU0NTNiMmtnY2VvZ2Fkcjc3emJvM24xdWhsaDdwcmx0aGZpbDgxeThuYmZwZSZlcD12MV9naWZzX3NlYXJjaCZjdD1n/26tn33aiTi1jkl6H6/giphy.gif" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;media1.giphy.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



&lt;h3&gt;
  
  
  Why WebRTC Matters for Browser Camera Access
&lt;/h3&gt;

&lt;p&gt;While &lt;code&gt;getUserMedia&lt;/code&gt; handles camera access, &lt;strong&gt;WebRTC&lt;/strong&gt; (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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getUserMedia&lt;/code&gt;&lt;/strong&gt; — accesses the camera and microphone (this is part of the WebRTC spec)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;RTCPeerConnection&lt;/code&gt;&lt;/strong&gt; — enables peer-to-peer audio/video/data transfer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;RTCDataChannel&lt;/code&gt;&lt;/strong&gt; — allows arbitrary data transfer between peers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Even though Picshots uses a client-server upload model for photos, WebRTC's &lt;code&gt;getUserMedia&lt;/code&gt; is the foundation. The media constraints API (&lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt;, &lt;code&gt;facingMode&lt;/code&gt;, &lt;code&gt;frameRate&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;RTCDataChannel&lt;/code&gt; API supports reliable, ordered data delivery — perfect for file transfers. I prototyped this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WebRTC P2P photo transfer prototype&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;peerConnection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataChannel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;peerConnection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDataChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;photos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;ordered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxRetransmits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;dataChannel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onopen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Send photo blob directly to another guest&lt;/span&gt;
  &lt;span class="nx"&gt;dataChannel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photoBlob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: QR Codes — The Zero-Friction Entry Point
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I used the &lt;code&gt;qrcode&lt;/code&gt; npm package (82 million monthly downloads — it's basically the standard at this point) to generate QR codes server-side:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qrcode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://picshots.app/e/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qrSvg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;svg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;errorCorrectionLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;H&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// High — survives up to 30% damage&lt;/span&gt;
  &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#1a1a2e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;light&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Key decisions here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SVG over PNG:&lt;/strong&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Error correction level H:&lt;/strong&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Short URLs matter:&lt;/strong&gt; The less data in a QR code, the larger and more scannable each module (those little squares) becomes. I use short event IDs (&lt;code&gt;/e/abc123&lt;/code&gt;) rather than long UUIDs to keep the QR code clean and scannable from a distance.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the scanning side, I use &lt;code&gt;html5-qrcode&lt;/code&gt; (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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: Capturing and Uploading Photos
&lt;/h2&gt;

&lt;p&gt;Once the camera is running, capturing a photo is straightforward — grab a frame from the video stream and draw it to a canvas:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;capturePhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The upload pipeline is where things get interesting. I use &lt;strong&gt;presigned S3 URLs&lt;/strong&gt; to bypass the server entirely during upload:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Client requests a presigned URL from the API&lt;/li&gt;
&lt;li&gt;Client uploads directly to S3 using that URL&lt;/li&gt;
&lt;li&gt;S3 triggers a Lambda that generates thumbnails and stores metadata in Supabase&lt;/li&gt;
&lt;li&gt;Supabase Realtime pushes the new photo to all connected gallery viewers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;photos&lt;/code&gt; 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.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://media1.giphy.com/media/v1.Y2lkPWFiNGU0NTNicXd2ZXV5aTY2emI4eDhxYTcyZno3bTAwY2ZjeG1leGQ1b3o2M284NiZlcD12MV9naWZzX3NlYXJjaCZjdD1n/1L5YuA6wpKkNO/giphy.gif" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;media1.giphy.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: The Hard Parts Nobody Talks About
&lt;/h2&gt;

&lt;h3&gt;
  
  
  iOS Safari and the "Page Reload" Problem
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;My fix: I store the camera state in &lt;code&gt;sessionStorage&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Orientation Lock on Mobile
&lt;/h3&gt;

&lt;p&gt;When a guest rotates their phone from portrait to landscape mid-capture, the video stream dimensions change. If you're not handling the &lt;code&gt;resize&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Concurrent Upload Limits
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5: The Gallery Experience
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I built the gallery with a few key features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Lazy loading with blur-up placeholders:&lt;/strong&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Infinite scroll with virtualization:&lt;/strong&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Download all as ZIP:&lt;/strong&gt; After the event, hosts can download every photo as a single ZIP file. This is generated server-side using &lt;code&gt;archiver&lt;/code&gt; and streamed directly from S3 — no temporary files on disk.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The Results: What 12,000+ Events Taught Me
&lt;/h2&gt;

&lt;p&gt;Since launching, &lt;a href="https://picshots.app" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt; has been used at over 12,000 events — weddings, birthday parties, corporate galas, baby showers, you name it. Here's what the data shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;92% guest participation rate&lt;/strong&gt; — meaning 92% of guests who scan the QR code take at least one photo&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Average of 8.3 photos per guest&lt;/strong&gt; — people don't just take one and leave; they come back throughout the night&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Under 3 seconds from scan to first photo&lt;/strong&gt; — the no-download, no-signup flow delivers on its promise&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zero app store reviews to manage&lt;/strong&gt; — because there's no app. Bug fixes ship instantly to every user.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;code&gt;git push&lt;/code&gt;.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://media0.giphy.com/media/v1.Y2lkPWFiNGU0NTNid2wxZHV5aGw5ZnBwejRobHBlMzltMGN3MWduM243Zmtkam5qbnZ3NiZlcD12MV9naWZzX3NlYXJjaCZjdD1n/vmon3eAOp1WfK/giphy.gif" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;media0.giphy.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were starting over today, I'd make three changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use the BarcodeDetector API for QR scanning.&lt;/strong&gt; It's now available in Chrome, Edge, and Samsung Internet, and it's hardware-accelerated — much faster than the pure-JS &lt;code&gt;html5-qrcode&lt;/code&gt; library. I'd use it as the primary scanner with &lt;code&gt;html5-qrcode&lt;/code&gt; as a fallback for Firefox and Safari.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add WebP support from day one.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the admin dashboard first, not last.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Should You Build Something Like This?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;should&lt;/em&gt; work.&lt;/p&gt;

&lt;p&gt;If you want to see it in action, check out &lt;a href="https://picshots.app/how-it-works" rel="noopener noreferrer"&gt;how Picshots works&lt;/a&gt; or try the &lt;a href="https://picshots.app/for/weddings" rel="noopener noreferrer"&gt;Picshots for weddings&lt;/a&gt; yourself. I'd love to hear what you think.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>buildinginpublic</category>
    </item>
    <item>
      <title>How I Built a No-App Photo Sharing Platform Using Just QR Codes and Browser Cameras</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:00:07 +0000</pubDate>
      <link>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-4c39</link>
      <guid>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-4c39</guid>
      <description>&lt;h2&gt;
  
  
  The Spark: Why I Decided to Build a Camera App Without an App
&lt;/h2&gt;

&lt;p&gt;I've already written about &lt;a href="https://picshots.app/blog/why-i-made-picshots-app-free-the-ux-decision-that-doubled-guest-photo-uploads" rel="noopener noreferrer"&gt;the UX decision to go app-free&lt;/a&gt; — 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 &lt;em&gt;why&lt;/em&gt;. This one is about the &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://picshots.app" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt; — 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmedia2.giphy.com%2Fmedia%2Fv1.Y2lkPWFiNGU0NTNiZGZhbmVvazZ1anVoZTlkOXA0ZDVreTQyZnA4M3hlN2ZpbXF5YTZkOSZlcD12MV9naWZzX3NlYXJjaCZjdD1n%2Fl0HlKrB02QY2R0Qxq%2Fgiphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmedia2.giphy.com%2Fmedia%2Fv1.Y2lkPWFiNGU0NTNiZGZhbmVvazZ1anVoZTlkOXA0ZDVreTQyZnA4M3hlN2ZpbXF5YTZkOSZlcD12MV9naWZzX3NlYXJjaCZjdD1n%2Fl0HlKrB02QY2R0Qxq%2Fgiphy.gif" alt="Person scanning QR code with phone camera" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture: Three Moving Parts
&lt;/h2&gt;

&lt;p&gt;At its core, the platform has three technical components that need to work together seamlessly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;QR Code Generation &amp;amp; Scanning&lt;/strong&gt; — 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser Camera Access&lt;/strong&gt; — 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).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-Time Photo Gallery&lt;/strong&gt; — 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let me walk through each one, including the code, the gotchas, and the libraries that saved me months of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: QR Codes — The Zero-Friction Entry Point
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing a QR Library
&lt;/h3&gt;

&lt;p&gt;I evaluated three approaches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Server-side generation&lt;/strong&gt; (Node.js + &lt;code&gt;qrcode&lt;/code&gt; package): 82 million monthly npm downloads, battle-tested, supports every format. The safe choice.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Client-side generation&lt;/strong&gt; (&lt;code&gt;qrcode-generator&lt;/code&gt; or Canvas API): No server round-trip, but heavier on the client and inconsistent across browsers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Third-party API&lt;/strong&gt; (Google Charts, QR Server): Adds an external dependency and latency. No thanks.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I went with server-side generation using the &lt;code&gt;qrcode&lt;/code&gt; npm package. Here's the core logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why Error Correction Level Matters
&lt;/h3&gt;

&lt;p&gt;I initially used error correction level &lt;code&gt;L&lt;/code&gt; (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 &lt;code&gt;M&lt;/code&gt; (~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 &lt;code&gt;Q&lt;/code&gt; (~25%).&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;width: 600&lt;/code&gt; parameter generates a high-enough resolution SVG/PNG that scales cleanly in both directions without pixelation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic QR Codes, Not Static
&lt;/h3&gt;

&lt;p&gt;One architectural decision I'm glad I made early: every QR code encodes a dynamic URL (&lt;code&gt;picshots.app/e/{eventId}&lt;/code&gt;), 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmedia3.giphy.com%2Fmedia%2Fv1.Y2lkPWFiNGU0NTNiZGZhbmVvazZ1anVoZTlkOXA0ZDVreTQyZnA4M3hlN2ZpbXF5YTZkOSZlcD12MV9naWZzX3NlYXJjaCZjdD1n%2FxT9DPldJHzZKtORo3C%2Fgiphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmedia3.giphy.com%2Fmedia%2Fv1.Y2lkPWFiNGU0NTNiZGZhbmVvazZ1anVoZTlkOXA0ZDVreTQyZnA4M3hlN2ZpbXF5YTZkOSZlcD12MV9naWZzX3NlYXJjaCZjdD1n%2FxT9DPldJHzZKtORo3C%2Fgiphy.gif" alt="QR code being scanned and opening a web page" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: The Browser Camera — Making getUserMedia Feel Native
&lt;/h2&gt;

&lt;p&gt;This is where things got interesting. The &lt;code&gt;getUserMedia&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Basic Setup
&lt;/h3&gt;

&lt;p&gt;Here's the starting point — the minimum viable camera:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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.
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;facingMode: 'environment'&lt;/code&gt; 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: &lt;code&gt;facingMode&lt;/code&gt; is a &lt;em&gt;constraint&lt;/em&gt;, 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Capturing High-Quality Still Photos
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;getUserMedia&lt;/code&gt; stream gives you a video feed, not a photo. To capture a still image, you have two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Canvas capture (works everywhere)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option B: ImageCapture API (Chromium only, higher quality)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async function capturePhotoHQ(track) {
  const imageCapture = new ImageCapture(track);
  const blob = await imageCapture.takePhoto({
    imageWidth: 1920,
    imageHeight: 1080
  });
  return URL.createObjectURL(blob);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use both. The &lt;code&gt;ImageCapture&lt;/code&gt; 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."&lt;/p&gt;

&lt;h3&gt;
  
  
  The Orientation Nightmare
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here's the orientation correction logic I landed on after four rewrites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function correctOrientation(canvas, videoElement) {
  const ctx = canvas.getContext('2d');
  const videoWidth = videoElement.videoWidth;
  const videoHeight = videoElement.videoHeight;

  // Detect device orientation
  const isPortrait = window.innerHeight &amp;gt; window.innerWidth;
  const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);

  if (isPortrait &amp;amp;&amp;amp; 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);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Flashlight Surprise
&lt;/h3&gt;

&lt;p&gt;One discovery that genuinely delighted me: you can control the phone's flashlight through the browser. The &lt;code&gt;ImageCapture&lt;/code&gt; API exposes a &lt;code&gt;torch&lt;/code&gt; capability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmedia0.giphy.com%2Fmedia%2Fv1.Y2lkPWFiNGU0NTNiZGZhbmVvazZ1anVoZTlkOXA0ZDVreTQyZnA4M3hlN2ZpbXF5YTZkOSZlcD12MV9naWZzX3NlYXJjaCZjdD1n%2F3o6Zt6KHxJTbC%2Fgiphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmedia0.giphy.com%2Fmedia%2Fv1.Y2lkPWFiNGU0NTNiZGZhbmVvazZ1anVoZTlkOXA0ZDVreTQyZnA4M3hlN2ZpbXF5YTZkOSZlcD12MV9naWZzX3NlYXJjaCZjdD1n%2F3o6Zt6KHxJTbC%2Fgiphy.gif" alt="Smartphone camera flash going off at a party" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: Real-Time Gallery — Making Photos Appear Instantly
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upload Resilience on Terrible WiFi
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I built a retry queue with exponential backoff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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 &amp;gt; 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 &amp;gt;= 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 =&amp;gt; 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();
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;photos&lt;/code&gt; table, and every connected client receives the new photo via a WebSocket subscription. No polling, no manual refresh — the gallery just updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Not WebRTC?
&lt;/h3&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;WebRTC needs signaling servers anyway&lt;/strong&gt; — you still need a server to establish the peer connection, so you're not actually serverless.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;NAT traversal is unreliable on venue networks&lt;/strong&gt; — hotel and venue WiFi often blocks the UDP ports WebRTC needs for STUN/TURN.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The host needs a central gallery&lt;/strong&gt; — peer-to-peer means photos live on individual devices. The whole point of Picshots is one shared gallery the host controls.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Guests come and go&lt;/strong&gt; — if the only person who has a photo leaves the event, that photo is gone from the P2P mesh.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack, Summarized
&lt;/h2&gt;

&lt;p&gt;Here's the full technical stack that powers the no-app photo sharing experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Frontend:&lt;/strong&gt; Vanilla JavaScript (no framework — keep the bundle tiny for fast QR code landing page loads)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Camera:&lt;/strong&gt; getUserMedia + ImageCapture API with canvas fallback&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;QR Generation:&lt;/strong&gt; &lt;code&gt;qrcode&lt;/code&gt; npm package (82M monthly downloads), server-side, error correction level M&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;QR Scanning:&lt;/strong&gt; BarcodeDetector API (Chromium) + &lt;code&gt;html5-qrcode&lt;/code&gt; polyfill (5M monthly downloads) for Safari&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Uploads:&lt;/strong&gt; Retry queue with exponential backoff, chunked uploads for large files&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Real-Time Gallery:&lt;/strong&gt; Supabase Realtime (WebSocket subscriptions)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Storage:&lt;/strong&gt; Supabase Storage for photos, Supabase Postgres for metadata&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hosting:&lt;/strong&gt; Static site on CDN, API on a lightweight Node.js server&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;Building in public means being honest about the mistakes. Here are mine:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. I should have tested on more Android devices earlier.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The orientation fix should have been a standalone library.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. I underestimated how much guests care about photo quality.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you want to see how it all comes together, check out &lt;a href="https://picshots.app/how-it-works" rel="noopener noreferrer"&gt;how Picshots works&lt;/a&gt; or &lt;a href="https://picshots.app/create" rel="noopener noreferrer"&gt;create your own event&lt;/a&gt; to try the QR code + browser camera flow yourself. No download required — that's the whole point.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>webrtc</category>
      <category>buildinginpublic</category>
    </item>
    <item>
      <title>How I Built a No-App Photo Sharing Platform Using Just QR Codes and Browser Cameras</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Tue, 11 Aug 2026 18:00:15 +0000</pubDate>
      <link>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-25i8</link>
      <guid>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-25i8</guid>
      <description>&lt;h2&gt;
  
  
  The Problem: Everyone Has a Phone, Nobody Shares Photos
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 — &lt;strong&gt;without downloading a single app&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;I called it &lt;a href="https://picshots.app" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt;. Here's how I built it, what I learned, and the technical decisions that made it work.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpugzu44s6c5bwzzfxh4f.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpugzu44s6c5bwzzfxh4f.gif" alt="QR code scanning concept" width="378" height="503"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Bet: Browser Cameras Are Good Enough
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  getUserMedia: The Gateway API
&lt;/h3&gt;

&lt;p&gt;The foundation of everything is the &lt;code&gt;MediaDevices.getUserMedia()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Here's the basic flow I started with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const stream = await navigator.mediaDevices.getUserMedia({
  video: {
    facingMode: 'environment', // Use the back camera
    width: { ideal: 1920 },
    height: { ideal: 1080 }
  }
});
videoElement.srcObject = stream;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;facingMode: 'environment'&lt;/code&gt; 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 &lt;code&gt;'environment'&lt;/code&gt; forces the rear camera — exactly what you want for event photography.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Flash Surprise
&lt;/h3&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const track = stream.getVideoTracks()[0];
const capabilities = track.getCapabilities();
if (capabilities.torch) {
  await track.applyConstraints({ advanced: [{ torch: true }] });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  QR Codes: The Zero-Friction Entry Point
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generating QR Codes Server-Side
&lt;/h3&gt;

&lt;p&gt;I used the &lt;code&gt;qrcode&lt;/code&gt; npm package (82 million monthly downloads — it's battle-tested) to generate QR codes dynamically for each event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  The QR-to-Camera Flow
&lt;/h3&gt;

&lt;p&gt;Here's the user journey I designed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Event host creates an event on &lt;a href="https://picshots.app/create" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt; and gets a unique QR code&lt;/li&gt;
&lt;li&gt;They print the QR code and place it on tables, near the dance floor, at the photo booth&lt;/li&gt;
&lt;li&gt;Guests scan the QR code with their phone's native camera app&lt;/li&gt;
&lt;li&gt;The link opens in their browser — no app store redirect, no sign-up wall&lt;/li&gt;
&lt;li&gt;The browser requests camera permission once&lt;/li&gt;
&lt;li&gt;Guests take photos that upload directly to the event gallery&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhl1c8539pvv80piswnk5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhl1c8539pvv80piswnk5.gif" alt="Building the platform" width="500" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Architecture
&lt;/h2&gt;

&lt;p&gt;Let me walk through the stack and the key decisions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frontend: Vanilla JS with a Sprinkle of Modern APIs
&lt;/h3&gt;

&lt;p&gt;I deliberately kept the frontend lightweight. No React, no Vue, no build step. Here's why:&lt;/p&gt;

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

&lt;p&gt;The entire camera page is ~12KB of minified JavaScript. It loads in under 300ms on a 3G connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Image Capture and Upload
&lt;/h3&gt;

&lt;p&gt;For actually capturing the photo, I used the &lt;code&gt;ImageCapture&lt;/code&gt; API where available, with a canvas fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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 =&amp;gt; {
    canvas.toBlob(resolve, 'image/jpeg', 0.85);
  });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ImageCapture.takePhoto()&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real-Time Gallery with Server-Sent Events
&lt;/h3&gt;

&lt;p&gt;For the live gallery — where photos appear in real-time as guests take them — I chose Server-Sent Events (SSE) over WebSockets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Server (Node.js + Express)
app.get('/api/events/:id/live', (req, res) =&amp;gt; {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });

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

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

  req.on('close', () =&amp;gt; {
    eventEmitter.off(`photo:${req.params.id}`, listener);
  });
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage: Optimizing for the Event Use Case
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hard Parts Nobody Talks About
&lt;/h2&gt;

&lt;h3&gt;
  
  
  iOS Safari and the "Not in Full Screen" Problem
&lt;/h3&gt;

&lt;p&gt;iOS Safari has a peculiar restriction: &lt;code&gt;getUserMedia&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const isInAppBrowser = 
  /FBAN|FBAV|Instagram|Twitter|Line/.test(navigator.userAgent) ||
  (navigator.standalone === false &amp;amp;&amp;amp; 
   /Safari/.test(navigator.userAgent) &amp;amp;&amp;amp;
   /iPhone/.test(navigator.userAgent) &amp;amp;&amp;amp;
   !/CriOS|FxiOS|OPiOS|mercury/.test(navigator.userAgent));
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  The "I Don't Want to Give Camera Access" Problem
&lt;/h3&gt;

&lt;p&gt;Some guests are understandably wary of granting camera permissions to a random website. I addressed this in two ways:&lt;/p&gt;

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

&lt;p&gt;This reduced the camera-permission denial rate from ~40% (in early testing) to under 10%.&lt;/p&gt;

&lt;h3&gt;
  
  
  Concurrent Uploads at Scale
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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();
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5hfij3lk0s4hfhagd2zg.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5hfij3lk0s4hfhagd2zg.gif" alt="Success and celebration" width="480" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;After running this for over a year and processing photos from thousands of events, here's what I've learned:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. WebRTC Would Have Been Overkill
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The BarcodeDetector API Is Underrated
&lt;/h3&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const detector = new BarcodeDetector({
  formats: ['qr_code']
});
const barcodes = await detector.detect(imageBitmap);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Offline Support Matters More Than I Thought
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;p&gt;After 12 months, here are the numbers:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack at a Glance
&lt;/h2&gt;

&lt;p&gt;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&lt;/p&gt;

&lt;h2&gt;
  
  
  Want to Try It?
&lt;/h2&gt;

&lt;p&gt;If you're curious about how the full flow works, check out &lt;a href="https://picshots.app/how-it-works" rel="noopener noreferrer"&gt;how Picshots works&lt;/a&gt; — I've documented the entire guest experience from QR scan to gallery view. And if you have an event coming up, you can &lt;a href="https://picshots.app/create" rel="noopener noreferrer"&gt;create your own event&lt;/a&gt; and see the no-app camera experience in action.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>webrtc</category>
      <category>qr</category>
    </item>
    <item>
      <title>Why I Made PicShots App-Free: The UX Decision That Doubled Guest Photo Uploads</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Tue, 11 Aug 2026 15:36:14 +0000</pubDate>
      <link>https://dev.to/morpheus1537/why-i-made-picshots-app-free-the-ux-decision-that-doubled-guest-photo-uploads-2dj0</link>
      <guid>https://dev.to/morpheus1537/why-i-made-picshots-app-free-the-ux-decision-that-doubled-guest-photo-uploads-2dj0</guid>
      <description>&lt;h2&gt;
  
  
  The Problem Nobody Wanted to Admit
&lt;/h2&gt;

&lt;p&gt;When I first built &lt;a href="https://[Picshots](https://picshots.app).app" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt;, I did what every founder does: I built an app. A nice one, too — smooth onboarding, clean UI, push notifications when new photos dropped. I was proud of it. I shipped it to the App Store and Play Store, set up a landing page, and waited for the wedding guests to pour in.&lt;/p&gt;

&lt;p&gt;They didn't.&lt;/p&gt;

&lt;p&gt;Here's what actually happened at the first dozen events: the host would set up their event, print the QR code cards, place them on every table — and then watch as maybe 20% of guests actually uploaded anything. The other 80%? They'd scan the QR code, see "Download PicShots on the App Store," and immediately close the tab. Some would mutter "I'll do it later" (they never did). Others would say their phone storage was full. A few just didn't want yet another app on their phone for a single evening.&lt;/p&gt;

&lt;p&gt;I had built a product that solved a real problem — capturing the candid moments your professional photographer misses — but I'd wrapped it in a distribution model that created a bigger problem than the one I was solving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Funnel of Sadness
&lt;/h2&gt;

&lt;p&gt;Let me walk you through the numbers, because they were brutal. At a 100-guest wedding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;100 guests&lt;/strong&gt; see the QR code on their table&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;~70&lt;/strong&gt; actually scan it (the other 30 are talking, dancing, or their phone is in their pocket)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;~50&lt;/strong&gt; land on the app store page&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;~30&lt;/strong&gt; start the download (the rest bail when they see it's 40MB or they need to enter their Apple ID password)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;~22&lt;/strong&gt; open the app after installing&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;~18&lt;/strong&gt; complete onboarding and actually take a photo&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Eighteen people out of a hundred. At a wedding where the couple paid for professional photography AND my service, hoping to capture the candid moments. I was losing 82% of potential contributors before they ever pressed a shutter button.&lt;/p&gt;

&lt;p&gt;And here's the thing — this wasn't a PicShots problem. This is the universal mobile app conversion funnel. Industry data consistently shows that every additional step between intent and action kills 20-30% of your users. App store redirect, download wait, install permissions, account creation — each one is a tiny guillotine.&lt;/p&gt;

&lt;p&gt;For a product whose entire value proposition was "guests take photos," I had built a system where most guests never got to the photo-taking part.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqx334ccmit09c7jd2znq.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqx334ccmit09c7jd2znq.gif" alt="web development" width="480" height="268"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Moment It Clicked
&lt;/h2&gt;

&lt;p&gt;The turning point came at my cousin's wedding in Austin. I was a guest, not the founder that night. I watched the QR code cards sit on tables, watched people scan them, watched them frown at their phones, and watched them put their phones back down.&lt;/p&gt;

&lt;p&gt;One of my aunts — the kind of person who takes 400 photos at every family gathering — scanned the code, saw the app store redirect, and said out loud: "Oh, I have to download something? Never mind."&lt;/p&gt;

&lt;p&gt;She said it like it was the most obvious thing in the world. And she was right. Why &lt;em&gt;would&lt;/em&gt; she download an app? She was at a wedding. She had a glass of wine in one hand and her phone in the other. She wanted to take a photo &lt;em&gt;right now&lt;/em&gt;, not in three minutes after navigating two app stores, waiting for a download, and creating an account.&lt;/p&gt;

&lt;p&gt;Check out &lt;a href="https://picshots.app/how-it-works" rel="noopener noreferrer"&gt;how it works&lt;/a&gt;. Check out our create your event.That night I opened my laptop in the hotel room and started researching. Could you actually build a full camera experience in a mobile browser? Could you access the camera, capture high-quality photos, handle orientation, manage uploads — all without a native app?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Rabbit Hole
&lt;/h2&gt;

&lt;p&gt;The short answer: yes, but it's not trivial.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;getUserMedia&lt;/code&gt; API has been around since 2015, but using it to build a camera that feels native is a different beast entirely. Here's what I had to solve:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Camera access and permissions.&lt;/strong&gt; Browsers require HTTPS for &lt;code&gt;getUserMedia&lt;/code&gt;, and the permission prompt is ugly. On iOS, Safari wouldn't even show the camera picker properly until iOS 14.5. I had to build a custom UI that explained &lt;em&gt;why&lt;/em&gt; we needed camera access before triggering the browser prompt, because if a user denies it once, Safari remembers forever and you're dead in the water.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Photo quality.&lt;/strong&gt; The default &lt;code&gt;getUserMedia&lt;/code&gt; stream gives you a video feed, not a photo. To capture a still image at full sensor resolution, you need to use the &lt;code&gt;ImageCapture&lt;/code&gt; API — which, as of 2023, was only supported in Chromium-based browsers. For Safari and Firefox, I had to fall back to drawing video frames onto a canvas and exporting them. The quality difference was noticeable, and I spent weeks tuning the canvas export settings to get something that didn't look like a 2007 flip phone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orientation hell.&lt;/strong&gt; Mobile browsers report orientation differently than native camera apps. Photos would come in sideways, upside down, or with EXIF rotation tags that different browsers interpreted differently. I wrote and rewrote the orientation correction logic four times before it worked reliably across iOS Safari, Chrome Android, and Samsung Internet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upload reliability on spotty venue WiFi.&lt;/strong&gt; Wedding venues are notorious for bad internet. I built a retry queue with exponential backoff, chunked uploads for large files, and a service worker that could hold photos offline and upload them when connectivity returned. This alone took three weeks of testing across different venue WiFi scenarios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;QR code scanning without a native app.&lt;/strong&gt; This was the biggest unlock. The &lt;code&gt;BarcodeDetector&lt;/code&gt; API is available in Chrome and Edge, but not Safari. For iOS users, I integrated &lt;code&gt;html5-qrcode&lt;/code&gt; (5 million monthly npm downloads, battle-tested) as a polyfill. The combined approach meant guests could scan a QR code from &lt;em&gt;within&lt;/em&gt; the web app to join an event — no camera app → QR scanner → browser → app flow. Just point and join.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Web-Only Prototype
&lt;/h2&gt;

&lt;p&gt;I shipped the web-only version as an experiment. I didn't even tell most users — I just stopped redirecting new event links to the app store and pointed them at the browser experience instead.&lt;/p&gt;

&lt;p&gt;The results were immediate and uncomfortable.&lt;/p&gt;

&lt;p&gt;Guest upload rates jumped from ~18% to ~35% in the first week. That's nearly double, and I hadn't changed anything about the product itself — just removed the app download step.&lt;/p&gt;

&lt;p&gt;But the feedback was mixed. Some users loved it. Others complained that the web version felt "less premium" than the app. A few Android users on older phones had camera performance issues. The web experience wasn't as polished as the native app, and it showed.&lt;/p&gt;

&lt;p&gt;I spent the next two months in a weird limbo: running both the app and the web version, watching the web version consistently outperform the app on guest uploads, but feeling like I was maintaining two separate products. The codebase was a mess. The app had features the web version didn't. The web version had better conversion but worse reviews.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk4b4fq90ljqqaj92dxzs.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk4b4fq90ljqqaj92dxzs.gif" alt="web development GIF" width="500" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hard Decision
&lt;/h2&gt;

&lt;p&gt;Killing the native app was not an easy call. I had spent six months building it. It had App Store reviews (good ones!). It had users who preferred it. It felt like a "real" product in a way that a web app didn't.&lt;/p&gt;

&lt;p&gt;But the data was screaming at me. Here's what the A/B test over 200+ events showed:&lt;/p&gt;

&lt;p&gt;MetricNative App FlowWeb-Only Flow&lt;/p&gt;

&lt;p&gt;QR scan → first photo18%47%&lt;br&gt;
Avg photos per guest3.25.8&lt;br&gt;
Events with 0 guest uploads14%2%&lt;br&gt;
Guest return rate (next event)8%22%&lt;br&gt;
Support tickets (can't upload)12/week3/week&lt;/p&gt;

&lt;p&gt;The web-only flow more than doubled guest photo uploads. Not 20% better, not 50% better — &lt;strong&gt;2.6x&lt;/strong&gt; better. The "zero upload" events — the nightmare scenario where a host pays for the service and gets nothing — dropped from 14% to 2%.&lt;/p&gt;

&lt;p&gt;I killed the native app in March 2024. De-listed from both app stores. Redirected all existing app users to the web experience with a migration flow that preserved their event history. Sent an email to every host explaining the change.&lt;/p&gt;

&lt;p&gt;You might also like our &lt;a href="https://picshots.app/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;. You might also find our create your event useful.I lost about 15% of my existing power users who were genuinely attached to the native app. That stung. But within six weeks, the overall upload volume had grown enough to more than compensate, and the product was simpler to maintain, simpler to onboard, and — most importantly — simpler for guests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned (The Hard Way)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Your user isn't your customer.&lt;/strong&gt; For PicShots, the customer is the host — the person paying for the event. But the &lt;em&gt;user&lt;/em&gt; is the guest, and the guest's experience determines whether the host gets value. I had optimized the host experience (nice dashboard, easy setup) while making the guest experience worse (download this app). That was backwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Every step is a tax.&lt;/strong&gt; I used to think "it's just an app download, everyone has apps." But at a wedding, "just an app download" means: find the App Store, search or tap the link, wait for it to load, tap Get, authenticate with Face ID or password, wait for download, find the app on your home screen, open it, create an account, grant camera permissions, and &lt;em&gt;then&lt;/em&gt; take a photo. That's 10+ steps. The web version is: scan QR code, enter your name, take a photo. Three steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The web platform is absurdly capable now.&lt;/strong&gt; Service workers, the Camera API, the BarcodeDetector API, IndexedDB for offline storage, the File API for uploads — you can build a genuinely native-quality camera experience in the browser. The gap between "native app" and "web app" has shrunk to the point where, for most use cases, the web wins on distribution and the native app wins on... nothing that matters for a disposable camera.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. "Building in public" means admitting you were wrong.&lt;/strong&gt; The hardest part of writing this isn't the technical details — it's admitting that I spent six months and a significant chunk of my savings building something that was fundamentally the wrong approach. But that's the whole point of building in public. If I only shared the wins, I'd be doing a disservice to every other founder who's currently polishing an app nobody wants to download.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Simplicity compounds.&lt;/strong&gt; Removing the app didn't just improve upload rates — it simplified everything downstream. Fewer support tickets about installation issues. Faster onboarding for new events. Easier A/B testing (one codebase instead of two). Lower hosting costs (no app store review cycles, no native build pipeline). The decision to go app-free created a cascade of secondary benefits I hadn't anticipated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers Today
&lt;/h2&gt;

&lt;p&gt;As of mid-2026, PicShots processes photos from thousands of events per month. The average event sees 47% of guests contribute at least one photo — up from 18% in the native app days. The average guest takes 5-6 photos. Events with zero uploads are below 2%.&lt;/p&gt;

&lt;p&gt;More importantly, the product feels &lt;em&gt;right&lt;/em&gt; now. The experience matches the promise: you put a QR code on a table, guests scan it, and photos appear. No asterisks. No "download required." No friction between the moment of intent and the moment of action.&lt;/p&gt;

&lt;p&gt;I'm not saying native apps are dead. For products that need deep OS integration, offline-first experiences, or heavy computation, native still wins. But for anything that lives at the intersection of "real-world event" and "digital participation," the app-free approach isn't just a nice-to-have — it's the difference between a product people use and a product people almost use.&lt;/p&gt;

&lt;p&gt;If you're building something that requires other people (not just your direct users) to participate, ask yourself: how many steps are between them and the value? Every step is a tax. And taxes, as we all know, suppress activity.&lt;/p&gt;

&lt;p&gt;Sometimes the best feature is the one you remove.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm building PicShots in public. Follow along at &lt;a href="https://picshots.app" rel="noopener noreferrer"&gt;picshots.app&lt;/a&gt; or find me on Twitter/X where I share the messy, unpolished reality of building an event tech startup. No corporate blog posts, no PR-filtered success stories — just what actually happens when you try to build something people want.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How I Built a No-App Photo Sharing Platform Using Just QR Codes and Browser Cameras</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Tue, 11 Aug 2026 15:32:41 +0000</pubDate>
      <link>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-17kh</link>
      <guid>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-17kh</guid>
      <description>&lt;h2&gt;
  
  
  The Problem (&lt;a href="https://[Picshots](https://picshots.app).app" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt;)
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The stack is deliberately boring — I wanted this to work reliably, not impress Hacker News:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Frontend:&lt;/strong&gt; Vanilla HTML/CSS/JS with the &lt;code&gt;html5-qrcode&lt;/code&gt; library for scanning and the MediaDevices API for camera access&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Backend:&lt;/strong&gt; Node.js with Express, handling file uploads via Multer&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Storage:&lt;/strong&gt; Local filesystem with a cron job to purge photos after 30 days&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;QR Generation:&lt;/strong&gt; The &lt;code&gt;qrcode&lt;/code&gt; npm package, generating codes server-side&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deployment:&lt;/strong&gt; A $6/month DigitalOcean droplet running behind Nginx with Let's Encrypt SSL&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  QR Code Implementation
&lt;/h2&gt;

&lt;p&gt;Generating QR codes is the easy part. The &lt;code&gt;qrcode&lt;/code&gt; package handles everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qrcode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateEventQR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://snapshare.app/e/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qrDataUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#1a1a2e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;light&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;errorCorrectionLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;H&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="c1"&gt;// 30% damage recovery&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;qrDataUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things I learned the hard way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error correction level matters.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL length vs. QR density.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic vs. static QR codes.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcbweowqmbl2aem0fh9w.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcbweowqmbl2aem0fh9w.gif" alt="Tech Coding GIF by Capgemini India" width="480" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser Camera Integration
&lt;/h2&gt;

&lt;p&gt;This is where things got interesting. The MediaDevices API (&lt;code&gt;getUserMedia&lt;/code&gt;) is powerful but temperamental across browsers and devices.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Basic Setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;initCamera&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;constraints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;facingMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;environment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// back camera&lt;/span&gt;
      &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fallback: some browsers don't support facingMode&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fallbackStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fallbackStream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Real-World Problems
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;iOS Safari and the "green dot" paranoia.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android Chrome and autofocus.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;capturePhoto&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getVideoTracks&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;capabilities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCapabilities&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;focusMode&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;single-shot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyConstraints&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;advanced&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;focusMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;single-shot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Small delay for focus to settle&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="nx"&gt;canvasContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;canvasToBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;uploadPhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The orientation nightmare.&lt;/strong&gt; Photos taken in portrait mode would appear rotated when displayed. The EXIF orientation data from &lt;code&gt;getUserMedia&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Check out &lt;a href="https://picshots.app/explore" rel="noopener noreferrer"&gt;explore events&lt;/a&gt;. Check out our create your event.### QR Code Scanning (The Other Direction)&lt;/p&gt;

&lt;p&gt;For the host to scan guest-submitted QR codes (for moderation or linking), I used &lt;code&gt;html5-qrcode&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scanner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Html5Qrcode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reader&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;scanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;facingMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;environment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;qrbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decodedText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decodedText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;scanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errorMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Ignore — scanning is continuous&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: set &lt;code&gt;fps: 10&lt;/code&gt; not 30. Higher FPS burns CPU and battery for no benefit — QR codes don't move.&lt;/p&gt;

&lt;h2&gt;
  
  
  The No-App Philosophy
&lt;/h2&gt;

&lt;p&gt;The "no-app" part isn't just a feature — it's the entire product philosophy. Here's what I mean:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No app store friction.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No account creation.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No data retention anxiety.&lt;/strong&gt; 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."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progressive Web App as the sweet spot.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Backend: Keep It Dumb
&lt;/h2&gt;

&lt;p&gt;The backend does exactly three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate QR codes for new events&lt;/li&gt;
&lt;li&gt;Accept photo uploads via multipart form data&lt;/li&gt;
&lt;li&gt;Serve photos with short-lived signed URLs
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Event creation endpoint&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nanoid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;guest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; 
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expiresIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;7d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://snapshare.app/e/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?t=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qrCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; 
    &lt;span class="na"&gt;errorCorrectionLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;H&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; 
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; 
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;qrCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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).&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenges That Almost Broke Me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. iOS WebRTC permissions are per-session.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Large photo uploads on slow connections.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compressImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxWidth&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This brought uploads down to 200-500KB with negligible quality loss for social sharing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The "someone took a photo of the floor" problem.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcd7ov3ekvyzmhaht44v1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcd7ov3ekvyzmhaht44v1.gif" alt="Hacker Coding GIF by Hack Club" width="480" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use S3 from day one.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a loading skeleton immediately.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;You might also like our &lt;a href="https://picshots.app/for/weddings" rel="noopener noreferrer"&gt;wedding guest camera&lt;/a&gt;. You might also find our create your event useful.&lt;strong&gt;Test on older devices earlier.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;p&gt;Over three months and 15 events (weddings, birthday parties, corporate gatherings), the platform handled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;4,200+ photos uploaded&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;800+ unique guests (zero app installs)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Average session time: 2 minutes 40 seconds&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Photo upload success rate: 94% (the 6% failures were almost entirely network timeouts on poor connections)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Code
&lt;/h2&gt;

&lt;p&gt;The full project is open source. The key libraries used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;qrcode&lt;/code&gt; — QR code generation (82M+ monthly downloads on npm)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;html5-qrcode&lt;/code&gt; — Browser-based QR scanning (5M+ monthly downloads)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;express&lt;/code&gt; + &lt;code&gt;multer&lt;/code&gt; — Backend and file handling&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;jsonwebtoken&lt;/code&gt; — Guest authentication tokens&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Vanilla JS — No framework, deliberately&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;QR codes are underrated infrastructure.&lt;/strong&gt; They're free, universally supported, and bridge the physical-digital gap better than anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The browser camera API is production-ready&lt;/strong&gt; — if you handle the edge cases. Test on real devices, not just Chrome DevTools device emulation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"No app" is a superpower.&lt;/strong&gt; The conversion rate from "sees QR code" to "uploads photo" was over 80%. Compare that to app install funnels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compress on the client, store on the server, purge on a schedule.&lt;/strong&gt; This simple data flow eliminated 90% of the complexity I initially over-engineered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build for the least technical user.&lt;/strong&gt; If a 78-year-old grandmother can use it, your UX is right.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Have you built something with QR codes or the browser camera API? I'd love to hear about your experience in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
