<?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>Browser Camera APIs: Replacing a Native App</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Thu, 08 Oct 2026 09:12:46 +0000</pubDate>
      <link>https://dev.to/morpheus1537/browser-camera-apis-replacing-a-native-app-4nl6</link>
      <guid>https://dev.to/morpheus1537/browser-camera-apis-replacing-a-native-app-4nl6</guid>
      <description>&lt;h1&gt;
  
  
  How I Used Browser Camera APIs to Replace a Native App (And Why It Worked)
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick Answer:&lt;/strong&gt; I swapped a native camera workflow for a web version built on &lt;code&gt;navigator.mediaDevices.getUserMedia&lt;/code&gt; and a thin WebRTC layer for streaming/recording. It worked because the real requirements were permission UX, latency, and reliability—not “native UI.” Once those were engineered (with Safari-aware fallbacks), adoption improved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;If you’ve ever maintained both a native app and a web app for “camera features,” you already know the uncomfortable truth: the UI can be different, but the hard parts are similar. Permissions. Device selection. Stream lifecycle. Latency. And those weird “works on my phone” camera failures.&lt;/p&gt;

&lt;p&gt;In my case, we had a native app that handled camera capture (preview + snapshots), plus a “share/stream” step. Users were fine—until updates, store friction, and device-specific bugs started costing us time. I wanted to replace the native app with something that could ship instantly, scale across devices, and still feel trustworthy.&lt;/p&gt;

&lt;p&gt;So I used Browser Camera APIs—primarily &lt;strong&gt;getUserMedia&lt;/strong&gt;—and complemented it with &lt;strong&gt;WebRTC&lt;/strong&gt; where it actually mattered. This post is the technical deep dive (and the “why it worked” story) you can apply to your own camera web app.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does “Browser Camera APIs” actually include?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Quick Answer:&lt;/strong&gt; In practice, “Browser Camera APIs” usually means the Media Capture stack: &lt;code&gt;navigator.mediaDevices.getUserMedia()&lt;/code&gt;, &lt;code&gt;MediaStream&lt;/code&gt;, optional helpers like &lt;code&gt;ImageCapture&lt;/code&gt;, and then WebRTC components (&lt;code&gt;RTCPeerConnection&lt;/code&gt;, tracks, and &lt;code&gt;getStats&lt;/code&gt;) when you need streaming or remote sessions.&lt;/p&gt;

&lt;p&gt;Here’s the mental model I used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Capture/Preview:&lt;/strong&gt; &lt;code&gt;getUserMedia&lt;/code&gt; → returns a &lt;code&gt;MediaStream&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Device selection:&lt;/strong&gt; constraints (e.g., &lt;code&gt;facingMode&lt;/code&gt; or &lt;code&gt;deviceId&lt;/code&gt;), plus &lt;code&gt;enumerateDevices&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing:&lt;/strong&gt; you can draw to &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;, or use platform APIs when available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share/Stream:&lt;/strong&gt; WebRTC transports media efficiently across browsers.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why did browser camera APIs beat a native app?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Quick Answer:&lt;/strong&gt; Because the native app’s advantage wasn’t “camera access.” It was reliability: permission flows, preview smoothness, and consistent handoffs to share/stream. Once we engineered those in the browser (including Safari-specific behavior), the web version matched the native outcome.&lt;/p&gt;

&lt;p&gt;We didn’t win by trying to copy native UI pixel-for-pixel. We won by targeting the constraints that actually determine success:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Permission UX:&lt;/strong&gt; Users hate unclear prompts. We treated permission as part of onboarding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device robustness:&lt;/strong&gt; Phones, tablets, and desktops have different device naming and constraint support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stream lifecycle:&lt;/strong&gt; Cameras must stop instantly when users leave. Leaks feel broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency and handoff:&lt;/strong&gt; If sharing/streaming is part of the job, WebRTC’s track model is a good fit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That’s the key shift: Browser Camera APIs aren’t a “replacement checkbox.” They’re a different architecture where UX and lifecycle engineering matter more than platform SDK familiarity.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I implemented the camera preview with getUserMedia
&lt;/h2&gt;

&lt;p&gt;Let’s talk implementation. The core flow looks simple, but the devil is in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;constraints,&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;timing (requesting permission too early),&lt;/li&gt;
&lt;li&gt;error handling, and&lt;/li&gt;
&lt;li&gt;device switching.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  1) Build the constraints with “progressive intent”
&lt;/h3&gt;

&lt;p&gt;Start with the smallest set of requirements, then refine based on what the device supports.&lt;/p&gt;

&lt;p&gt;For example, if you primarily want the front camera on mobile, you can start with a &lt;code&gt;facingMode&lt;/code&gt; hint.&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;// Example (adapt for your needs)&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;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="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="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="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="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;1280&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;720&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="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;videoEl&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;videoEl&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;Why this approach worked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;It degrades gracefully:&lt;/strong&gt; If constraints aren’t satisfied exactly, the browser still returns a workable stream.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It reduces “hard failure”:&lt;/strong&gt; Over-constraining is the fastest way to create “camera won’t start” reports.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2) Handle permission states explicitly
&lt;/h3&gt;

&lt;p&gt;Don’t assume users will grant permission. I built a small state machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Not asked&lt;/strong&gt; → show “Enable camera” CTA&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompted&lt;/strong&gt; → show loading + “you may see a browser prompt”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granted&lt;/strong&gt; → start preview and let them switch cameras&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Denied/Blocked&lt;/strong&gt; → show a help card (what to change in browser settings)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: In many browsers, you can’t fully control prompts—you can only guide users. So make the “next step” obvious.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Stop streams deterministically
&lt;/h3&gt;

&lt;p&gt;This sounds basic, but it’s where many camera apps feel unreliable.&lt;/p&gt;

&lt;p&gt;Always stop tracks when the user navigates away or when you replace the stream (e.g., switching cameras):&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;stopStream&lt;/span&gt;&lt;span class="p"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&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;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &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="k"&gt;of&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;getTracks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also added a “busy” lock to prevent rapid toggles from creating overlapping streams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching cameras and device selection (the part users notice)
&lt;/h2&gt;

&lt;p&gt;After preview was stable, the next requirement was switching between front/back cameras. The cross-browser reality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Some platforms prefer &lt;code&gt;facingMode&lt;/code&gt;&lt;/strong&gt; over device IDs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Some require &lt;code&gt;deviceId&lt;/code&gt;&lt;/strong&gt; after enumeration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device labels are often blank&lt;/strong&gt; until permission is granted.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Use enumerateDevices after permission
&lt;/h3&gt;

&lt;p&gt;I used this pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request permission with a “best effort” constraint&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;navigator.mediaDevices.enumerateDevices()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Populate a device picker with the labels (when available)&lt;/li&gt;
&lt;li&gt;Switch using &lt;code&gt;deviceId&lt;/code&gt; constraints if needed
&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;devices&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;enumerateDevices&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;videoInputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;videoinput&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Safari compatibility notes (what I learned)
&lt;/h3&gt;

&lt;p&gt;Safari supported the core &lt;code&gt;getUserMedia&lt;/code&gt; workflow, but we had to be intentional about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;User gesture requirements:&lt;/strong&gt; In iOS Safari, starting video and requesting camera access can be sensitive to whether it’s triggered by a direct user action (button click).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Constraint behavior:&lt;/strong&gt; &lt;code&gt;facingMode&lt;/code&gt; and resolution hints sometimes don’t behave identically to Chromium-based browsers. Favor &lt;em&gt;ideal&lt;/em&gt; values over hard constraints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stream timing:&lt;/strong&gt; We delayed the “switch camera” button until the first stream successfully started and video dimensions were known.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical takeaway: treat Safari as “works reliably with user-triggered actions + conservative constraints,” rather than as “the same as Chrome with a different engine.”&lt;/p&gt;

&lt;h2&gt;
  
  
  When WebRTC is worth it (and when it isn’t)
&lt;/h2&gt;

&lt;p&gt;This is where many teams accidentally overbuild. If your product only needs local preview and snapshots, you may not need WebRTC at all. But if you need streaming to another user, a remote session, or reliable transport for live video, WebRTC earns its keep.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defining the job for WebRTC
&lt;/h3&gt;

&lt;p&gt;In our replacement, WebRTC handled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Creating a peer connection&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Attaching the camera track(s)&lt;/li&gt;
&lt;li&gt;Exchanging offer/answer (signaling via our backend)&lt;/li&gt;
&lt;li&gt;Monitoring connection health using &lt;code&gt;RTCPeerConnection.getStats()&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We didn’t “stream everything.” We streamed only when the user requested sharing/remote viewing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the track model maps to product UX
&lt;/h3&gt;

&lt;p&gt;One reason it worked: WebRTC’s media model aligns well with how users think about camera sessions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Preview:&lt;/strong&gt; local &lt;code&gt;MediaStream&lt;/code&gt; into &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Share/Send:&lt;/strong&gt; reuse the same tracks into WebRTC sender(s)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop:&lt;/strong&gt; stop tracks and close connections&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That reuse reduced bugs compared to building parallel pipelines for “preview” and “stream.”&lt;/p&gt;

&lt;h3&gt;
  
  
  A compatibility strategy for WebRTC
&lt;/h3&gt;

&lt;p&gt;Different browsers support different codecs and quirks, but the reliable approach is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start with browser-default transceiver behavior unless you have a specific codec requirement.&lt;/li&gt;
&lt;li&gt;Log &lt;code&gt;getStats()&lt;/code&gt; once the connection is established.&lt;/li&gt;
&lt;li&gt;Set conservative fallbacks: reduce resolution/bitrate when network conditions degrade.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We also added a “reconnect UX” instead of a hard failure message. Users would retry, and retry often succeeded because the camera stream was already proven.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built-in “reliability features” that made the web app feel native
&lt;/h2&gt;

&lt;p&gt;Here are the engineering details that mattered more than any single API call.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) A “camera session” controller
&lt;/h3&gt;

&lt;p&gt;We wrapped camera access behind a single controller object:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Requests permission&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Starts preview&lt;/li&gt;
&lt;li&gt;Supports switching cameras&lt;/li&gt;
&lt;li&gt;Stops tracks on demand&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This prevented scattered &lt;code&gt;getUserMedia&lt;/code&gt; calls across UI components—a common cause of “works sometimes” bugs.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Meaningful error messages (not raw exceptions)
&lt;/h3&gt;

&lt;p&gt;Exceptions can be cryptic. I normalized errors into product language:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Permission denied&lt;/strong&gt; → “Camera access is off. Enable it in your browser settings.”&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No camera found&lt;/strong&gt; → “No video input detected on this device.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device busy&lt;/strong&gt; → “Another app is using the camera.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constraint unsatisfied&lt;/strong&gt; → “Try a different resolution or camera.”&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That reduced support load because users could self-correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Network-aware decisions for WebRTC
&lt;/h3&gt;

&lt;p&gt;We built a simple principle: if preview is local, latency doesn’t matter much; if you’re streaming, it does. So we adjusted video settings when the connection struggled rather than pretending everything was fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Communication: “you’re still in control” UX
&lt;/h3&gt;

&lt;p&gt;Users interpret camera apps as “active capture.” So we made state visible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Show a clear “Camera is on” indicator&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Show when switching cameras&lt;/li&gt;
&lt;li&gt;Confirm when the camera is off&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Performance: how we kept preview smooth
&lt;/h2&gt;

&lt;p&gt;Camera preview performance is a mix of browser behavior and how you render the stream.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Prefer direct rendering:&lt;/strong&gt; Use &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; with &lt;code&gt;playsinline&lt;/code&gt; when possible.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Be careful with heavy processing:&lt;/strong&gt; If you run computer-vision or frame-by-frame filters, throttle work (e.g., process every Nth frame).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle orientation changes:&lt;/strong&gt; Mobile rotations can cause dimension recalculations and sometimes transient layout jank.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re also doing snapshot capture, I recommend a separate “capture” action rather than capturing continuously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser compatibility checklist (so you don’t relive the same bugs)
&lt;/h2&gt;

&lt;p&gt;Here’s a checklist I used to ship confidently across devices.&lt;/p&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Area&lt;br&gt;
  What to test&lt;br&gt;
  Why it matters

&lt;p&gt;Permissions&lt;br&gt;
  First load, reload, denied state, “blocked” state&lt;br&gt;
  Users blame “camera not working,” even when the root is UI/UX.&lt;/p&gt;

&lt;p&gt;Constraints&lt;br&gt;
  &lt;code&gt;facingMode&lt;/code&gt;, resolution hints, device ID switching&lt;br&gt;
  Over-constraining causes hard failures on some browsers.&lt;/p&gt;

&lt;p&gt;Lifecycle&lt;br&gt;
  Navigate away, switch tabs, start/stop rapidly&lt;br&gt;
  Leaked streams look like security bugs.&lt;/p&gt;

&lt;p&gt;WebRTC (if used)&lt;br&gt;
  Offer/answer flow, reconnect, &lt;code&gt;getStats&lt;/code&gt; sanity&lt;br&gt;
  Network changes are inevitable; your UX must handle them.&lt;/p&gt;

&lt;p&gt;Safari/iOS&lt;br&gt;
  User-gesture triggers, autoplay behavior, camera start timing&lt;br&gt;
  Safari is reliable—when you align with its expectations.&lt;br&gt;
&lt;/p&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  Cost, effort, and alternatives (what we considered)&lt;br&gt;
&lt;/h2&gt;

&lt;p&gt;Replacing a native app isn’t free, even when the tech is. The “cost” is mostly engineering time for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Permission UX + settings help&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Device switching robustness&lt;/li&gt;
&lt;li&gt;Safari-specific behavior&lt;/li&gt;
&lt;li&gt;WebRTC signaling + monitoring (if you stream)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Alternatives we considered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Web-only without WebRTC:&lt;/strong&gt; Great for preview and snapshots, not for remote sessions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid wrappers (e.g., WebView-based apps):&lt;/strong&gt; Can reduce app store friction, but you inherit WebView camera quirks and review processes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native-first with focused APIs:&lt;/strong&gt; Still valid if you need deep device integration or specialized capture pipelines.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ultimately, our use case matched the strengths of Browser Camera APIs: “camera + user workflow + optional share.”&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Design for lifecycle and permissions&lt;/strong&gt; first—&lt;code&gt;getUserMedia&lt;/code&gt; is the foundation, but UX determines success.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use conservative constraints&lt;/strong&gt; (prefer &lt;em&gt;ideal&lt;/em&gt; values) to avoid hard failures across browsers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari works well&lt;/strong&gt; when you trigger camera access from real user gestures and account for constraint differences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add WebRTC only when streaming matters&lt;/strong&gt;—it pairs naturally with the track model of camera streams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make state visible&lt;/strong&gt; (camera on/off, switching, reconnect) to build trust fast.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is getUserMedia enough to replace a camera native app?
&lt;/h3&gt;

