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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Last month, I was at a friend's wedding. 200 guests, all with smartphones, and the couple wanted one thing: a shared photo album everyone could contribute to without downloading anything. No app store. No sign-up. No "create an account to view these photos." Just point, shoot, and share.&lt;/p&gt;

&lt;p&gt;I looked at existing solutions. Google Photos shared albums require a Google account. Wedding-specific apps like The Guest push you through app store installs. WhatsApp groups compress images to oblivion. Dropbox file requests still need the Dropbox app for a smooth mobile experience.&lt;/p&gt;

&lt;p&gt;So I built something different: a web app that uses QR codes for instant access and the browser's camera API for photo capture. Zero installs. Zero accounts. One QR code.&lt;/p&gt;

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

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

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

&lt;p&gt;The flow is dead simple: the host generates a QR code from the dashboard, prints it or displays it on a screen, guests scan it with their phone camera, and they're instantly on the capture page — no typing URLs, no app installs.&lt;/p&gt;

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

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

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

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

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

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Error correction level matters.&lt;/strong&gt; I started with 'L' (7% recovery) and had guests at an outdoor evening event struggling because the printed QR code got slightly smudged. Bumping to 'H' (30%) made the codes slightly denser but dramatically more reliable in the real world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL length vs. QR density.&lt;/strong&gt; The longer your URL, the denser the QR code. I kept event IDs to 8-character nanoids instead of UUIDs, which kept the URLs short and the QR codes scannable from across a room.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic vs. static QR codes.&lt;/strong&gt; I generate QR codes dynamically per event rather than pre-generating them. Each event gets a unique URL with a short-lived token embedded, so even if someone leaks the QR code, it expires with the event.&lt;/p&gt;

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

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

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

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



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

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

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

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;iOS Safari and the "green dot" paranoia.&lt;/strong&gt; Starting with iOS 14, Safari shows a prominent green dot when the camera is active. Some guests thought they were being recorded continuously. I added a clear UI indicator — a pulsing red circle with "Camera active — photo captured only when you tap" — which eliminated the confusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android Chrome and autofocus.&lt;/strong&gt; On mid-range Android devices, the camera would hunt for focus endlessly, draining battery. I added a one-shot autofocus trigger on tap-to-capture:&lt;br&gt;
&lt;/p&gt;

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

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

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

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

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The orientation nightmare.&lt;/strong&gt; Photos taken in portrait mode would appear rotated when displayed. The EXIF orientation data from &lt;code&gt;getUserMedia&lt;/code&gt; is inconsistent across browsers. My solution: read the image on a canvas, detect the actual dimensions, and rotate server-side if needed. Not elegant, but it works everywhere.&lt;/p&gt;

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;p&gt;&lt;strong&gt;No app store friction.&lt;/strong&gt; The average person installs zero new apps per month. Asking 200 wedding guests to install something is a non-starter. A URL behind a QR code has zero friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No account creation.&lt;/strong&gt; Every sign-up form loses 60-80% of users. My platform uses event-based tokens: the QR code URL contains a short-lived JWT that authenticates the guest to that specific event. No email, no password, no social login.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No data retention anxiety.&lt;/strong&gt; Photos auto-delete after 30 days. Guests know their photos aren't being mined for training data or sold to advertisers. The privacy model is: "your photos, your event, then gone."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progressive Web App as the sweet spot.&lt;/strong&gt; I added a service worker so returning guests get a slightly faster load, but I deliberately didn't push "Add to Home Screen." The whole point is that you shouldn't need to.&lt;/p&gt;

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

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

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

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

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

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

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

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

&lt;/div&gt;



&lt;p&gt;No user management. No complex permissions. No real-time features (those came later, and honestly, they weren't worth the complexity for the use case).&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. iOS WebRTC permissions are per-session.&lt;/strong&gt; Every time a guest switches away from Safari and comes back, the camera permission prompt fires again. There's no way around this — it's an OS-level security decision. I added a prominent "Tap to re-enable camera" button that appears when the stream disconnects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Large photo uploads on slow connections.&lt;/strong&gt; Wedding venues often have terrible cell reception. A 12MP photo is 3-8MB. On EDGE-speed connections, that's a 2-minute upload. I added client-side compression:&lt;br&gt;
&lt;/p&gt;

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

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

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;3. The "someone took a photo of the floor" problem.&lt;/strong&gt; Without moderation, photo albums fill with accidental shots. I added an optional blur-detection pass using the Canvas API to flag likely-bad photos, but ultimately the best solution was giving the event host a simple moderation dashboard.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;Use S3 from day one.&lt;/strong&gt; Local filesystem storage works until you need to scale or back up. Migrating 50GB of photos from a DigitalOcean volume to S3-compatible storage mid-project was not fun.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a loading skeleton immediately.&lt;/strong&gt; The camera initialization takes 1-3 seconds on most devices. Without a skeleton UI, guests thought the page was broken. A simple pulsing placeholder would have saved me dozens of "it's not working" messages.&lt;/p&gt;

&lt;p&gt;You might also like our &lt;a href="https://picshots.app/for/weddings" rel="noopener noreferrer"&gt;wedding guest camera&lt;/a&gt;. You might also find our create your event useful.&lt;strong&gt;Test on older devices earlier.&lt;/strong&gt; I developed on an iPhone 15 and Pixel 8. The first real-world test was at a family gathering where my aunt's iPhone 8 couldn't maintain a stable video stream. Turns out, requesting 1080p on older devices causes the stream to stutter. Adding a capability check and falling back to 720p fixed it.&lt;/p&gt;

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

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

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

&lt;p&gt;The best feedback came from a bride who said: "My 78-year-old grandmother figured it out in 30 seconds. She's never used anything but the Phone and Messages apps."&lt;/p&gt;

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

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

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

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

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

&lt;p&gt;If you're building something similar, start with a single HTML file that opens the camera and uploads to a basic endpoint. You'll have a working prototype in an afternoon. Everything else — the QR codes, the event management, the gallery view — is just polish on top of that core loop.&lt;/p&gt;

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

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