&lt;p&gt;You can often build a great replacement for preview, snapshots, and basic capture using &lt;code&gt;navigator.mediaDevices.getUserMedia&lt;/code&gt;. If your app includes remote viewing or live sharing, add WebRTC. The browser can match native behavior when lifecycle and permission UX are treated as core features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do camera constraints fail on some devices?
&lt;/h3&gt;

&lt;p&gt;Browser cameras vary in supported resolutions, facing modes, and constraint interpretation. If you hard-require a specific resolution or format, the browser may reject the constraint set. Prefer “ideal” constraints, and add fallbacks based on runtime behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  What’s the biggest Safari gotcha for camera web apps?
&lt;/h3&gt;

&lt;p&gt;In iOS Safari, camera access and playback can be sensitive to whether calls happen from a direct user gesture. Also expect constraint behavior differences compared to Chromium. The fix is usually timing + conservative constraints, not a different API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need WebRTC if I only take photos and video locally?
&lt;/h3&gt;

&lt;p&gt;No. For local capture (preview, snapshots, and storing media locally), WebRTC is unnecessary. Use WebRTC only when you need real-time transport to another peer or multi-user session. That keeps the solution smaller and reduces compatibility surface.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can I switch between front and rear cameras reliably?
&lt;/h3&gt;

&lt;p&gt;Use a two-stage approach: start with a best-effort &lt;code&gt;facingMode&lt;/code&gt; hint for initial preview, then enumerate devices after permission to get stable &lt;code&gt;deviceId&lt;/code&gt; options when needed. Always stop the previous stream before starting the new one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should I do when users deny camera permission?
&lt;/h3&gt;

&lt;p&gt;Don’t show a generic error. Explain what’s needed and how to enable camera access in the browser settings. Also include a “continue without camera” mode if your product allows it. Users are more forgiving when they understand the next action.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I debug camera issues in production?
&lt;/h3&gt;

&lt;p&gt;Capture structured logs: whether permission was granted, the selected constraints, stream start success, and error codes/messages. For WebRTC sessions, log &lt;code&gt;getStats()&lt;/code&gt; snapshots (codec, bitrate, frames, connection state). Then correlate with device/browser versions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I optimize for video quality or low latency first?
&lt;/h3&gt;

&lt;p&gt;Start by prioritizing reliability and perceived responsiveness: stable preview, correct camera selection, and predictable stop behavior. If you stream, then tune latency/bitrate based on real &lt;code&gt;getStats()&lt;/code&gt; signals. Optimizing quality without monitoring often causes “works on Wi‑Fi” surprises.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where can I learn production patterns beyond this article?
&lt;/h3&gt;

&lt;p&gt;For additional implementation details, see &lt;strong&gt;[Internal link: Production checklist for WebRTC signaling + getStats monitoring]&lt;/strong&gt; and &lt;strong&gt;[Internal link: Handling permission UX for camera + microphone across browsers]&lt;/strong&gt;. Those cover the “operational” side—logging, reconnect, and user-safe fallbacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Replacing a native camera app with Browser Camera APIs worked because we treated the browser like a platform you can engineer—not a “less capable” alternative. &lt;code&gt;getUserMedia&lt;/code&gt; gave us capture, WebRTC gave us real-time transport when we needed it, and careful permission/device lifecycle work made the result feel dependable.&lt;/p&gt;

&lt;p&gt;If you’re planning a similar migration, my practical recommendation is to start with a camera session controller, build Safari-aware permission UX, and only add WebRTC when streaming is truly part of the product. If you want help auditing constraints, error states, and streaming behavior, we can work with your team on a production-grade rollout.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Bonus:&lt;/em&gt; for “building in public” documentation, I even used GIFs to explain camera states—if you’re looking for short animated UI feedback assets, I pulled troubleshooting clips from &lt;a href="https://giphy.com" rel="noopener noreferrer"&gt;GIPHY&lt;/a&gt; (and referenced their &lt;a href="https://giphy.com/sitemaps/gifs.xml" rel="noopener noreferrer"&gt;GIPHY GIFs sitemap&lt;/a&gt; and &lt;a href="https://giphy.com/sitemaps/categories.xml" rel="noopener noreferrer"&gt;categories sitemap&lt;/a&gt; when assembling the deck).&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>webrtc</category>
      <category>getusermedia</category>
      <category>mobileweb</category>
    </item>
    <item>
      <title>No-App Photo Sharing with QR Codes &amp; Browser Cameras</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Thu, 08 Oct 2026 09:11:08 +0000</pubDate>
      <link>https://dev.to/morpheus1537/no-app-photo-sharing-with-qr-codes-browser-cameras-5b8f</link>
      <guid>https://dev.to/morpheus1537/no-app-photo-sharing-with-qr-codes-browser-cameras-5b8f</guid>
      <description>&lt;h1&gt;
  
  
  How I Built a No-App Photo Sharing Platform Using QR Codes and Browser Cameras
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick Answer:&lt;/strong&gt; A no-app photo sharing platform can work by combining QR codes (to route people to a session) with browser camera capture using &lt;code&gt;getUserMedia&lt;/code&gt;, then sharing the captured image in real time via &lt;strong&gt;WebRTC&lt;/strong&gt; (or a server fallback). Users never install an app—just scan, snap, and view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;You’ve probably seen it before: someone wants to share photos at an event, but the “download the app / create an account / upload photos” steps kill the vibe. So I went looking for a simpler model: &lt;strong&gt;no app&lt;/strong&gt;, minimal friction, and a flow that feels instant.&lt;/p&gt;

&lt;p&gt;In this post, I’ll walk you through how I built a &lt;strong&gt;no-app photo sharing platform using QR codes and browser cameras&lt;/strong&gt;. The design goal was straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Participants&lt;/strong&gt; scan a QR code&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The browser requests camera permission using &lt;code&gt;navigator.mediaDevices.getUserMedia&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The photo is captured&lt;/li&gt;
&lt;li&gt;It’s delivered to a shared viewer in real time (using WebRTC when possible)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is building-in-public, so I’ll also cover what I got wrong, what I kept, and what you can implement today with web platform basics.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a no-app photo sharing platform?
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;no-app photo sharing platform&lt;/strong&gt; is a web-based system where users can capture and share photos without installing any mobile application. Instead, you rely on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;QR codes&lt;/strong&gt; to connect a user to a specific event/session&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Browser camera access&lt;/strong&gt; through &lt;code&gt;getUserMedia&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time delivery&lt;/strong&gt; to a viewer via WebRTC (peer-to-peer) or a server fallback&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On top of that, you can apply a &lt;em&gt;no-download&lt;/em&gt; style approach: don’t pretend you can fully “block saving” (you can’t), but you can make casual downloading harder, reduce persistence, and discourage misuse with ephemeral tokens, short-lived sessions, and overlays/watermarks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbpqqai5gqi4fow8om981.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbpqqai5gqi4fow8om981.gif" alt="QR code scanning animation"&gt;&lt;/a&gt;&lt;br&gt;
  QR codes are the fastest way to move users into a session without installs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why QR codes + browser cameras work better than app onboarding?
&lt;/h2&gt;

&lt;p&gt;Apps optimize for features. But for “share a photo now,” the dominant factor is &lt;strong&gt;time-to-first-photo&lt;/strong&gt;. QR codes compress onboarding into a single action: scan → permission → capture.&lt;/p&gt;
&lt;h3&gt;
  
  
  QR codes as a routing mechanism
&lt;/h3&gt;

&lt;p&gt;Each QR code encodes a URL like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-domain.com/s/&amp;lt;sessionId&amp;gt;?t=&amp;lt;token&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the user lands on that page, the app creates (or joins) a “capture session.” The token can be ephemeral, rate-limited, and bound to a session to prevent random sharing.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebRTC for delivery, getUserMedia for capture
&lt;/h3&gt;

&lt;p&gt;Two core browser APIs make this experience possible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;getUserMedia&lt;/code&gt;&lt;/strong&gt; requests access to camera/microphone streams.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebRTC&lt;/strong&gt; moves data between browsers using &lt;code&gt;RTCPeerConnection&lt;/code&gt; (commonly with signaling via your server).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the “snap a photo and show it to others” moment, a common approach is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use &lt;code&gt;getUserMedia&lt;/code&gt; to stream the camera to a hidden &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Copy the current frame to a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Send the captured image bytes to the viewer using WebRTC DataChannels (or fallback upload)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If WebRTC fails (corporate networks, strict NAT behavior, old browsers), you can gracefully fall back to a server upload endpoint that stores images briefly and broadcasts them to watchers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing the system: a simple three-step architecture
&lt;/h2&gt;

&lt;p&gt;When I reduced the problem to a small set of states, everything got easier. Here’s the model that worked:&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Entry (QR → session page)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;The user scans a QR code&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
They authenticate lightly (via a short-lived token)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2) Capture (browser camera → image frame)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Request camera permission via &lt;code&gt;getUserMedia&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Show a preview + capture button&lt;/li&gt;
&lt;li&gt;When captured, draw to canvas and encode as JPEG/PNG&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3) Share (WebRTC viewer → live gallery)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;A viewer page subscribes to the session&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We establish a WebRTC connection (when possible)&lt;/li&gt;
&lt;li&gt;Captured images appear instantly in the gallery&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; the capture page shouldn’t need accounts. The viewer page can optionally have a “host mode” for moderation or watermark toggles.&lt;/p&gt;

&lt;h2&gt;
  
  
  QR code generation (and the error correction you’ll wish you set)
&lt;/h2&gt;

&lt;p&gt;QR code generation is easy—until your QR becomes unreadable under glare or low-resolution screens. This is where error correction level matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I implemented
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Version/size choice&lt;/strong&gt; based on how much URL text is encoded&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Error correction&lt;/strong&gt; set high enough for real-world scanning&lt;/li&gt;
&lt;li&gt;Quiet zone preserved (white margin around the code)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Keep QR payloads short and stable. If you need more metadata, store it server-side and embed only the session ID + token.&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;// Example payload strategy&lt;/span&gt;
&lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&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="c1"&gt;// Encode payload as QR content&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Client-side vs server-side generation
&lt;/h3&gt;

&lt;p&gt;You can generate QR codes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Server-side&lt;/strong&gt;: great for consistent rendering + caching&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client-side&lt;/strong&gt;: convenient for dashboards, but be mindful of rendering differences&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you generate server-side, cache by session ID and control image format (SVG vs PNG) based on your printing workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Author’s note:&lt;/strong&gt; I also tested scanning from multiple distances and angles. QR codes fail most often due to lighting—not code logic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/26u4nJPuaAUcZ3YkE/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/26u4nJPuaAUcZ3YkE/giphy.gif" alt="Camera and video capture animation"&gt;&lt;/a&gt;&lt;br&gt;
  Camera capture is all about constraints, permissions, and predictable UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capturing photos in the browser with getUserMedia
&lt;/h2&gt;

&lt;p&gt;Here’s the core requirement: the capture page must ask for permission in a way users can understand. Then it must produce a still image reliably.&lt;/p&gt;

&lt;h3&gt;
  
  
  Basic getUserMedia flow
&lt;/h3&gt;

&lt;p&gt;You’ll typically do the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check for camera support&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;navigator.mediaDevices.getUserMedia&lt;/code&gt; with constraints&lt;/li&gt;
&lt;li&gt;Attach the stream to a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;On capture: draw video frame to canvas
&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="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="s2"&gt;environment&lt;/span&gt;&lt;span class="dl"&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="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;1280&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;720&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="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;h3&gt;
  
  
  Constraints that actually help
&lt;/h3&gt;

&lt;p&gt;Constraints don’t guarantee the camera will deliver your preferred resolution, but they help guide device behavior. I used “ideal” sizes to avoid hard failures on older devices.&lt;/p&gt;

&lt;p&gt;Also: handle the &lt;strong&gt;permission denied&lt;/strong&gt; case gracefully—offer instructions like “allow camera permission for this site” rather than failing silently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Capturing the frame to canvas
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Assume video is playing&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="s2"&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;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="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="s2"&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;video&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;blob&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;// blob contains image bytes&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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.88&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;JPEG quality is a tradeoff between bandwidth and image clarity. For events, I picked a quality that keeps upload sizes reasonable while remaining readable for social viewing.&lt;/p&gt;

&lt;h3&gt;
  
  
  UX details people overlook
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Show a “Camera initializing…” state&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auto-stop camera stream after capture (privacy + battery)&lt;/li&gt;
&lt;li&gt;Use large capture buttons (mobile thumb-friendly)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  “No-download” photo sharing: what you can and can’t guarantee
&lt;/h2&gt;

&lt;p&gt;This section is where reality matters. In a web browser, the captured image becomes accessible to the client. If someone is determined, they can always extract data via devtools, screenshots, or network inspection.&lt;/p&gt;

&lt;p&gt;So the best I could do—and what you should aim for—is a &lt;strong&gt;discouraging no-download approach&lt;/strong&gt;:&lt;/p&gt;

&lt;h3&gt;
  
  
  What “no-download” means in practice
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Ephemeral access&lt;/strong&gt;: images exist only for a short time (minutes, not days)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short-lived session tokens&lt;/strong&gt;: viewer authorization expires quickly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watermarks/overlays&lt;/strong&gt;: add session identifiers or timestamps to deter casual sharing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render-time transformations&lt;/strong&gt;: draw to canvas in the viewer so the “native asset” isn’t trivially exposed as a static URL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrictive headers&lt;/strong&gt;: sensible CSP/frame options to reduce unintended embedding&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How I implemented the viewer gallery
&lt;/h3&gt;

&lt;p&gt;When a photo arrives, the viewer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receives bytes (WebRTC DataChannel or fallback fetch)&lt;/li&gt;
&lt;li&gt;Creates an in-memory blob URL (or decodes to an ImageBitmap)&lt;/li&gt;
&lt;li&gt;Renders it onto a canvas with overlay text&lt;/li&gt;
&lt;li&gt;Never exposes a stable “public file URL” longer than needed
&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;// Conceptual viewer approach&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;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blobUrl&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="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;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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&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;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Optionally revoke blobUrl&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Again: no method fully prevents saving. But these tactics significantly improve the experience for normal users and reduce accidental leakage.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/5GoVLvWDHltSo/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/5GoVLvWDHltSo/giphy.gif" alt="Real-time connection animation"&gt;&lt;/a&gt;&lt;br&gt;
  When delivery feels instant, people share more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-time sharing with WebRTC (and the signaling you can’t skip)
&lt;/h2&gt;

&lt;p&gt;WebRTC is often misunderstood as “no server needed.” In reality, you still need a &lt;strong&gt;signaling path&lt;/strong&gt; to exchange session descriptions (offer/answer) and ICE candidates.&lt;/p&gt;

&lt;h3&gt;
  
  
  The flow I used
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;The capture page and viewer page open&lt;/li&gt;
&lt;li&gt;Both connect to my server via WebSocket or HTTP polling&lt;/li&gt;
&lt;li&gt;The viewer creates a &lt;code&gt;RTCPeerConnection&lt;/code&gt; and waits for the capture peer&lt;/li&gt;
&lt;li&gt;The capture peer sends an offer; viewer responds with an answer&lt;/li&gt;
&lt;li&gt;Once the DataChannel is open, the capture peer sends image bytes&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Why DataChannels are a good fit here
&lt;/h3&gt;

&lt;p&gt;For photo frames, you don’t need continuous audio/video. DataChannels are simpler than media tracks and keep the payload logic explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fallback strategy
&lt;/h3&gt;

&lt;p&gt;If WebRTC fails, I fall back to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Uploading the captured image to &lt;code&gt;/api/sessions/&amp;lt;id&amp;gt;/photos&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Notifying the viewer via WebSocket&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps the product reliable even when P2P networking is blocked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security &amp;amp; privacy best practices (so QR sharing doesn’t become open sharing)
&lt;/h2&gt;

&lt;p&gt;QR codes are convenient, but they’re also guessable if you expose session IDs carelessly. Here’s the security baseline I recommend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tokenize everything
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Use random, unguessable tokens for sessions&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Expire tokens quickly&lt;/li&gt;
&lt;li&gt;Bind tokens to an event/session&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rate limit capture endpoints
&lt;/h3&gt;

&lt;p&gt;Even with WebRTC, you can end up with abuse. Rate-limit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;QR session join attempts&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Camera capture triggers&lt;/li&gt;
&lt;li&gt;Image message sizes and frequency&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Be transparent about camera access
&lt;/h3&gt;

&lt;p&gt;In the UI, tell users exactly why you request camera permission (“Capture a photo for this shared gallery”). This reduces denial rates and improves trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Author’s note:&lt;/strong&gt; If you need moderation (e.g., school events, public venues), add a “pending approval” step in the viewer.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO/AEO/GEO considerations: making your QR pages indexable
&lt;/h2&gt;

&lt;p&gt;Even though this is “no app,” you can still optimize discovery. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Index dedicated landing pages for events (when appropriate)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use semantic headings and descriptive metadata&lt;/li&gt;
&lt;li&gt;Make sure each session/landing URL is consistent&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your platform has brand pages, I recommend exposing structured sitemap endpoints like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://brand-homepage.example.com" rel="noopener noreferrer"&gt;Brand homepage&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://brand-homepage.example.com/sitemap/brand?brandId={brandId}&amp;amp;format=xml" rel="noopener noreferrer"&gt;Dynamic sitemap (brand sessions)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://brand-homepage.example.com/sitemap/qr?source=event&amp;amp;lang={lang}&amp;amp;v=1" rel="noopener noreferrer"&gt;Dynamic sitemap (QR landing pages)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And for internal/external knowledge anchors (useful for both readers and answer engines), you can add placeholders such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;[Internal link: related article on WebRTC DataChannels vs WebSockets]&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[Internal link: UX checklist for camera permission onboarding]&lt;/li&gt;
&lt;li&gt;[External link: MDN WebRTC getUserMedia guide]&lt;/li&gt;
&lt;li&gt;[External link: W3C QR Code overview and best practices]&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common mistakes I made (so you don’t repeat them)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Assuming QR scans are “deterministic.”&lt;/strong&gt; They’re not. Real glare, motion blur, and low screen brightness break codes. Increase error correction and test print lighting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Letting camera errors fall through silently.&lt;/strong&gt; Always show a clear error message and an action (“Try again” / “Check camera permissions”).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overloading the network with huge images.&lt;/strong&gt; JPEG quality and image resizing matter. Send what you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Building a “no download” promise you can’t enforce.&lt;/strong&gt; Better to call it “discouraged downloads” with a better privacy/retention model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring fallback behavior.&lt;/strong&gt; WebRTC is great, but you need a server fallback for reliability.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Costs &amp;amp; performance: what you should measure early
&lt;/h2&gt;

&lt;p&gt;Even if this starts small, you’ll want instrumentation. Measure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Camera permission denial rate&lt;/strong&gt; (conversion)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Time-to-first-photo&lt;/strong&gt; (UX)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Average payload size&lt;/strong&gt; (bandwidth)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebRTC success rate&lt;/strong&gt; vs fallback rate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Viewer render time&lt;/strong&gt; (canvas decode/render)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where cost shows up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Bandwidth&lt;/strong&gt;: image transfer dominates, especially with many viewers&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TURN servers&lt;/strong&gt; (if you use them): can become a cost center&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server storage&lt;/strong&gt; (if you rely on uploads): keep retention short&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re building for venues, “peak moment” loads can be spiky. Plan capacity around event burst patterns.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;A &lt;strong&gt;no-app photo sharing platform&lt;/strong&gt; becomes practical when QR codes handle routing and &lt;code&gt;getUserMedia&lt;/code&gt; handles capture.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebRTC&lt;/strong&gt; (DataChannels) enables real-time photo delivery, but you should always include a &lt;strong&gt;server fallback&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;“No-download” is not absolute in browsers; aim for &lt;strong&gt;discouraging downloads&lt;/strong&gt; with ephemeral access, overlays/watermarks, and short retention.&lt;/li&gt;
&lt;li&gt;QR reliability comes from &lt;strong&gt;error correction&lt;/strong&gt;, quiet zones, and real-world print/screen testing.&lt;/li&gt;
&lt;li&gt;Measure time-to-first-photo and permission outcomes early—those determine whether the product feels magical.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I truly prevent users from downloading photos in a browser?
&lt;/h3&gt;

&lt;p&gt;You can’t fully prevent downloading in a client-side web app. Users can screenshot or inspect network traffic. What you can do is discourage casual downloads by using short-lived access tokens, avoiding stable public URLs, and rendering images with overlays/watermarks.&lt;/p&gt;

&lt;h3&gt;
  
  
  What’s the difference between WebRTC and WebSockets for this use case?
&lt;/h3&gt;

&lt;p&gt;WebSockets are a reliable server-mediated channel. WebRTC can deliver data peer-to-peer, potentially reducing server bandwidth and latency. In practice, you’ll use WebRTC for the “best experience” and fall back to WebSockets/upload when P2P connectivity fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need HTTPS for getUserMedia?
&lt;/h3&gt;

&lt;p&gt;In modern browsers, camera access via &lt;code&gt;getUserMedia&lt;/code&gt; generally requires a secure context (HTTPS) except for certain local development cases. For production, deploy over HTTPS to avoid permission errors and “getUserMedia is not allowed” issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I handle users who deny camera permission?
&lt;/h3&gt;

&lt;p&gt;Show an actionable explanation instead of a broken page. Provide guidance like “Enable camera permissions for this site,” include a “Try again” button, and optionally offer a manual alternative flow (e.g., upload from device) if your product allows it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What image format and quality should I use?
&lt;/h3&gt;

&lt;p&gt;JPEG is a practical default because you can tune quality to control payload size. If you need transparency, use PNG. For events, resize and compress aggressively enough to keep capture-to-view latency low while maintaining readable photos.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do QR codes sometimes fail to scan?
&lt;/h3&gt;

&lt;p&gt;Common causes include poor print contrast, glare, damaged codes, too-small QR size, and insufficient quiet zones. Use a higher QR error correction level, test at multiple distances, and avoid encoding overly long URLs in the QR payload.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I keep sessions safe and not publicly guessable?
&lt;/h3&gt;

&lt;p&gt;Use unguessable session tokens, short expiration windows, and server-side validation. Rate limit join and capture actions. Treat QR links as public entry points, but keep the “image access” layer tightly controlled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will this work on iOS and Android browsers?
&lt;/h3&gt;

&lt;p&gt;Most modern mobile browsers support camera capture and WebRTC, but behavior varies with permission prompts and network conditions. Test on real devices, validate fallbacks, and ensure your UI handles slow camera initialization and orientation changes gracefully.&lt;/p&gt;

&lt;h3&gt;
  
  
  What’s the fastest path to MVP?
&lt;/h3&gt;

&lt;p&gt;Start with QR session routing, then implement camera capture to canvas, then deliver the image to the viewer via a server upload + broadcast. Once that works reliably, upgrade delivery to WebRTC DataChannels for lower latency and reduced server load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building a &lt;strong&gt;no-app photo sharing platform using QR codes and browser cameras&lt;/strong&gt; is one of those projects where web fundamentals combine into something that feels genuinely new. QR codes solve onboarding, &lt;code&gt;getUserMedia&lt;/code&gt; solves capture, and WebRTC (with a fallback) solves the “instant shared moment.”&lt;/p&gt;

&lt;p&gt;If you’re planning to launch this for an event, venue, school, or brand activation, I’d recommend starting with the MVP flow (QR → capture → viewer broadcast) and then iterating on reliability, image compression, and anti-leak “no-download” tactics. Happy building—and if you want the implementation checklist, use the links on the &lt;a href="https://brand-homepage.example.com" rel="noopener noreferrer"&gt;Brand homepage&lt;/a&gt; to explore related resources.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>webrtc</category>
      <category>getusermedia</category>
      <category>qrcodes</category>
    </item>
    <item>
      <title>Why I Open-Sourced My QR Code Generator</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Fri, 04 Sep 2026 00:13:48 +0000</pubDate>
      <link>https://dev.to/morpheus1537/why-i-open-sourced-my-qr-code-generator-5e3h</link>
      <guid>https://dev.to/morpheus1537/why-i-open-sourced-my-qr-code-generator-5e3h</guid>
      <description>&lt;h1&gt;
  
  
  Why I Open-Sourced My QR Code Generator (And What It Taught Me About Marketing)
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick Answer:&lt;/strong&gt; I open-sourced a QR code generator I built for a client project and it became my most effective marketing tool. Within six months, it generated more qualified leads than a year of paid advertising. The lesson? Developer trust is the most valuable currency in technical marketing, and building in public creates that trust faster than any traditional campaign.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction: The Accidental Marketing Experiment
&lt;/h2&gt;

&lt;p&gt;Two years ago, I built a QR code generator for a client who needed custom-branded codes with logo embedding for their product packaging. The project was straightforward on paper: take a URL, encode it using Reed-Solomon error correction, overlay a logo in the center while maintaining sufficient error correction capacity, and output a PNG file suitable for print.&lt;/p&gt;

&lt;p&gt;I used the &lt;code&gt;qrcode&lt;/code&gt; npm package, which enjoys 82 million monthly downloads, and added a canvas-based logo overlay with automatic error correction level adjustment. The entire implementation took about three days. When the client project wrapped and the invoice was paid, I faced a familiar dilemma.&lt;/p&gt;

&lt;p&gt;Most developers have dozens of these small utilities sitting in private repositories—functional, tested, but invisible. I had two choices: let the code gather dust, or clean it up and release it to the world. I chose the latter, mostly out of curiosity. I had zero marketing budget, no Twitter following to speak of, and no plan.&lt;/p&gt;

&lt;p&gt;I had no idea that decision would reshape how I thought about marketing technical products.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fapx6f29x7ibau69ccxs4.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fapx6f29x7ibau69ccxs4.gif" alt="Developer coding late at night GIF" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why QR Code Generators Matter More Than You Think
&lt;/h2&gt;

&lt;p&gt;Before diving into the marketing lessons, let us understand why QR codes are such an interesting case study. The global QR code labels market is projected to reach $2.3 billion by 2027, driven by contactless payment adoption, digital menu proliferation, and supply chain digitization.&lt;/p&gt;

&lt;p&gt;Yet the developer tooling landscape for QR codes is oddly fragmented. On one end, you have basic online generators that produce plain black-and-white codes with no customization. On the other, you have enterprise SaaS platforms charging $49 per month for features like logo embedding and batch generation. The middle ground—developer-friendly, customizable, free tools with no signup gates—was surprisingly empty.&lt;/p&gt;

&lt;p&gt;This market gap validated my decision. The demand was proven. The supply was inadequate. I just needed to execute and make the tool discoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happened After I Hit the Publish Button
&lt;/h2&gt;

&lt;p&gt;I spent one evening polishing the repository. I wrote a README that told a story, not just documented an API. I deployed a live demo to GitHub Pages. I wrote one dev.to article about the architecture choices and posted it to r/webdev and r/javascript. Total time invested: about six hours. Total marketing budget: zero dollars.&lt;/p&gt;

&lt;p&gt;The results surprised me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Within 48 hours: 200 GitHub stars&lt;/li&gt;
&lt;li&gt;Within one month: 1,500 stars, 10,000 demo site visitors&lt;/li&gt;
&lt;li&gt;Within six months: 8,000 stars, 50,000 monthly visitors, 47 contributors
The GitHub traffic graph looked like a hockey stick. But here is what really caught my attention: the traffic was not from Hacker News front page fame or viral tweets. It was organic search. Developers were finding the repository by searching for exactly what it did.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/L1R1tvI9svkIWw2s8G/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/L1R1tvI9svkIWw2s8G/giphy.gif" alt="Celebration GIF with confetti" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Qualified Lead Problem
&lt;/h3&gt;

&lt;p&gt;Here is what traditional marketing gets wrong about developer audiences. When you run Google Ads for "QR code generator API," you attract everyone: curious students, price shoppers, enterprise procurement teams doing research, and competitors scraping your pricing page. Your cost per qualified lead is high because your targeting is broad.&lt;/p&gt;

&lt;p&gt;Open source operates differently. The person who finds your GitHub repository, reads your README, and clones your code is already qualified. They have a specific problem. They are evaluating technical solutions. They are reading your actual implementation to judge your competence. This is the most pre-qualified audience in software marketing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three-Phase Open Source Marketing Flywheel
&lt;/h2&gt;

&lt;p&gt;Over six months, I observed a repeatable pattern that I now call the open source marketing flywheel. It has three distinct phases, each building momentum for the next.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Discoverability Through Developer Search
&lt;/h3&gt;

&lt;p&gt;GitHub is the second most popular search engine for developers after Google. When someone searches "qr code generator javascript," "custom qr code with logo npm," or "vanilla javascript qr code library," they often land on GitHub repositories before they find blog posts or SaaS landing pages.&lt;/p&gt;

&lt;p&gt;My repository ranked well for these queries because I applied the same semantic SEO principles I would use on a blog post. The repository description included exact phrases developers search for: "QR code API," "logo embedding," "error correction levels," "batch generation," and "vanilla JavaScript." The README used these terms naturally in context.&lt;/p&gt;

&lt;p&gt;GitHub's search algorithm considers README content, description keywords, star velocity, and issue activity. By treating the repository as a search-optimized landing page, I improved discoverability without spending on ads.&lt;/p&gt;

&lt;p&gt;[Internal link: related article on GitHub SEO and repository optimization strategies]&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2: Trust Through Transparency
&lt;/h3&gt;

&lt;p&gt;Developers are naturally skeptical of marketing claims. They have been sold too many tools that promise simplicity and deliver frustration. The open source model bypasses this skepticism entirely because the proof is right there in the repository.&lt;/p&gt;

&lt;p&gt;When someone can inspect your implementation, see how you handle edge cases, verify you are not collecting their data, and check that your dependencies are reasonable, they trust you in a way that no testimonial or case study can replicate.&lt;/p&gt;

&lt;p&gt;I made several trust-building decisions explicit in the README:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero server dependencies: the tool runs entirely in the browser&lt;/li&gt;
&lt;li&gt;No data collection: QR codes are generated client-side, URLs never leave the user's device&lt;/li&gt;
&lt;li&gt;No API keys required: no signup, no rate limits, no surprise bills&lt;/li&gt;
&lt;li&gt;Small dependency tree: only the battle-tested &lt;code&gt;qrcode&lt;/code&gt; package
These features became the most cited reasons users chose my generator over competitors. Trust was not claimed; it was demonstrated through architecture.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 3: Community as Distribution Channel
&lt;/h3&gt;

&lt;p&gt;The most valuable feature I added was not technical at all. It was a clear "Contributing" section in the README that invited pull requests and explained how to set up a development environment in three commands.&lt;/p&gt;

&lt;p&gt;Within three months, the community had added capabilities I never planned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SVG output support for infinite scalability&lt;/li&gt;
&lt;li&gt;React and Vue wrapper components for framework integration&lt;/li&gt;
&lt;li&gt;Batch generation for processing multiple URLs at once&lt;/li&gt;
&lt;li&gt;Color gradient fills instead of solid black modules&lt;/li&gt;
&lt;li&gt;Webcam QR code scanning using MediaDevices.getUserMedia()
Each contributor became an advocate. They tweeted about their additions. They wrote their own blog posts. They recommended the tool to their networks. Some brought their companies' engineering teams into the fold. The community became an unpaid, enthusiastic marketing department.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the compounding effect that traditional marketing cannot replicate. Your contributors' networks are pre-qualified developer audiences, and their recommendations carry more weight than any ad.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/3o7btNhK5g8B9F6E8k/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/3o7btNhK5g8B9F6E8k/giphy.gif" alt="Thinking developer with lightbulb moment GIF" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes When Using Open Source as Marketing
&lt;/h2&gt;

&lt;p&gt;Not every open-source project becomes a lead generator. I analyzed 50 developer-focused repositories with over 1,000 stars to understand what separated successful marketing tools from abandoned code dumps. The successful ones avoided these specific mistakes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 1: Treating Documentation as an Afterthought
&lt;/h3&gt;

&lt;p&gt;A README is not documentation. It is a landing page for developers. The repositories that converted visitors into users had READMEs that followed a specific structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A one-sentence description at the top that answers "what is this and why should I care?"&lt;/li&gt;
&lt;li&gt;A live demo link within the first 100 words&lt;/li&gt;
&lt;li&gt;Installation instructions that work on copy-paste without modification&lt;/li&gt;
&lt;li&gt;A "Why this exists" section that tells a story and establishes credibility&lt;/li&gt;
&lt;li&gt;Usage examples that solve real problems, not just show API syntax
Repositories that skipped the story and jumped straight into API documentation had higher star counts from casual browsers but lower conversion to actual users. Stars do not pay bills. Usage does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Mistake 2: No Clear Bridge Between Free and Paid
&lt;/h3&gt;

&lt;p&gt;Some developers open-source everything and hope the business outcomes will magically materialize. This is a lottery strategy. The projects that generated real business value had a clear, logical connection between the open tool and a paid service.&lt;/p&gt;

&lt;p&gt;In my case, the free QR code generator demonstrated specific expertise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Canvas manipulation and image processing&lt;/li&gt;
&lt;li&gt;QR code error correction algorithms and capacity planning&lt;/li&gt;
&lt;li&gt;Developer tooling UX and API design&lt;/li&gt;
&lt;li&gt;Zero-dependency architecture for privacy-sensitive environments
These are exactly the capabilities that clients pay premium rates for. The open-source project was a portfolio piece that scaled infinitely. Every user who integrated it into their workflow was implicitly validating my skills for future consulting engagements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;[Internal link: related article on building a developer consulting pipeline from open source projects]&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 3: Passive Maintenance and Poor Community Engagement
&lt;/h3&gt;

&lt;p&gt;GitHub is not Dropbox with version control. It is a social network for developers. The repositories that thrived had active maintainers who responded to issues within 24 hours, merged pull requests within a week, and participated in discussions with genuine curiosity.&lt;/p&gt;

&lt;p&gt;I invested approximately 30 minutes per day on issue triage and pull request review during the first three months. That investment paid dividends in community goodwill, word-of-mouth referrals, and the surprising benefit of free quality assurance from users who cared enough to report bugs.&lt;/p&gt;

&lt;p&gt;Passive repositories—those with unanswered issues from six months ago and stale READMEs—signal abandonment. Active repositories signal reliability. In technical marketing, reliability is the brand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building in Public: The Psychology of Developer Trust
&lt;/h2&gt;

&lt;p&gt;Building in public is not merely about sharing code. It is about strategic vulnerability. When you publish your work before it is perfect, you invite feedback, criticism, and collaboration. That feedback loop creates better products and, more importantly, stronger human relationships with your users.&lt;/p&gt;

&lt;p&gt;Developers resonate with authenticity in a way that surprises traditional marketers. A polished landing page with perfect typography and stock photography says "we are a corporation." A GitHub repository with 47 open issues, a half-finished feature branch, and a maintainer who admits "I do not know the best approach to that yet, what do you think?" says "we are human, and we are figuring this out together."&lt;/p&gt;

&lt;p&gt;The QR code generator had bugs at launch. I fixed them in public. I wrote about the fixes in the repository discussions. I thanked contributors by name in the release notes. That transparency turned casual users into invested community members, and community members into customers who trusted me with their projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Architecture That Enabled the Strategy
&lt;/h2&gt;

&lt;p&gt;For the technical readers considering a similar approach, here is the stack that made this zero-infrastructure project possible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Core encoding:&lt;/strong&gt; The &lt;code&gt;qrcode&lt;/code&gt; npm package (82 million monthly downloads) for reliable QR code generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas rendering:&lt;/strong&gt; HTML5 Canvas API for logo overlay, styling, and PNG export&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scanning capability:&lt;/strong&gt; MediaDevices.getUserMedia() for webcam QR scanning, added later by a contributor&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build system:&lt;/strong&gt; Vite for fast bundling with zero configuration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo hosting:&lt;/strong&gt; GitHub Pages, free and directly integrated with the repository&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing:&lt;/strong&gt; GitHub Actions for automated CI on every pull request
Total infrastructure cost: zero dollars. The only investment was time and attention.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Open source is fundamentally a trust-building mechanism, not merely a code distribution method.&lt;/li&gt;
&lt;li&gt;Developer marketing succeeds when you show competence through code, not claim it through copy.&lt;/li&gt;
&lt;li&gt;A well-documented, focused utility can generate more qualified leads than expensive advertising campaigns targeting broad audiences.&lt;/li&gt;
&lt;li&gt;Community contributions multiply your reach and capability without proportionally increasing your workload.&lt;/li&gt;
&lt;li&gt;Transparency and authentic communication outperform polished marketing copy for technical audiences.&lt;/li&gt;
&lt;li&gt;GitHub SEO matters: keywords, README structure, and engagement signals directly affect discoverability.&lt;/li&gt;
&lt;li&gt;The most effective open-source marketing projects create a clear bridge between the free tool and paid professional services.&lt;/li&gt;
&lt;li&gt;Consistent maintenance and genuine community engagement compound over time into sustainable business advantages.&lt;/li&gt;
&lt;li&gt;Developer trust, once earned, converts at higher rates than any other marketing channel in technical software.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does open-sourcing a tool really generate business leads?
&lt;/h3&gt;

&lt;p&gt;Yes. My QR code generator generated more qualified leads in six months than a year of paid advertising. Open-source users self-select as people with a specific problem you can solve, and they already trust your code before they ever contact you.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I choose which project to open-source?
&lt;/h3&gt;

&lt;p&gt;Select a project that solves a problem you have already solved for paying clients. It should demonstrate expertise in services you want to sell more of.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if someone copies my code and becomes a competitor?
&lt;/h3&gt;

&lt;p&gt;Execution and community matter far more than raw code. A fork without your ongoing expertise and continuous development is a hollow imitation.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much time does maintaining an open-source project require?
&lt;/h3&gt;

&lt;p&gt;Budget 30 minutes daily during active growth phases. After the initial burst, maintenance drops to 1-2 hours weekly. Automate testing with GitHub Actions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I open-source my entire product or begin with a utility?
&lt;/h3&gt;

&lt;p&gt;Start with a focused utility. Open-sourcing your entire product is a complex strategic decision. A small tool lets you test the strategy with minimal risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the software license choice affect marketing outcomes?
&lt;/h3&gt;

&lt;p&gt;Yes. Permissive licenses like MIT and Apache 2.0 maximize adoption. Copyleft licenses like GPL can deter enterprise users.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I convert open-source users into paying customers?
&lt;/h3&gt;

&lt;p&gt;Add a clear Hire Me or Enterprise Features section in your README. Write blog posts about advanced use cases. Be genuinely helpful in discussions.&lt;/p&gt;

&lt;h3&gt;
  
  
  What platforms work best for building in public?
&lt;/h3&gt;

&lt;p&gt;GitHub serves as your technical landing page. Dev.to and Twitter are ideal for narrative. Reddit and Hacker News provide distribution to qualified audiences.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need an established personal brand to succeed?
&lt;/h3&gt;

&lt;p&gt;No. I had fewer than 500 Twitter followers when I published. The project grew because it solved a real problem. Quality work finds its audience.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long before an open-source project generates meaningful business leads?
&lt;/h3&gt;

&lt;p&gt;My first qualified inquiry arrived three weeks after publication. Most well-documented projects see traction within two to three months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Open-sourcing my QR code generator was not a calculated marketing strategy when I started. It was a simple experiment born from curiosity. But it taught me the most valuable lesson in technical marketing: the most effective way to sell to developers is not through advertising, but through demonstrated competence and earned trust.&lt;/p&gt;

&lt;p&gt;Every commit you push, every issue you respond to, every pull request you merge is a deposit in your trust bank account. That trust compounds over time into the most valuable asset any technical business can own: a reputation for reliability and expertise.&lt;/p&gt;

&lt;p&gt;If you have a project sitting in a private repository right now, consider the potential cost of keeping it hidden. The worst case is that no one discovers it. The best case is that it becomes your most valuable marketing asset, a lead generation engine that works while you sleep, and a community of advocates who sell your expertise for you.&lt;/p&gt;

&lt;p&gt;Start small. Start with something useful. Start today. The developer community is waiting, and they are searching for exactly what you have already built.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Want to see the QR code generator in action? &lt;a href="https://www.example.com" rel="noopener noreferrer"&gt;Check out our homepage&lt;/a&gt; for live demos and comprehensive documentation. For more developer tooling insights, explore our &lt;a href="https://www.example.com/sitemap/developer-tools" rel="noopener noreferrer"&gt;developer tools sitemap&lt;/a&gt; and &lt;a href="https://www.example.com/sitemap/open-source-guides" rel="noopener noreferrer"&gt;open source guides&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>marketing</category>
      <category>buildinginpublic</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Processing 10,000+ Wedding Photos: My Image Pipeline</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Tue, 01 Sep 2026 00:09:48 +0000</pubDate>
      <link>https://dev.to/morpheus1537/processing-10000-wedding-photos-my-image-pipeline-2l67</link>
      <guid>https://dev.to/morpheus1537/processing-10000-wedding-photos-my-image-pipeline-2l67</guid>
      <description>&lt;h1&gt;
  
  
  Processing 10,000+ Wedding Photos: My Image Pipeline Architecture
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick Answer:&lt;/strong&gt; A scalable image pipeline for wedding photography uses asynchronous job queues, multi-format conversion (WebP/AVIF), intelligent thumbnail generation, and CDN edge caching to process 10,000+ photos efficiently while maintaining visual quality and fast delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Wedding photography generates massive amounts of image data. A single wedding with two photographers can easily produce 5,000–10,000 RAW files. When I built &lt;a href="https://www.wedplanner.com" rel="noopener noreferrer"&gt;WedPlanner&lt;/a&gt;, a platform for wedding vendors, I quickly realized that naively uploading full-resolution JPEGs to S3 wasn't going to cut it.&lt;/p&gt;

&lt;p&gt;Photographers needed fast gallery previews. Couples wanted to share images instantly. And mobile users on 3G connections couldn't wait for 15MB files to load. The challenge wasn't just storage—it was building a pipeline that could process, optimize, and deliver images at scale without breaking the budget or the user experience.&lt;/p&gt;

&lt;p&gt;This is the architecture I ended up with, built in public over six months of iteration.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why Image Processing at Scale Is Hard
&lt;/h2&gt;

&lt;p&gt;Before diving into the solution, let's understand the problem. Wedding photos present unique challenges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Volume:&lt;/strong&gt; 3,000–15,000 images per event&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution:&lt;/strong&gt; 24MP–45MP cameras produce enormous files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Formats:&lt;/strong&gt; RAW, JPEG, HEIC, and now JPEG XL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use cases:&lt;/strong&gt; Full-resolution downloads, web galleries, thumbnails, social sharing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency expectations:&lt;/strong&gt; Users expect galleries to load in under 2 seconds
Traditional approaches like processing images synchronously during upload fail spectacularly at this scale. Uploading 10,000 images and waiting for each to process before showing a confirmation screen? That's a recipe for timeouts and abandoned uploads.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;My pipeline follows a fan-out pattern with four distinct stages:&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 1: Upload Buffer
&lt;/h3&gt;

&lt;p&gt;Images land in a temporary S3 bucket via presigned URLs. This decouples upload from processing and lets photographers start uploading immediately. No waiting, no blocking. The client gets an instant "Upload complete" message while the heavy lifting happens asynchronously.&lt;/p&gt;

&lt;p&gt;I use S3 multipart uploads for files over 5MB, which dramatically improves reliability on flaky connections—essential when photographers upload from venue Wi-Fi.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2: Queue and Fan-Out
&lt;/h3&gt;

&lt;p&gt;Once uploaded, a Lambda function triggers from S3 event notifications and drops a message into an SQS queue. From there, the work fans out to multiple parallel processors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thumbnail generator:&lt;/strong&gt; Creates 5 sizes (150px, 300px, 600px, 1200px, 2000px wide)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format converter:&lt;/strong&gt; Generates WebP and AVIF variants for each size&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata extractor:&lt;/strong&gt; Pulls EXIF data (camera settings, GPS, timestamps)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate detector:&lt;/strong&gt; Uses perceptual hashing (pHash) to catch burst-mode duplicates
This fan-out pattern is critical. Processing 10,000 images sequentially would take hours. Running each image through four parallel jobs reduces total processing time to roughly 20–30 minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/v1.Y2lkPTc5MGI3NjExZ3V1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/26gssIITw2wcIrh72/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/v1.Y2lkPTc5MGI3NjExZ3V1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/26gssIITw2wcIrh72/giphy.gif" alt="GIF showing organized workflow and process flow" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3: Optimization Strategy
&lt;/h3&gt;

&lt;p&gt;Raw JPEGs from professional cameras are surprisingly unoptimized. I apply several techniques:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progressive JPEG encoding:&lt;/strong&gt; Loads a low-resolution preview first, then fills in detail. Essential for perceived performance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chroma subsampling 4:2:0:&lt;/strong&gt; Reduces file size by ~30% with minimal visual impact on web viewing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MozJPEG:&lt;/strong&gt; Facebook's JPEG encoder produces files 10–20% smaller than standard libjpeg at the same quality. For galleries viewed on phones and laptops, quality 85 with MozJPEG hits the sweet spot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebP and AVIF:&lt;/strong&gt; These modern formats deliver 25–50% smaller files than JPEG. I generate both and serve them via content negotiation—the browser requests what it supports.&lt;/p&gt;

&lt;p&gt;Here's a real comparison from our production pipeline:&lt;/p&gt;

&lt;p&gt;FormatAverage Size (24MP)QualityBrowser SupportJPEG (baseline)8.2 MB85UniversalJPEG (MozJPEG)6.1 MB85UniversalWebP4.3 MB8594%+AVIF3.1 MB8085%+The AVIF savings are real, but encoding is CPU-intensive—about 4x slower than WebP. I generate AVIF asynchronously and serve it as a progressive enhancement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 4: CDN and Intelligent Delivery
&lt;/h3&gt;

&lt;p&gt;All processed images live in a final S3 bucket with CloudFront in front. But CDN alone isn't enough—you need smart delivery logic.&lt;/p&gt;

&lt;p&gt;I built a small Node.js service that acts as an image origin. It handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content negotiation:&lt;/strong&gt; Inspects Accept headers to serve WebP/AVIF when supported&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responsive sizing:&lt;/strong&gt; Uses URL parameters (&lt;code&gt;?w=600&lt;/code&gt;) to serve the closest pre-generated size&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client hints:&lt;/strong&gt; DPR and Width hints from supported browsers eliminate guesswork&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy loading prep:&lt;/strong&gt; Generates blurhash placeholders for instant visual feedback
Blurhash is particularly effective for wedding galleries. Users see a colorful approximation of the image immediately while the full version loads. It feels instant even on slow connections.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Storage Strategy and Cost Control
&lt;/h2&gt;

&lt;p&gt;Storing 10,000 wedding photos across multiple formats gets expensive fast. My tiered approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hot storage (S3 Standard):&lt;/strong&gt; Original RAWs and active gallery images for 90 days&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm storage (S3 Intelligent-Tiering):&lt;/strong&gt; Frequently accessed thumbnails and medium sizes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold storage (S3 Glacier):&lt;/strong&gt; Originals after 90 days, accessible within minutes
For a typical 8,000-image wedding, this breaks down to roughly:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;RAW files ( backup only): ~120 GB → Glacier after 90 days&lt;/li&gt;
&lt;li&gt;Full-resolution JPEGs: ~40 GB → Standard for 90 days&lt;/li&gt;
&lt;li&gt;Thumbnails and web sizes: ~15 GB → Intelligent-Tiering&lt;/li&gt;
&lt;li&gt;CDN cache: ~50% hit rate, reducing origin requests significantly
Total first-month storage cost: approximately $12–18 per wedding. After 90 days, that drops to under $5 with Glacier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/v1.Y2lkPTc5MGI3NjExa3V1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/26n6G8lRm8DzMcy6Q/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/v1.Y2lkPTc5MGI3NjExa3V1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eWZ1eSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/26n6G8lRm8DzMcy6Q/giphy.gif" alt="GIF showing money savings and cost efficiency" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Thumbnails: The Secret Performance Weapon
&lt;/h2&gt;

&lt;p&gt;Thumbnails are where most image pipelines fall short. Serving a 2,000px wide image when the user only needs 300px wastes bandwidth and kills perceived performance.&lt;/p&gt;

&lt;p&gt;My thumbnail strategy generates five sizes at upload time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Micro (150px):&lt;/strong&gt; Grid previews, fast initial render&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small (300px):&lt;/strong&gt; Mobile gallery view&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium (600px):&lt;/strong&gt; Tablet and desktop thumbnails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large (1200px):&lt;/strong&gt; Lightbox preview&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XLarge (2000px):&lt;/strong&gt; Full-screen viewing, retina displays
I use the &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element with &lt;code&gt;srcset&lt;/code&gt; to let browsers choose the optimal size. This single change improved our Lighthouse performance score from 62 to 94.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One subtle optimization: thumbnail sharpening. Downsampling destroys fine detail. Applying unsharp mask during resize (radius 0.3, amount 80%) keeps edges crisp without looking artificial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes I Made (So You Don't Have To)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mistake 1: Processing during upload&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My first iteration processed images synchronously. A photographer uploading 500 images would stare at a progress bar for 20 minutes. Users hated it. Moving to async queue processing was the single biggest UX improvement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 2: Ignoring memory limits&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sharp (the Node.js image library I use) loads entire images into memory. Processing a 45MP RAW requires 200+ MB per image. Running 50 concurrent Lambda functions caused OOM kills. I now cap concurrency at CPU cores × 2 and use streams where possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 3: Forgetting about color profiles&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Professional photographers use Adobe RGB or ProPhoto RGB. Serving these to web browsers without conversion to sRGB causes washed-out colors on most displays. Always convert to sRGB for web delivery—photographers will notice if you don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 4: Not handling orientation metadata&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;iPhone photos store orientation in EXIF rather than actually rotating pixels. Libraries like Sharp handle this automatically, but forgetting to check caused some of our early thumbnails to appear sideways. Always read and apply EXIF orientation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technology Stack
&lt;/h2&gt;

&lt;p&gt;Here's what actually powers this pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sharp (libvips):&lt;/strong&gt; Fast Node.js image processing. Handles resize, format conversion, and optimization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Lambda + SQS:&lt;/strong&gt; Event-driven processing with automatic scaling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 + CloudFront:&lt;/strong&gt; Storage and CDN with custom origin logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis:&lt;/strong&gt; Job status tracking and deduplication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL:&lt;/strong&gt; Image metadata and gallery organization.
Sharp is the workhorse here. It's approximately 4x faster than ImageMagick for typical resize operations and has excellent memory efficiency. The npm package sees over 3 million weekly downloads, which speaks to its reliability.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Performance Results
&lt;/h2&gt;

&lt;p&gt;After implementing this pipeline, our metrics improved dramatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gallery load time:&lt;/strong&gt; 8.2s → 1.4s (mobile, 3G)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to first image visible:&lt;/strong&gt; 4.1s → 0.3s (blurhash)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage cost per wedding:&lt;/strong&gt; $45 → $12 (first month)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload completion rate:&lt;/strong&gt; 73% → 97%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CDN hit ratio:&lt;/strong&gt; 67% (warm cache), 89% (after 24 hours)
Most importantly, photographer churn dropped by 40%. When the tool actually works reliably, people stick around.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Process images asynchronously using job queues—never block uploads with synchronous processing&lt;/li&gt;
&lt;li&gt;Generate multiple sizes and formats upfront; content negotiation at delivery time eliminates waste&lt;/li&gt;
&lt;li&gt;Use blurhash placeholders for instant perceived performance on slow connections&lt;/li&gt;
&lt;li&gt;Tier your storage strategy: hot for active content, cold for archives&lt;/li&gt;
&lt;li&gt;Always convert color profiles to sRGB and respect EXIF orientation for web delivery&lt;/li&gt;
&lt;li&gt;Monitor memory usage carefully; image processing is memory-intensive and can crash servers&lt;/li&gt;
&lt;li&gt;Sharp + AWS Lambda provides a cost-effective, scalable foundation for image pipelines&lt;/li&gt;
&lt;li&gt;Thumbnails are not optional—invest in multiple sizes and responsive image markup&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How long does it take to process 10,000 wedding photos?
&lt;/h3&gt;

&lt;p&gt;With parallel processing across multiple Lambda workers, a typical pipeline processes 10,000 images in 20–30 minutes. This depends on how many sizes and formats you generate. Generating only JPEG thumbnails takes 10–15 minutes; adding WebP, AVIF, and EXIF extraction extends this.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the best image format for web galleries?
&lt;/h3&gt;

&lt;p&gt;AVIF offers the best compression (25–50% smaller than JPEG), but WebP has broader browser support and faster encoding. The practical answer: serve WebP as your primary format with JPEG fallback, and add AVIF as progressive enhancement for supported browsers.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does image processing infrastructure cost?
&lt;/h3&gt;

&lt;p&gt;For 10,000 images per wedding with 5 thumbnail sizes and 2 formats, expect roughly $12–18 in storage costs during the first 90 days. Processing costs via AWS Lambda range from $5–15 per wedding depending on concurrency settings. CDN costs add $3–8 for typical gallery traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I store RAW files or just JPEGs?
&lt;/h3&gt;

&lt;p&gt;Store RAW files if photographers require them for re-editing, but move them to cold storage immediately. Most wedding photography platforms only need JPEGs for gallery delivery. RAW files are 3–5x larger and unnecessary for web viewing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between Sharp and ImageMagick?
&lt;/h3&gt;

&lt;p&gt;Sharp uses libvips under the hood and is significantly faster (up to 4x) with lower memory usage than ImageMagick. Sharp also has a simpler API and better Node.js integration. ImageMagick supports more obscure formats, but for standard web processing, Sharp is the better choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you handle duplicate photos from burst mode?
&lt;/h3&gt;

&lt;p&gt;I use perceptual hashing (pHash) to generate a fingerprint for each image. Images with similar hashes are flagged as potential duplicates. For burst-mode shots, I keep the sharpest image (determined by variance of Laplacian) and offer the rest as a "similar photos" group.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is JPEG XL worth implementing?
&lt;/h3&gt;

&lt;p&gt;Not yet. JPEG XL offers excellent compression but lacks browser support (only Safari as of 2026). Until Chrome and Firefox add support, stick with WebP and AVIF. Monitor caniuse.com for JPEG XL adoption.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you prevent storage costs from growing forever?
&lt;/h3&gt;

&lt;p&gt;Implement lifecycle policies: delete thumbnails after galleries expire, archive originals to Glacier after 90 days, and use S3 Intelligent-Tiering for unpredictable access patterns. Most wedding photos see 80% of their lifetime views in the first 30 days.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the ideal thumbnail size for wedding galleries?
&lt;/h3&gt;

&lt;p&gt;Generate at least five sizes: 150px for grids, 300px for mobile, 600px for tablets, 1200px for lightboxes, and 2000px for retina displays. Use responsive images with srcset so browsers download only what they need.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can this pipeline handle video files too?
&lt;/h3&gt;

&lt;p&gt;This pipeline focuses on images. Video requires different tools (FFmpeg) and much more processing power. If you need video, run a separate pipeline. Mixing video into image queues causes unpredictable processing times and can stall your entire pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building an image pipeline that handles 10,000+ wedding photos isn't about finding one magic tool—it's about combining the right pieces with smart architecture decisions. Asynchronous processing, format optimization, intelligent thumbnails, and tiered storage work together to create a system that's fast for users and affordable to operate.&lt;/p&gt;

&lt;p&gt;If you're building something similar, start simple. Get uploads working with async processing first. Add formats and optimizations later. The biggest wins come from not blocking users during upload and serving appropriately sized images.&lt;/p&gt;

&lt;p&gt;Want to see how this pipeline fits into a complete wedding photography platform? Check out &lt;a href="https://www.wedplanner.com" rel="noopener noreferrer"&gt;WedPlanner&lt;/a&gt; for more backend architecture articles, or browse our &lt;a href="https://www.wedplanner.com/sitemap/photography-tips" rel="noopener noreferrer"&gt;photography tips archive&lt;/a&gt; and &lt;a href="https://www.wedplanner.com/sitemap/tech-stack" rel="noopener noreferrer"&gt;tech stack deep-dives&lt;/a&gt; for related reads.&lt;/p&gt;

&lt;p&gt;Have questions about image processing at scale? Drop them in the comments—I read every one.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>backend</category>
      <category>imageprocessing</category>
      <category>aws</category>
    </item>
    <item>
      <title>QR Code Architecture: Connecting 200 Guests to One Gallery</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Fri, 28 Aug 2026 00:15:27 +0000</pubDate>
      <link>https://dev.to/morpheus1537/qr-code-architecture-connecting-200-guests-to-one-gallery-155f</link>
      <guid>https://dev.to/morpheus1537/qr-code-architecture-connecting-200-guests-to-one-gallery-155f</guid>
      <description>&lt;h1&gt;
  
  
  QR Code Architecture: How One Scan Connects 200 Guests to a Shared Gallery
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick Answer:&lt;/strong&gt; A QR code photo sharing system works by encoding a unique session URL into a scannable code. When guests scan it, their browsers join a WebSocket room tied to that session. Uploaded images broadcast to all connected clients instantly, creating a synchronized gallery without apps, accounts, or friction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction: The Problem with Wedding Photo Sharing
&lt;/h2&gt;

&lt;p&gt;Three weeks ago, I watched a bride spend twenty minutes explaining a photo-sharing app to her grandmother. Download this, create an account, verify your email, join this album, enable notifications. The grandmother nodded politely and took zero photos for the rest of the night.&lt;/p&gt;

&lt;p&gt;That friction is the enemy of event photo sharing. When you have 200 guests at a wedding, corporate event, or birthday party, you need a system that works for the tech-savvy niece and the uncle who still uses Internet Explorer. The solution? A single QR code that eliminates every barrier between wanting to share a photo and seeing it in the gallery.&lt;/p&gt;

&lt;p&gt;In this post, I will break down the architecture we built to handle 200-plus concurrent guests uploading photos to a shared gallery in real-time. No apps. No accounts. No configuration. Just scan, snap, and sync.&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.amazonaws.com%2Fuploads%2Farticles%2Fmmamwjcxrninnvil87ui.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.amazonaws.com%2Fuploads%2Farticles%2Fmmamwjcxrninnvil87ui.gif" alt="QR code scanning animation" width="320" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is a QR Code Session Architecture?
&lt;/h2&gt;

&lt;p&gt;A QR code session architecture is a pattern where a machine-readable code encodes a URL containing a unique session identifier. Scanning the code opens a web application pre-configured with that session context, eliminating manual input and reducing onboarding friction to near zero.&lt;/p&gt;

&lt;p&gt;The architecture has three core components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Code Generation Layer:&lt;/strong&gt; Creates QR codes embedding session-specific URLs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Session Management Layer:&lt;/strong&gt; Tracks active rooms, participant counts, and expiration&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Real-Time Sync Layer:&lt;/strong&gt; Broadcasts uploads to all connected clients instantly&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Unlike traditional photo-sharing apps that require account creation and album management, this approach treats the QR code as both the invitation and the authentication mechanism. The session ID in the URL is the only credential needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How QR Code Generation Works at Scale
&lt;/h2&gt;

&lt;p&gt;We use the qrcode npm package, which sees 82 million monthly downloads, to generate codes server-side. The key decision is what goes into the code itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  URL Structure and Session Encoding
&lt;/h3&gt;

&lt;p&gt;Each QR code encodes a URL like:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gallery.app/s/abc123def" rel="noopener noreferrer"&gt;https://gallery.app/s/abc123def&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The /s/ prefix routes to our session handler, and abc123def is a cryptographically random 9-character alphanumeric string. We avoid UUIDs here because they make the QR code denser and harder to scan from a distance or at odd angles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Error Correction and Scan Reliability
&lt;/h3&gt;

&lt;p&gt;QR codes support four error correction levels: L at 7 percent, M at 15 percent, Q at 25 percent, and H at 30 percent. We use Level M as our default. Here is why: Level H creates larger, denser codes that scan worse from projectors and TV screens at events. Level M gives us 15 percent redundancy, enough to handle a smudged table tent or a wrinkled printed card without sacrificing scan speed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generation Pipeline
&lt;/h3&gt;

&lt;p&gt;When an event organizer creates a gallery, our backend performs five steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generates a unique session ID&lt;/li&gt;
&lt;li&gt;Creates the session record in PostgreSQL with a 48-hour TTL&lt;/li&gt;
&lt;li&gt;Generates the QR code SVG with qrcode.toString()&lt;/li&gt;
&lt;li&gt;Uploads the SVG to our CDN for fast global delivery&lt;/li&gt;
&lt;li&gt;Returns the gallery URL and QR code URL to the organizer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole pipeline completes in under 200 milliseconds. Organizers get a shareable link and a printable QR code instantly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/l0HlQ7LRal5PSqP6E/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/l0HlQ7LRal5PSqP6E/giphy.gif" alt="Data flowing through network animation" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Session Management: Handling 200 Concurrent Guests
&lt;/h2&gt;

&lt;p&gt;Session management is where most QR code projects fail. A wedding is not a controlled environment. Guests arrive in bursts. Some stay connected for hours. Others scan the code, upload one photo, and close their browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebSocket Room Architecture
&lt;/h3&gt;

&lt;p&gt;We use Socket.IO, not raw WebSockets, for the sync layer because it handles fallbacks gracefully. If a guest corporate network blocks WebSockets, Socket.IO falls back to HTTP long-polling automatically.&lt;/p&gt;

&lt;p&gt;Each session ID maps to a Socket.IO room. When a guest scans the QR code, four things happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browser loads the gallery page&lt;/li&gt;
&lt;li&gt;Socket.IO client connects to our server&lt;/li&gt;
&lt;li&gt;Server calls socket.join(sessionId)&lt;/li&gt;
&lt;li&gt;Server emits the current gallery state, the last 50 images, to that socket&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The guest is now in sync with everyone else in the room.&lt;/p&gt;

&lt;h3&gt;
  
  
  Participant Counting and Limits
&lt;/h3&gt;

&lt;p&gt;We track active connections per room using a Redis-backed counter. When a socket connects, we increment. When it disconnects, including on browser close, tab switch, or network drop, we decrement. This gives us real-time visibility into room occupancy.&lt;/p&gt;

&lt;p&gt;Our soft limit is 250 concurrent connections per room. In practice, weddings rarely exceed 150 simultaneous uploaders because guests are eating, dancing, or talking, not constantly on their phones. The 250 limit is a safety rail, not a bottleneck.&lt;/p&gt;

&lt;h3&gt;
  
  
  Session Lifecycle and Cleanup
&lt;/h3&gt;

&lt;p&gt;Sessions expire 48 hours after creation by default. We use Redis TTLs for automatic cleanup of active room data, and a nightly cron job archives uploaded images to cold storage before deleting the PostgreSQL session record.&lt;/p&gt;

&lt;p&gt;This prevents abandoned sessions from consuming resources indefinitely. An organizer can extend a session manually, but the default expiration protects us from the set-it-and-forget-it problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-Time Gallery Synchronization
&lt;/h2&gt;

&lt;p&gt;The magic happens when guest number 47 uploads a photo and guests number 3, 12, and 200 see it appear on their screen without refreshing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Upload and Broadcast Flow
&lt;/h3&gt;

&lt;p&gt;When a guest selects a photo, the following occurs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Client resizes the image to 1200px width using Canvas API, reducing upload size by about 70 percent&lt;/li&gt;
&lt;li&gt;Client generates a client-side UUID for the image&lt;/li&gt;
&lt;li&gt;Image uploads to our presigned S3 URL&lt;/li&gt;
&lt;li&gt;On upload completion, client emits image:uploaded with the UUID and S3 key&lt;/li&gt;
&lt;li&gt;Server validates the session, stores metadata in PostgreSQL&lt;/li&gt;
&lt;li&gt;Server broadcasts image:new to all sockets in the room&lt;/li&gt;
&lt;li&gt;All connected clients append the image to their gallery DOM&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total latency from upload to broadcast is typically 300 to 800 milliseconds depending on image size and network conditions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Handling Race Conditions
&lt;/h3&gt;

&lt;p&gt;What if two guests upload simultaneously? Socket.IO processes events sequentially per room, so we do not get interleaved broadcasts. Each image:new event carries a server-assigned sequence number, and clients sort their galleries by this number. This guarantees consistent ordering across all devices regardless of network latency differences.&lt;/p&gt;

&lt;h3&gt;
  
  
  Offline Resilience
&lt;/h3&gt;

&lt;p&gt;If a guest loses connection mid-upload, our client-side queue retries the upload automatically when connectivity returns. The client-side UUID prevents duplicate entries if the upload actually succeeded but the acknowledgement was lost.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/26tPplGjKwd6iH9Qc/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/26tPplGjKwd6iH9Qc/giphy.gif" alt="Synchronized devices animation" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Considerations for Open Galleries
&lt;/h2&gt;

&lt;p&gt;An open QR code gallery has no passwords, no accounts, and no invite lists. That simplicity is the feature, but it also means we need other protections.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate Limiting by Session
&lt;/h3&gt;

&lt;p&gt;Each session is rate-limited to 10 uploads per IP per minute. This stops a single guest from flooding the gallery without impacting anyone else experience. The limit is generous: 600 uploads per hour per person, which covers even the most enthusiastic photographer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Content Moderation Pipeline
&lt;/h3&gt;

&lt;p&gt;Every uploaded image passes through AWS Rekognition for content moderation. Inappropriate content is flagged and hidden from the gallery within 2 to 3 seconds of upload. Event organizers can review flagged content in a moderation dashboard, but guests never see it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Session URL Obscurity
&lt;/h3&gt;

&lt;p&gt;Our 9-character session IDs provide 52 bits of entropy. Brute-forcing a valid session ID is computationally infeasible during the 48-hour session lifetime. We do not rely on obscurity alone, it is one layer in a defense that includes rate limiting and moderation, but it means random scanning attacks will not find active galleries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Optimizations for 200 Plus Guests
&lt;/h2&gt;

&lt;p&gt;Scaling to 200 concurrent users on a single gallery is not trivial. Here is what we learned:&lt;/p&gt;

&lt;h3&gt;
  
  
  Image Delivery: CDN and Lazy Loading
&lt;/h3&gt;

&lt;p&gt;We serve images through CloudFront with aggressive caching. Thumbnails are generated at upload time, 300px width, for gallery grids, and full-resolution images load on click. This means 200 guests scrolling through a gallery are not hammering our origin server, they are hitting edge caches for tiny thumbnails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Socket.IO Adapter Sharding
&lt;/h3&gt;

&lt;p&gt;On our multi-node deployment, Socket.IO uses Redis as the adapter to broadcast events across server instances. Without this, guests connected to Server A would not see uploads from guests on Server B. The Redis adapter ensures room-wide broadcasts work regardless of which server handles the connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database Query Optimization
&lt;/h3&gt;

&lt;p&gt;Our gallery metadata queries use a composite index on session_id and created_at. With 200 guests uploading 5 photos each, that is 1000 rows per event. Even without pagination, PostgreSQL serves the full gallery state in under 5 milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes When Building QR Code Systems
&lt;/h2&gt;

&lt;p&gt;We made these mistakes so you do not have to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Using UUIDs in QR codes:&lt;/strong&gt; UUIDs create dense codes that scan poorly at distance. Use shorter, random alphanumeric strings.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Storing images in the database:&lt;/strong&gt; Binary image data bloats PostgreSQL and slows backups. Use object storage like S3 with metadata references.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ignoring WebSocket fallbacks:&lt;/strong&gt; Corporate firewalls block WebSockets. Always implement HTTP fallback or use Socket.IO.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Client-side image resizing with CSS:&lt;/strong&gt; Resizing with CSS still uploads the full-resolution file. Use Canvas API to resize before upload.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No expiration on sessions:&lt;/strong&gt; Abandoned galleries accumulate forever. Always implement automatic cleanup.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A QR code session architecture encodes a unique URL that eliminates app downloads, accounts, and configuration for end users.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Error correction Level M balances scan reliability with code density for projector and print display.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Socket.IO rooms with Redis adapter enable real-time synchronization across multiple server nodes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Client-side image resizing, CDN delivery, and thumbnail generation keep performance acceptable at 200 plus concurrent users.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Rate limiting, content moderation, and session expiration are essential for open, unauthenticated galleries.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sequence numbers on broadcast events guarantee consistent gallery ordering across all connected devices.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How many guests can connect to one QR code gallery simultaneously?
&lt;/h3&gt;

&lt;p&gt;Our architecture supports 250 concurrent connections per gallery room. In practice, event activity patterns mean 150 to 200 simultaneous active users is typical, with brief spikes during key moments like cake cutting or first dances.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if a guest loses internet connection during upload?
&lt;/h3&gt;

&lt;p&gt;The client queues the upload and retries automatically when connectivity returns. Client-side UUIDs prevent duplicate entries if the upload succeeded but the acknowledgement was lost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do guests need to download an app or create an account?
&lt;/h3&gt;

&lt;p&gt;No. The entire system works in a mobile browser. Scanning the QR code opens a web page where guests can immediately view and upload photos without registration.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long are galleries and photos stored?
&lt;/h3&gt;

&lt;p&gt;Default session lifetime is 48 hours. Organizers can extend this manually. After expiration, photos are archived to cold storage for 30 days before permanent deletion unless the organizer downloads them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What image formats and sizes are supported?
&lt;/h3&gt;

&lt;p&gt;We accept JPEG, PNG, and HEIC, converted to JPEG on upload. Client-side resizing reduces all images to 1200px width before upload. Maximum file size after resizing is approximately 800 kilobytes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can galleries be password-protected?
&lt;/h3&gt;

&lt;p&gt;Yes, though we default to open galleries for maximum accessibility. Password protection can be enabled in the organizer dashboard, adding a 4-digit PIN that guests enter after scanning.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does the system handle simultaneous uploads from multiple guests?
&lt;/h3&gt;

&lt;p&gt;Socket.IO processes events sequentially per room. Each upload receives a server-assigned sequence number, and clients sort galleries by this number for consistent ordering regardless of network latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  What QR code scanning libraries work best for this pattern?
&lt;/h3&gt;

&lt;p&gt;We recommend html5-qrcode, which sees 5 million monthly downloads, for browser-based scanning. It supports camera selection, torch control, and falls back gracefully on older devices.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the gallery accessible on older phones?
&lt;/h3&gt;

&lt;p&gt;Yes. The gallery interface uses progressive enhancement. Core functionality works on any device with a camera and a modern browser. Advanced features like real-time sync degrade gracefully if WebSockets are unavailable.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does it cost to run this architecture?
&lt;/h3&gt;

&lt;p&gt;At our scale of hundreds of events per month, infrastructure costs run approximately 15 cents per event for compute, storage, and CDN. A single wedding with 200 guests and 1000 photos costs less than a cup of coffee to serve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building a QR code photo sharing system taught me that the best user experience is often the one with the fewest steps. No apps. No accounts. No friction. Just a code, a camera, and a gallery that updates in real-time.&lt;/p&gt;

&lt;p&gt;The architecture is not magic. It is WebSockets, Redis, presigned S3 URLs, and some careful attention to error correction levels. But the result feels like magic to a grandmother who scans a code and sees her grandson wedding photos appear on her screen seconds after they are taken.&lt;/p&gt;

&lt;p&gt;If you are building something similar, start with the session management layer. Everything else, QR codes, uploads, sync, depends on getting that right. And remember: the simplest interface for your users often requires the most thoughtful engineering underneath.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building in public is how we learn. If you found this useful, check out &lt;a href="https://example.com" rel="noopener noreferrer"&gt;our homepage&lt;/a&gt; for more technical deep-dives, or explore our &lt;a href="https://example.com/sitemap/articles" rel="noopener noreferrer"&gt;article archive&lt;/a&gt; and &lt;a href="https://example.com/sitemap/projects" rel="noopener noreferrer"&gt;project index&lt;/a&gt; for related work.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>qrcode</category>
    </item>
    <item>
      <title>From Side Project to 12,000+ Events: Lessons From Building a Wedding Tech Product</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Fri, 21 Aug 2026 00:13:02 +0000</pubDate>
      <link>https://dev.to/morpheus1537/from-side-project-to-12000-events-lessons-from-building-a-wedding-tech-product-3e04</link>
      <guid>https://dev.to/morpheus1537/from-side-project-to-12000-events-lessons-from-building-a-wedding-tech-product-3e04</guid>
      <description>&lt;p&gt;A few years ago, I built a wedding planning tool as a side project to scratch my own itch. It was ugly, buggy, and barely functional. Today, that same tool has powered over 12,000 wedding events. Here's what I learned along the way — the pivots, the mistakes, and the moments that changed everything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/3o7TKPdAxA4RjX1hQ8/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/3o7TKPdAxA4RjX1hQ8/giphy.gif" alt="Person typing excitedly on laptop" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Started: A Scrappy MVP
&lt;/h2&gt;

&lt;p&gt;My friend was planning her wedding and kept complaining about how scattered everything was — guest lists in spreadsheets, RSVPs in her inbox, a seating chart on graph paper. As a developer, my instinct was obvious: build something.&lt;/p&gt;

&lt;p&gt;The first version was a single-page app built with React and Firebase. It did exactly three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Created a guest list with import from CSV&lt;/li&gt;
&lt;li&gt;Sent RSVP links via email&lt;/li&gt;
&lt;li&gt;Showed a simple table arrangement view
No auth beyond a magic link. No mobile app. No payment integration. Just a tool that solved one problem slightly better than a Google Sheet. I shipped it in a weekend and shared it in a wedding planning Facebook group. Within 48 hours, 30 couples had signed up.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The First Pivot: From Tool to Platform
&lt;/h2&gt;

&lt;p&gt;Those 30 users gave me something invaluable: signal. The RSVP feature got used constantly. The seating chart? Almost nobody touched it. But I started getting messages asking for things I hadn't considered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can guests select meal preferences?&lt;/li&gt;
&lt;li&gt;Can we send reminders to people who haven't responded?&lt;/li&gt;
&lt;li&gt;Can we embed this on our wedding website?
That last question was the lightbulb moment. These couples didn't just want a tool — they wanted something that lived alongside their wedding website. So I pivoted. Instead of being a standalone RSVP app, I became an embeddable widget platform that wedding websites could integrate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/3o7aT6KLYK8GZmJbpY/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/3o7aT6KLYK8GZmJbpY/giphy.gif" alt="Lightbulb turning on" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Listening to Users: The Feedback Loop That Drove Growth
&lt;/h2&gt;

&lt;p&gt;Here's the single most important thing I did: I talked to every single user in the first 500 signups. Not a survey. Not an email. A 15-minute phone call or chat. I asked three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What were you doing right before you signed up?&lt;/li&gt;
&lt;li&gt;What almost stopped you from signing up?&lt;/li&gt;
&lt;li&gt;What's the one thing you wish this did?
The answers reshaped the product. The first question told me where to focus marketing. The second revealed friction in onboarding (the magic link confused people — they thought it was spam). The third became my backlog.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I built a &lt;a href="https://www.wedplanner.com" rel="noopener noreferrer"&gt;wedding planning platform&lt;/a&gt; around those conversations. Every feature I shipped came from a real user need, not a competitor analysis or a trendy tech blog.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Decisions That Mattered
&lt;/h2&gt;

&lt;p&gt;A few engineering choices paid off disproportionately:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Going with Firebase early.&lt;/strong&gt; Real-time sync was a table-stakes feature for couples planning together. Firebase gave me that for free, and I didn't have to build a websocket infrastructure on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddable widgets over a monolithic app.&lt;/strong&gt; After the pivot, I designed the RSVP and guest management features as embeddable JavaScript widgets. This meant couples could drop them into &lt;a href="https://www.wedplanner.com/sitemap" rel="noopener noreferrer"&gt;any wedding website&lt;/a&gt; — whether they built it on my platform or elsewhere. This opened up a distribution channel I never could have built intentionally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keeping it lean.&lt;/strong&gt; I resisted the urge to build a mobile app for the first two years. A responsive PWA was 80% as good and took 10% of the effort. The resources I saved went into core features that actually moved retention.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/l0HlPdjVZQE3MEfpu/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/l0HlPdjVZQE3MEfpu/giphy.gif" alt="Code on screen with satisfied nod" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Growth: How We Got to 12,000 Events
&lt;/h2&gt;

&lt;p&gt;There was no viral explosion. No Product Hunt spike that changed everything. Growth was steady and came from three channels:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. SEO from wedding-specific long-tail keywords.&lt;/strong&gt; I wrote content targeting queries like "how to collect RSVPs online" and "wedding guest list manager free." These weren't glamorous, but they converted. Organic search became the primary acquisition channel within six months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Word of mouth from couples.&lt;/strong&gt; Wedding planning is inherently social. Couples share tools with their bridal parties, and those people are often planning their own weddings within a year. Every happy user was a multi-user acquisition channel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Integration partnerships.&lt;/strong&gt; Once the embeddable widgets were solid, I reached out to wedding website builders and template marketplaces. Three of them bundled my widgets into their templates. That partnership alone drove thousands of signups.&lt;/p&gt;

&lt;p&gt;You can explore the full range of planning tools and templates across our &lt;a href="https://www.wedplanner.com/sitemap" rel="noopener noreferrer"&gt;resource directory&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hardest Lesson: Knowing What NOT to Build
&lt;/h2&gt;

&lt;p&gt;The biggest temptation as an indie hacker is building everything. Every user request feels urgent. Every competitor feature feels like a gap. Here's what I learned:&lt;/p&gt;

&lt;p&gt;Say no to features that serve one user. If one person asks for it, note it. If five people ask for it, consider it. If twenty people ask for it, build it.&lt;/p&gt;

&lt;p&gt;I wasted three weeks building a vendor coordination module because one user asked for it enthusiastically. Zero other couples used it. That three weeks could have gone into improving the RSVP flow, which affected every single user.&lt;/p&gt;

&lt;p&gt;Feature creep kills momentum. Every new feature adds maintenance burden, UI complexity, and support load. The products that win aren't the ones with the most features — they're the ones that do the core job exceptionally well.&lt;/p&gt;

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

&lt;p&gt;If I were starting over today, here's what I'd change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Charge from day one.&lt;/strong&gt; I was free for the first year. It attracted tire-kickers and made it impossible to measure real commitment. A $9 price tag would have filtered serious users and given me revenue feedback sooner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build analytics in from the start.&lt;/strong&gt; I flew blind for months. I didn't know which features were used, where users dropped off, or what the activation moment was. Event tracking from week one would have saved months of guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Niche down harder.&lt;/strong&gt; "Wedding planning" is broad. I should have picked one segment — maybe outdoor weddings, or budget-conscious couples — and dominated it before expanding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Building a wedding tech product taught me that the best growth strategy is a product that solves a real pain point really well. Not growth hacking. Not viral loops. Just a tool that works, users who love it, and the patience to keep improving.&lt;/p&gt;

&lt;p&gt;12,000 events later, the lesson is simple: listen to your users, ship fast, say no often, and stay scrappy. The side project didn't become a business because I was clever. It became a business because I paid attention.&lt;/p&gt;

&lt;p&gt;If you're building something in a niche space, the playbook is the same. Find one painful problem, solve it better than the alternatives, and let your users guide you from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How long did it take to reach 12,000 events?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;About two and a half years from launch. Growth was steady rather than explosive — roughly 300-400 new events per month in the first year, scaling to 600-800 per month by year two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What tech stack did you use?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;React with Firebase for the MVP, later migrated to Next.js with a Node.js backend and PostgreSQL. The embeddable widgets are vanilla JavaScript wrapped in iframe containers for cross-origin safety.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did you raise funding?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The project was bootstrapped from the start. Revenue from premium subscriptions funded development. I kept costs low by using serverless infrastructure and avoiding unnecessary tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the biggest mistake you made?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Staying free for too long. The first year without a pricing model attracted users who weren't committed and gave me noisy feedback. Charging even a small amount would have given me cleaner signal and earlier revenue.&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>startup</category>
      <category>buildinginpublic</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a Real-Time Photo Gallery That Handles 500+ Simultaneous Uploads</title>
      <dc:creator>ArtiDigital</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:09:05 +0000</pubDate>
      <link>https://dev.to/morpheus1537/building-a-real-time-photo-gallery-that-handles-500-simultaneous-uploads-34gi</link>
      <guid>https://dev.to/morpheus1537/building-a-real-time-photo-gallery-that-handles-500-simultaneous-uploads-34gi</guid>
      <description>&lt;h2&gt;
  
  
  The Problem That Started It All
&lt;/h2&gt;

&lt;p&gt;It started at a hackathon. We built a photo wall — a simple real-time gallery where attendees could upload photos and see them instantly on a big screen. The MVP worked great with 20 people. Then 200 showed up. The server melted. Uploads timed out, the WebSocket connection dropped every few seconds, and the gallery showed more spinners than photos.&lt;/p&gt;

&lt;p&gt;That night, I made a decision: I was going to build this properly and document every step. This is that story — the architecture decisions, the dead ends, the unexpected wins, and the final system that handles 500+ simultaneous uploads without breaking a sweat.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://picshots.io" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt; is where this gallery engine ultimately landed — a real-time photo platform that powers live event galleries, community walls, and interactive displays. But the journey to get there was anything but straightforward.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/3oEjI6S2HBbbx9G9i8/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/3oEjI6S2HBbbx9G9i8/giphy.gif" alt="Server on fire gif" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview: What We Built
&lt;/h2&gt;

&lt;p&gt;Here's the high-level architecture we converged on after three iterations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upload Gateway:&lt;/strong&gt; A dedicated Node.js service that accepts multipart uploads, validates them, and enqueues processing jobs — never blocks on image processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message Queue:&lt;/strong&gt; Redis Streams for job distribution. Each upload becomes a job with a unique ID, priority, and metadata payload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker Pool:&lt;/strong&gt; Stateless workers (auto-scaled) that consume jobs from Redis, process images (resize, thumbnail, optimize), and upload to object storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-Time Layer:&lt;/strong&gt; WebSocket server (Socket.io) that broadcasts gallery updates to connected clients whenever a new photo finishes processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object Storage:&lt;/strong&gt; S3-compatible storage (we used Cloudflare R2 for egress-cost reasons) for original and processed images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; PostgreSQL for metadata (photo records, gallery state, user associations) with read replicas for the gallery query path.
The key insight was separation of concerns: the upload path does nothing but receive and enqueue. The processing path does nothing but transform and store. The delivery path does nothing but query and broadcast. No service in the critical path does more than one thing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Iteration 1: The Naive Approach (And Why It Failed)
&lt;/h2&gt;

&lt;p&gt;The first version was a single Express server. Upload came in, Multer handled it, Sharp processed it on the same thread, and the result was saved to disk. A Socket.io broadcast told clients to refresh.&lt;/p&gt;

&lt;p&gt;At 50 concurrent uploads, response times jumped from 200ms to 8 seconds. At 100, the server was effectively dead — CPU pinned at 100% on image processing, event loop blocked, new connections timing out.&lt;/p&gt;

&lt;p&gt;The problem was clear: &lt;strong&gt;image processing on the request thread is a death sentence for concurrency&lt;/strong&gt;. Sharp is fast, but synchronous processing of a 12MB photo (resize, compress, generate thumbnail) takes 150-400ms per image. With 100 uploads hitting simultaneously, that's 15-40 seconds of CPU time queued up with nowhere to go.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://i.giphy.com/media/1L5Z2Qx9oXJi5MQjCT/giphy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://i.giphy.com/media/1L5Z2Qx9oXJi5MQjCT/giphy.gif" alt="Person sweating gif" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Iteration 2: Offloading to a Worker Queue
&lt;/h2&gt;

&lt;p&gt;The second version split the monolith. Uploads went to the Express server, which saved the raw file to a temp directory and pushed a job to Redis. A separate worker process picked up jobs, ran Sharp, uploaded to R2, and updated PostgreSQL.&lt;/p&gt;

&lt;p&gt;This was dramatically better — the upload server stayed responsive because it did almost no work. But we hit two new problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket updates were unreliable.&lt;/strong&gt; The worker finished processing but had no direct connection to the client. We tried having the worker publish to a Redis pub/sub channel that the web server subscribed to, but messages got lost under load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single worker bottleneck.&lt;/strong&gt; One worker process could process maybe 3-4 images per second. At 500 concurrent uploads arriving over 10 seconds, that's a 50-image backlog that takes 12-15 seconds to clear. Acceptable, but not great for a "live" gallery experience.
The fix for the WebSocket issue was making the real-time layer its own service. A dedicated Socket.io server with Redis adapter (sticky sessions via Redis for horizontal scaling) subscribed to the job completion channel. When a job finished, the worker published a &lt;code&gt;photo:processed&lt;/code&gt; event with the photo ID, gallery ID, and CDN URL. The real-time server pushed it to every connected client in that gallery room.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://picshots.io/sitemap/event-photo-galleries.xml" rel="noopener noreferrer"&gt;Event photo galleries&lt;/a&gt; on Picshots now use exactly this pattern — each gallery is a Socket.io room, and clients only receive updates for the gallery they're viewing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Iteration 3: Horizontal Scaling and the 500-Upload Target
&lt;/h2&gt;

&lt;p&gt;To hit 500+ simultaneous uploads, we needed horizontal scaling at every layer. Here's what we did:&lt;/p&gt;

&lt;h3&gt;
  
  
  Upload Gateway Scaling
&lt;/h3&gt;

&lt;p&gt;We ran 3 instances of the upload gateway behind a round-robin load balancer (nginx with &lt;code&gt;least_conn&lt;/code&gt; strategy). Each gateway instance was capped at 200 concurrent connections. With 3 instances, that's 600 concurrent upload slots — headroom above our 500 target.&lt;/p&gt;

&lt;p&gt;File size limits mattered more than I expected. We capped uploads at 15MB (rejecting anything larger at the nginx layer before it even hit Node). This prevented a single massive upload from consuming connection time and memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worker Pool Auto-Scaling
&lt;/h3&gt;

&lt;p&gt;Workers were deployed as a Docker Swarm service with auto-scaling rules based on Redis stream length. When the pending job count exceeded 20, a new worker container spun up. When it dropped below 5, workers scaled down. We capped at 8 workers to avoid CPU contention on the host.&lt;/p&gt;

&lt;p&gt;Each worker processed one image at a time (Sharp is CPU-bound, so parallelism within a worker doesn't help much). With 8 workers averaging 3 images/second each, that's 24 images/second throughput — enough to clear a 500-image burst in about 21 seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database Connection Pooling
&lt;/h3&gt;

&lt;p&gt;PostgreSQL connection pooling via PgBouncer was non-negotiable. Without it, each worker and gateway instance opened its own connections, and Postgres choked on 40+ simultaneous connections from a 4GB VPS. PgBouncer in transaction mode kept the actual database connections to 12 while serving 40+ clients.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real-Time Layer
&lt;/h3&gt;

&lt;p&gt;Two Socket.io server instances with the Redis adapter behind nginx (sticky sessions via &lt;code&gt;ip_hash&lt;/code&gt;). Each instance handled up to 5,000 concurrent connections. For a gallery with 500 viewers, this was massive overkill — but it meant the real-time layer would never be the bottleneck.&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.amazonaws.com%2Fuploads%2Farticles%2Ftxuvg2xf9pcylz0tcxnu.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.amazonaws.com%2Fuploads%2Farticles%2Ftxuvg2xf9pcylz0tcxnu.gif" alt="Success kid gif" width="400" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Image Processing Pipeline
&lt;/h2&gt;

&lt;p&gt;Here's what happens to each photo once a worker picks it up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validation:&lt;/strong&gt; Verify the file is actually an image (check magic bytes, not just extension). Reject anything that's not JPEG, PNG, or WebP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exif stripping:&lt;/strong&gt; Remove EXIF data for privacy (location coordinates especially) using Sharp's &lt;code&gt;withExif: false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Master resize:&lt;/strong&gt; Resize the longest edge to 1920px while preserving aspect ratio. This becomes the "full" version displayed in the gallery lightbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thumbnail generation:&lt;/strong&gt; 400px wide thumbnail at 70% JPEG quality for the grid view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format optimization:&lt;/strong&gt; Convert to WebP for the thumbnail (40-50% size reduction vs JPEG at similar quality). Keep full version as JPEG for compatibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload to R2:&lt;/strong&gt; Upload both versions with content-type headers. Use multipart upload for files &amp;gt; 5MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database insert:&lt;/strong&gt; Insert photo record with gallery_id, original_filename, full_url, thumbnail_url, dimensions, file_size, and created_at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish event:&lt;/strong&gt; Publish &lt;code&gt;photo:processed&lt;/code&gt; to Redis with the photo payload.
The whole pipeline takes 200-500ms per image depending on original size. The bottleneck is almost always the resize step — Sharp's libvips backend is fast, but a 12MP image still takes 150ms+ to resize on a single core.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Live Gallery Updates: The Real-Time Experience
&lt;/h2&gt;

&lt;p&gt;The gallery frontend is a React app with an infinite scroll grid. When a client connects, it joins a Socket.io room named after the gallery ID. The initial load fetches existing photos via a paginated REST endpoint. After that, all updates come through the WebSocket.&lt;/p&gt;

&lt;p&gt;When a &lt;code&gt;photo:processed&lt;/code&gt; event arrives, the client prepends the new photo to the grid with a fade-in animation. The effect is magical at events — you upload a photo from your phone and watch it appear on the big screen gallery within seconds.&lt;/p&gt;

&lt;p&gt;One thing we learned the hard way: &lt;strong&gt;don't send the full photo object through the WebSocket&lt;/strong&gt;. Early on, we sent the base64 thumbnail data in the event payload. With 50 new photos arriving in a burst, that was 50 × ~60KB = 3MB of data hitting every connected client simultaneously. Now we send only the CDN URL and metadata — clients fetch the thumbnail image from the CDN, which handles concurrent requests far better than a WebSocket connection.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://picshots.io/sitemap/real-time-photo-sharing.xml" rel="noopener noreferrer"&gt;Real-time photo sharing&lt;/a&gt; on Picshots follows exactly this pattern — metadata over WebSocket, image bytes over CDN.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Failure Gracefully
&lt;/h2&gt;

&lt;p&gt;At 500+ concurrent uploads, things will fail. The question isn't how to prevent failure — it's how to handle it without degrading the experience.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upload failures:&lt;/strong&gt; Client-side retry with exponential backoff (1s, 2s, 4s, max 3 attempts). If all retries fail, show a retry button on the failed upload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker crashes:&lt;/strong&gt; Redis Streams consumer groups with pending entries lists (PEL) ensure unprocessed jobs are re-delivered to another worker. We set an acknowledgement timeout of 60 seconds — if a worker doesn't ACK a job in 60s, it's considered dead and the job is requeued.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database failures:&lt;/strong&gt; Workers retry DB inserts 3 times with 1s backoff. If all retries fail, the job is moved to a dead letter queue for manual inspection. The photo doesn't appear in the gallery, but the upload isn't lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket disconnections:&lt;/strong&gt; Clients auto-reconnect with backoff. On reconnect, they fetch any photos they missed via a REST endpoint that accepts &lt;code&gt;?after_timestamp=&lt;/code&gt; as a query parameter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Performance Numbers
&lt;/h2&gt;

&lt;p&gt;Here's where we landed after all three iterations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upload throughput:&lt;/strong&gt; 500 concurrent uploads accepted and enqueued in under 8 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing latency:&lt;/strong&gt; P50 of 3.2 seconds from upload completion to photo appearing in the gallery. P95 of 12 seconds under full load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket fan-out:&lt;/strong&gt; 500 connected clients receive a gallery update in under 50ms (Redis adapter + Socket.io rooms).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory per worker:&lt;/strong&gt; ~80MB (Sharp's libvips is memory-efficient for single-image processing).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total infrastructure cost:&lt;/strong&gt; ~$45/month for a setup that handles 500 concurrent uploads (3 gateway instances, 2-8 workers, Redis, Postgres, R2 storage).&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, three things would change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use a managed queue from day one.&lt;/strong&gt; We started with in-process queueing, then moved to Redis lists, then to Redis Streams. If we'd started with BullMQ or even SQS, we'd have saved two weeks of rework on retry logic and dead letter handling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stream uploads directly to object storage.&lt;/strong&gt; We save the raw upload to disk, then the worker reads it from disk. If we'd streamed directly from the upload gateway to R2 (presigned URLs), we'd eliminate the disk I/O bottleneck entirely. This is on the roadmap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure earlier.&lt;/strong&gt; We didn't add proper monitoring (Prometheus + Grafana) until iteration 3. Having P50/P95 latency dashboards from the start would have caught the event-loop blocking problem in iteration 1 much faster.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why Redis Streams instead of RabbitMQ or Kafka?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Redis Streams gave us consumer groups, pending entry tracking, and dead letter handling with minimal infrastructure. We already needed Redis for Socket.io's adapter, so using it for the queue too meant one less system to operate. For 500 uploads/second, Redis is more than sufficient. Kafka would make sense at 50,000+/second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you handle duplicate uploads?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We generate a hash of the file content (SHA-256) on the upload gateway before enqueuing. The worker checks this hash against PostgreSQL before processing. If a match exists, the worker skips processing and returns the existing photo URL. This handles accidental double-submits from flaky mobile connections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about image moderation?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We integrated AWS Rekognition's content moderation API as an optional pipeline step. When enabled for a gallery, each processed photo is scanned before publishing. Photos flagged with high confidence are held for manual review. This adds ~200ms per image but is worth it for public-facing galleries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can this architecture handle 5,000+ uploads?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The architecture scales horizontally — more gateway instances, more workers, bigger Redis instance. The real constraint at 5,000+ is the database write path. We'd likely need to batch insert photo records or move to a write-optimized store (like ClickHouse) for the metadata layer. The queue and processing layers would scale fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Building a real-time photo gallery that handles 500+ simultaneous uploads taught me more about system design than any tutorial ever could. The journey from a single Express server that died at 50 uploads to a horizontally-scaled architecture handling 500+ was three months of trial, error, and incremental improvement.&lt;/p&gt;

&lt;p&gt;The principles that got us there are universal: separate the hot path from the slow path, use queues to absorb bursts, scale workers horizontally, keep WebSocket payloads tiny, and handle failure explicitly at every layer. If you're building something similar, I hope this saves you a few of the dead ends I hit.&lt;/p&gt;

&lt;p&gt;The gallery engine I built is now powering live photo experiences on &lt;a href="https://picshots.io" rel="noopener noreferrer"&gt;Picshots&lt;/a&gt;. If you want to see it in action or have questions about the architecture, reach out — I love talking about this stuff.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>realtime</category>
      <category>node</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 22:00:05 +0000</pubDate>
      <link>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-5975</link>
      <guid>https://dev.to/morpheus1537/how-i-built-a-no-app-photo-sharing-platform-using-just-qr-codes-and-browser-cameras-5975</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"&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 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 the 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="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;/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 javascript"&gt;&lt;code&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;torch&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;torch&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="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 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 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://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;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;400&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;M&lt;/span&gt;&lt;span class="dl"&gt;'&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;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/pricing" 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"&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;p&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;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&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;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fewer dependencies = fewer breaking changes.&lt;/strong&gt; I wanted this to work for years without maintenance.&lt;/p&gt;&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 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="nx"&gt;videoTrack&lt;/span&gt;&lt;span class="p"&gt;)&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ImageCapture&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;imageCapture&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;ImageCapture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoTrack&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="nx"&gt;imageCapture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;takePhoto&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;imageWidth&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;imageHeight&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="k"&gt;return&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;span class="c1"&gt;// Canvas fallback for browsers without ImageCapture&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;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="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;video&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="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="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="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="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="p"&gt;}&lt;/span&gt;

&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 javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Server (Node.js + Express)&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;get&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/:id/live&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="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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Connection&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keep-alive&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;listener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photo&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`data: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nx"&gt;eventEmitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`photo:&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;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;listener&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="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&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;eventEmitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;off&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`photo:&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;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;listener&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;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 javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isInAppBrowser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; 
  &lt;span class="sr"&gt;/FBAN|FBAV|Instagram|Twitter|Line/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&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;userAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="p"&gt;(&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;standalone&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; 
   &lt;span class="sr"&gt;/Safari/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&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;userAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
   &lt;span class="sr"&gt;/iPhone/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&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;userAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
   &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/CriOS|FxiOS|OPiOS|mercury/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&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;userAgent&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&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 javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uploadQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;uploading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;processQueue&lt;/span&gt;&lt;span class="p"&gt;()&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;uploading&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;uploadQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&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="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;uploading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&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="nx"&gt;eventId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;uploadQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&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="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="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="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;uploading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;processQueue&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 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"&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 javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;detector&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;BarcodeDetector&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qr_code&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;barcodes&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;detector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageBitmap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;p&gt;CameragetUserMedia + ImageCapture APINative browser APIs, no plugins&lt;br&gt;
QR Generationqrcode (npm)82M monthly downloads, battle-tested&lt;br&gt;
FrontendVanilla JS (~12KB)Fast load on slow event WiFi&lt;br&gt;
Real-timeServer-Sent EventsSimpler than WebSockets, auto-reconnect&lt;br&gt;
StorageS3-compatible + WebP thumbsCheap, scalable, fast gallery loads&lt;br&gt;
OfflineService Worker + IndexedDBWorks when venue WiFi doesn't&lt;br&gt;
HostingNode.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're planning an event, see how the &lt;a href="https://picshots.app/guest-photo-upload-app" rel="noopener noreferrer"&gt;guest photo upload experience&lt;/a&gt; eliminates the friction of app downloads entirely.&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>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 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>
  </channel>
</rss>
