<?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: Nacho González</title>
    <description>The latest articles on DEV Community by Nacho González (@nchgzl).</description>
    <link>https://dev.to/nchgzl</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3896636%2Fe1426abb-88a3-4726-8807-52fe5df14b81.jpg</url>
      <title>DEV Community: Nacho González</title>
      <link>https://dev.to/nchgzl</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nchgzl"/>
    <language>en</language>
    <item>
      <title>QR Code Security Best Practices for Platforms</title>
      <dc:creator>Nacho González</dc:creator>
      <pubDate>Fri, 15 May 2026 08:51:19 +0000</pubDate>
      <link>https://dev.to/nchgzl/qr-code-security-best-practices-for-platforms-3fn6</link>
      <guid>https://dev.to/nchgzl/qr-code-security-best-practices-for-platforms-3fn6</guid>
      <description>&lt;p&gt;Most guides about QR code security focus on the wrong end of the problem. They tell end users to "check the URL before tapping" while the platforms generating those codes do almost nothing to screen what they produce. End-user vigilance is necessary but not sufficient. &lt;strong&gt;QR code security best practices for platforms start before a code is ever generated: validating destinations against threat feeds, enforcing HTTPS, auditing redirect chains, monitoring scan anomalies, and maintaining tamper-evident audit logs. These controls sit at the platform layer, not the scanner layer. That's what separates a trusted QR infrastructure from one that can be weaponized.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Platform-level security acts before a QR code is generated — not after a user scans something suspicious.&lt;/li&gt;
&lt;li&gt;The six core controls: URL validation, HTTPS enforcement, redirect chain auditing, scan anomaly monitoring, rate limiting, and audit logs.&lt;/li&gt;
&lt;li&gt;Most platforms skip redirect chain inspection and scan anomaly alerting — two of the highest-value controls for detecting abuse early.&lt;/li&gt;
&lt;li&gt;Enterprise deployments additionally need domain allowlists, role-based access, expiry policies, and compliance-ready audit exports.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&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%2Fqrcodenova.com%2Fblog%2Fimages%2Fqr-code-security-best-practices-platform%2Ffeatured.webp" 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%2Fqrcodenova.com%2Fblog%2Fimages%2Fqr-code-security-best-practices-platform%2Ffeatured.webp" alt="QR code security best practices for platforms — padlock, shield, dashboard, and QR code sketches with the text " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Platform Security Is the Overlooked Half of QR Code Safety
&lt;/h2&gt;

&lt;p&gt;QR code phishing (quishing) grew 400% between 2023 and 2025, according to data compiled by Keepnet Labs. The vast majority of the defensive effort went into educating users — how to preview URLs, how to spot lookalike domains. That's correct advice but it treats the symptom, not the source.&lt;/p&gt;

&lt;p&gt;The source is often the generation platform. A platform with no URL validation will happily generate a code pointing to a known phishing domain. A platform with no rate limiting becomes a factory for attackers who create thousands of short-lived codes. A platform with no scan anomaly monitoring gives administrators no signal when one of their codes gets hijacked by a sticker overlay or link substitution.&lt;/p&gt;

&lt;p&gt;End-user security training has a ceiling. Platform-layer controls don't. Every enforcement decision made at the platform level applies to every code, every scan, and every user simultaneously, without requiring anyone to remember to check a URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  URL Validation Before QR Code Generation
&lt;/h2&gt;

&lt;p&gt;URL validation is the first line of defense. A secure platform checks every destination URL before issuing a code, against at minimum three layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Threat intelligence feeds&lt;/strong&gt;: Google Safe Browsing, URLhaus, and PhishTank collectively cover hundreds of millions of known malicious domains. Blocking these at generation time prevents the code from ever existing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protocol enforcement&lt;/strong&gt;: HTTPS-only. HTTP destinations expose every scanner to a man-in-the-middle attack on unprotected networks. There is no valid reason for a production QR code to point to an HTTP URL in 2026.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain reputation scoring&lt;/strong&gt;: Newly registered domains (under 30 days old), domains with no MX records, and domains using lookalike Unicode characters all carry elevated risk and warrant flagging or blocking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Validation should run at creation and on every edit. A code pointing to a safe URL today can be retargeted tomorrow if the platform allows destination changes without re-validation.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Most Platforms Skip
&lt;/h3&gt;

&lt;p&gt;Most platforms check the URL format (is it a valid URL structure?) but not the URL content (is this destination safe?). Checking format is a developer convenience. Checking content is a security control. These are not the same thing.&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%2Fqrcodenova.com%2Fblog%2Fimages%2Fqr-code-security-best-practices-platform%2Fvisual-01.webp" 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%2Fqrcodenova.com%2Fblog%2Fimages%2Fqr-code-security-best-practices-platform%2Fvisual-01.webp" alt="URL validation pipeline diagram: URL input → threat feed lookup → HTTPS check → domain reputation score → certificate validation → PASS or FAIL" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS Enforcement and TLS Certificate Validation
&lt;/h2&gt;

&lt;p&gt;HTTPS enforcement is a hard requirement for any platform used in a business context. The practical implementation goes further than just requiring the &lt;code&gt;https://&lt;/code&gt; prefix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validate that the destination certificate is valid and not self-signed&lt;/li&gt;
&lt;li&gt;Check that the certificate common name matches the destination domain&lt;/li&gt;
&lt;li&gt;Block destinations with certificates that expired within the last 90 days (the operator may not have renewed)&lt;/li&gt;
&lt;li&gt;Flag destinations using certificates from CA providers with a history of mis-issuance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A platform that generates a code pointing to an expired certificate sends a false signal. Users see "HTTPS" and assume safety. A certificate check at generation time catches this before anyone scans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redirect Chain Auditing
&lt;/h2&gt;

&lt;p&gt;Redirect chain auditing traces the full sequence of HTTP 301/302 responses triggered when a QR code destination is accessed. A secure platform records every hop, not just the first URL entered.&lt;/p&gt;

&lt;p&gt;The exploit is domain laundering: an attacker registers a clean-looking URL, sets it as the QR destination, then adds a redirect to a malicious page. The platform's one-time URL check passes. The malicious destination is delivered on first scan. Without continuous redirect chain inspection, this attack stays invisible until a user reports it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to Monitor in a Redirect Chain
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Root domain changes&lt;/strong&gt;: The final URL should share the root domain with the entered URL. A redirect from &lt;code&gt;brand.com&lt;/code&gt; to &lt;code&gt;login-brand.com&lt;/code&gt; or &lt;code&gt;brand.malicious.site&lt;/code&gt; is a red flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chain length&lt;/strong&gt;: Legitimate destinations rarely redirect more than 2 times. Chains of 4+ hops are common in ad-fraud and phishing infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party intermediaries&lt;/strong&gt;: A link shortener inside a redirect chain for a branded QR code is a red flag. It adds a control point the platform owner doesn't govern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed content&lt;/strong&gt;: HTTPS-to-HTTP transition mid-chain nullifies the protection of the initial HTTPS check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;According to research from QR-Insights published in 2025, approximately 23% of malicious QR codes in the wild use at least one redirect hop that crosses to a different root domain than the one encoded in the code itself. Continuous chain monitoring catches these cases. One-time generation checks do not.&lt;/p&gt;

&lt;p&gt;Dynamic QR codes make continuous redirect chain auditing feasible because the platform controls the intermediate resolution layer: every redirect can be inspected server-side before the scanner's device ever reaches the final destination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scan Anomaly Monitoring
&lt;/h2&gt;

&lt;p&gt;Scan anomaly monitoring treats each QR code as having a behavioral baseline and alerts when scans deviate significantly from it.&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%2Fqrcodenova.com%2Fblog%2Fimages%2Fqr-code-security-best-practices-platform%2Fvisual-03.webp" 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%2Fqrcodenova.com%2Fblog%2Fimages%2Fqr-code-security-best-practices-platform%2Fvisual-03.webp" alt="Scan anomaly chart showing steady baseline scan volume with a sharp spike at day 20 and an alert bell icon above the spike" width="800" height="400"&gt;&lt;/a&gt; This catches two distinct threat scenarios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Code compromise&lt;/strong&gt;: An attacker places a sticker over a physical QR code with their own code. The original code's scan count flatlines while a new attack code gets high volume. The platform can't see the attacker's code directly, but it can see the drop in expected scans on the original.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coordinated attack infrastructure&lt;/strong&gt;: A compromised account generating many codes and distributing them at scale will show unusual scan volume patterns — hundreds of codes each getting a burst of scans from the same geographic cluster or device fingerprint.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Baseline Parameters Worth Tracking
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Scan volume per hour, with rolling 30-day median and standard deviation&lt;/li&gt;
&lt;li&gt;Geographic distribution — expected vs. actual country and city breakdown&lt;/li&gt;
&lt;li&gt;Device and OS mix — a code on a physical restaurant table should show a spread of mobile devices, not 98% of one OS version&lt;/li&gt;
&lt;li&gt;Time-of-day patterns — a retail QR code with consistent 3am UTC scan activity needs investigation&lt;/li&gt;
&lt;li&gt;User agent entropy — extremely low diversity in user agent strings suggests automated scanning, not real users&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Enterprise platforms should alert on deviations exceeding three standard deviations from 30-day rolling baselines, with a minimum scan count threshold to avoid noise on low-volume codes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate Limiting and Abuse Prevention
&lt;/h2&gt;

&lt;p&gt;Rate limiting on QR code generation is typically the last control operators add, usually after an abuse incident. Define limits before abuse happens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generation Rate Limits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Free-tier accounts: limit to a low per-hour ceiling (typically 10-20 codes per hour, 50-100 per day)&lt;/li&gt;
&lt;li&gt;API keys: per-key limits with automatic suspension above configurable thresholds&lt;/li&gt;
&lt;li&gt;Bulk generation: require explicit justification or approval for batch requests above enterprise thresholds&lt;/li&gt;
&lt;li&gt;IP-based limits: rate limit by IP for unauthenticated requests, regardless of account limits&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Scan-Side Rate Limits
&lt;/h3&gt;

&lt;p&gt;Rate limiting also applies to scan resolution. A dynamic QR code that can be redirected instantly is also a code that could be weaponized instantly after a redirect change goes live. Platforms should:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cache scan destinations for a short TTL (30-60 seconds) to prevent real-time redirect manipulation during attacks&lt;/li&gt;
&lt;li&gt;Rate limit redirect resolution per source IP to mitigate automated scanner abuse&lt;/li&gt;
&lt;li&gt;Require re-authentication for destination URL changes on high-value or high-scan-volume codes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Domain Allowlists for Enterprise Deployments
&lt;/h2&gt;

&lt;p&gt;Domain allowlists restrict which destination domains QR codes in an organization's workspace can resolve to. This single control eliminates most insider threat and accidental misuse scenarios.&lt;/p&gt;

&lt;p&gt;A media company running QR codes across 10,000 print placements can configure their workspace to only allow codes pointing to their own domains and a pre-approved set of CDN and partner domains. Any team member who tries to create a code pointing outside that list gets a hard block, not a warning they can click through.&lt;/p&gt;

&lt;p&gt;The implementation should operate at the root domain level, not the individual URL level. A list of exact allowed URLs collapses under its own weight within weeks. A list of 20 allowed root domains is sustainable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic vs. Static Allowlist Enforcement
&lt;/h3&gt;

&lt;p&gt;For dynamic codes, allowlist enforcement needs to apply on every destination edit, not just at creation time. A code created pointing to an allowed domain can be edited to point elsewhere if the platform only checks at generation. Enforce the allowlist on every save, not just the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit Logs for Enterprise Compliance
&lt;/h2&gt;

&lt;p&gt;Enterprise QR code deployments sit within compliance scopes that require demonstrable controls. ISO/IEC 27001:2022 requires logging, monitoring, and role-based access. NIST SP 800-53 mandates event audit records for investigations. GDPR Article 32 requires demonstrable access controls for systems processing personal data, and a QR code tracking scans is processing personal data by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a Compliant Audit Log Includes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Code creation: timestamp, creator user ID, initial destination URL, workspace/team&lt;/li&gt;
&lt;li&gt;Every destination edit: timestamp, editor user ID, old URL, new URL&lt;/li&gt;
&lt;li&gt;Deactivation and reactivation events: timestamp, actor&lt;/li&gt;
&lt;li&gt;Access control changes: who was granted or revoked edit/view rights&lt;/li&gt;
&lt;li&gt;Bulk operations: export requests, mass deactivations, API key issuance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Logs must be tamper-evident: append-only, cryptographically signed, or stored in a system the application layer cannot modify. An audit log the application can overwrite is not an audit log for compliance purposes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retention and Export
&lt;/h3&gt;

&lt;p&gt;Most compliance frameworks require 12-24 months of audit log retention. GDPR's accountability principle means you need to produce records on demand. The platform should support structured exports (JSON or CSV) covering all fields needed for incident response without requiring an engineering ticket to pull the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  QR Code Expiry and Rotation Policies
&lt;/h2&gt;

&lt;p&gt;Not all QR codes should live forever. Expiry and rotation policies are security controls, not just operational housekeeping.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Physical access codes&lt;/strong&gt;: should expire with the access grant. A code printed for a one-day event should not resolve 90 days later to anything useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Financial transaction codes&lt;/strong&gt;: should expire within minutes to prevent replay attacks. A payment QR code that works indefinitely is a liability after the transaction is complete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Campaign codes&lt;/strong&gt;: should be deactivated at campaign end, not left resolving to a 404 or, worse, a domain that gets re-registered by a third party.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personnel-specific codes&lt;/strong&gt;: any code tied to an individual (business card, personal page, access pass) should be in an offboarding checklist — deactivate on the last day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rotation means issuing a new code with a new destination while deactivating the old one. Think of it like rotating a TLS certificate: you do it on a schedule, not just when something breaks. For high-security contexts, also rotate on suspected compromise, personnel changes, or any incident affecting the destination domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Physical QR Code Tampering: The Gap Platform Controls Cannot Close Alone
&lt;/h2&gt;

&lt;p&gt;Platform-layer security has one structural blind spot: it cannot detect a physical sticker attack. An attacker who places a fraudulent QR sticker over a legitimate printed code bypasses every server-side control. The original code keeps existing on the platform with no anomaly, until its scan count drops enough to trigger an alert.&lt;/p&gt;

&lt;p&gt;This is why scan anomaly monitoring matters even for organizations with strong platform controls. The only signal that a physical code has been covered is an unexpected drop in expected scan volume. Operators deploying codes in public spaces — restaurant tables, event entrances, retail displays — should set minimum scan rate alerts, not just spike alerts, so a sudden silence is as visible as a sudden flood.&lt;/p&gt;

&lt;p&gt;Beyond monitoring, the physical mitigations are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use tamper-evident label stock for high-risk physical deployments (seals that show evidence of peeling)&lt;/li&gt;
&lt;li&gt;Include a visible short URL alongside the QR code so users can verify the destination independently&lt;/li&gt;
&lt;li&gt;For permanent installations, embed the code in the surface rather than on a removable label&lt;/li&gt;
&lt;li&gt;Conduct periodic physical audits — spot-scan all codes at high-traffic locations quarterly&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When Platform Security Controls Have Limits
&lt;/h2&gt;

&lt;p&gt;Platform-level controls are more reliable than user-side security training, but they're not a complete substitute for a security culture. Here is where platform controls break down and what fills the gap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-day malicious domains&lt;/strong&gt;: Threat intelligence feeds are reactive. A newly registered domain that hasn't yet appeared in Google Safe Browsing, URLhaus, or PhishTank will pass validation. Mitigation: add domain age checks (under 30 days) and domain reputation scoring as a secondary layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compromised allowed domains&lt;/strong&gt;: A domain on your allowlist that gets compromised remains allowed. The allowlist doesn't protect against destination compromise — only redirect chain monitoring and anomaly alerting do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Insider threat&lt;/strong&gt;: An administrator with platform access can create codes bypassing controls if the platform doesn't enforce role-based access with least-privilege principles. Audit logs are the detective control here; they don't prevent malicious insider creation, but they make it attributable and reversible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API key leakage&lt;/strong&gt;: Rate limits and API key per-key suspension mitigate but don't eliminate the window of exposure between key compromise and detection. Rotate API keys on a schedule, not just on suspicion.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Secure Platforms Do vs. What Most Skip
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;What most platforms do&lt;/th&gt;
&lt;th&gt;What secure platforms do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;URL validation&lt;/td&gt;
&lt;td&gt;Format check only (is it a valid URL?)&lt;/td&gt;
&lt;td&gt;Threat feed lookup, domain reputation, HTTPS enforcement, cert validity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect handling&lt;/td&gt;
&lt;td&gt;Encode the first URL entered, no inspection&lt;/td&gt;
&lt;td&gt;Full redirect chain audit, domain change detection, chain length limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scan monitoring&lt;/td&gt;
&lt;td&gt;Count scans, display a chart&lt;/td&gt;
&lt;td&gt;Anomaly detection, geographic alerts, device entropy analysis, automated suspension&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate limiting&lt;/td&gt;
&lt;td&gt;None, or only per-account plan limits&lt;/td&gt;
&lt;td&gt;Per-IP, per-key, per-account generation limits with automatic suspension&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain control&lt;/td&gt;
&lt;td&gt;Any destination allowed&lt;/td&gt;
&lt;td&gt;Organization-level allowlists enforced at creation and every edit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit logs&lt;/td&gt;
&lt;td&gt;None, or basic creation timestamp&lt;/td&gt;
&lt;td&gt;Tamper-evident logs covering creation, edits, deactivations, access changes — exportable for compliance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expiry policies&lt;/td&gt;
&lt;td&gt;Manual deactivation only&lt;/td&gt;
&lt;td&gt;Scheduled expiry, automatic deactivation, rotation workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fqrcodenova.com%2Fblog%2Fimages%2Fqr-code-security-best-practices-platform%2Fvisual-02.webp" 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%2Fqrcodenova.com%2Fblog%2Fimages%2Fqr-code-security-best-practices-platform%2Fvisual-02.webp" alt="Comparison: Basic security (single gray shield, three lines) vs. Layered security (six stacked teal/navy bars with shield icon)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Controls That Actually Move the Needle
&lt;/h2&gt;

&lt;p&gt;If you're evaluating a QR code platform and you only have time to check three things, check these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does it validate against threat feeds at generation time and on every edit?&lt;/strong&gt; If the answer is "we check the URL format," that's a no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it audit the full redirect chain, not just the first URL?&lt;/strong&gt; Redirect laundering is the most common evasion technique. A platform that only checks the encoded URL provides incomplete protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it support tamper-evident audit logs with structured export?&lt;/strong&gt; If the platform can't produce a compliance-ready audit trail on demand, it cannot be in scope for ISO 27001 or GDPR accountability controls.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else — domain allowlists, anomaly alerting, expiry policies — layers on top of these three. Get the foundation right first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing QR Code Security Best Practices at Scale
&lt;/h2&gt;

&lt;p&gt;Security controls on a QR code platform are most effective when enforced by configuration, not by user choice. Putting a warning on a dangerous destination and asking the user to confirm is not a security control — it's a liability disclaimer. Blocking the destination is a security control.&lt;/p&gt;

&lt;p&gt;For organizations deploying QR codes across marketing, operations, payments, or physical access, the right question to ask any platform vendor is: what is enforced vs. what is optional? A platform that makes all of these controls optional and off-by-default provides them as features, not as security. A platform that enforces them at the workspace level provides them as architecture.&lt;/p&gt;

&lt;p&gt;Security at scale fails at the point of least enforcement. If one team member can create a code pointing to an unvalidated destination because the control is opt-in, the platform's threat feed integration doesn't protect you. It protects everyone else.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Last verified: May 2026. Compliance framework references (ISO/IEC 27001:2022, NIST SP 800-53, GDPR Article 32) reflect current published standards as of this date.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GS1 Digital Link QR Code Implementation: The Technical Guide</title>
      <dc:creator>Nacho González</dc:creator>
      <pubDate>Thu, 14 May 2026 16:14:20 +0000</pubDate>
      <link>https://dev.to/nchgzl/gs1-digital-link-qr-code-implementation-the-technical-guide-4ja5</link>
      <guid>https://dev.to/nchgzl/gs1-digital-link-qr-code-implementation-the-technical-guide-4ja5</guid>
      <description>&lt;p&gt;Most guides about GS1 Digital Link QR code implementation explain what it is at a high level, then stop before the part that actually matters: how to build a URI that works at checkout, passes conformance validation, and survives contact with real scanner infrastructure. That gap is where implementations break.&lt;/p&gt;

&lt;p&gt;Here is what you need to build a GS1 Digital Link QR code that works correctly, plus the five structural mistakes that produce codes which scan fine in a lab and fail in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GS1 Digital Link is not a new type of QR code. It is a standardized URI structure that turns any QR code into a dual-purpose identifier, readable by both checkout scanners and consumer smartphones from a single printed symbol.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; GS1 Digital Link encodes a GTIN (and optional qualifiers) into a web-compliant URI — one code for POS, logistics, and consumer engagement simultaneously. URI structure: &lt;code&gt;https://domain/01/{14-digit-GTIN}[/qualifier/value][?attribute=value]&lt;/code&gt;. The GS1 Sunrise 2027 deadline requires POS systems in 48+ countries to accept 2D barcodes by end of 2027 — suppliers that miss this deadline face listing risk with major retailers. The five implementation pitfalls: wrong GTIN padding, deprecated convenience alphas, over-encoding, missing conformance testing, and short-link redirect layers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What GS1 Digital Link Actually Is
&lt;/h2&gt;

&lt;p&gt;GS1 Digital Link is an international standard (ISO/IEC 18975) published by GS1, the global nonprofit that maintains supply chain identity standards including GTINs, GLNs, and SSCCs. The standard defines a URI syntax that embeds GS1 identifiers directly into a web URL path, making those identifiers both machine-readable and web-resolvable.&lt;/p&gt;

&lt;p&gt;A standard QR code encodes any arbitrary string: a URL, plain text, contact data. A GS1 Digital Link QR code encodes a URI that follows a specific structure. The same QR code can be scanned at a grocery checkout to retrieve GTIN-linked pricing and inventory data, and scanned by a consumer's smartphone to open a product detail page — without any modification between the two uses.&lt;/p&gt;

&lt;p&gt;The distinction matters for implementation. The QR code format itself (the black-and-white square) is identical to any other QR code. What differs is the string encoded inside it. If that string follows the GS1 Digital Link URI syntax, the code becomes a standards-compliant identifier. If it does not, it is just a QR code with a URL in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GS1 Digital Link URI Structure
&lt;/h2&gt;

&lt;p&gt;The canonical URI structure defined in GS1 Digital Link standard version 1.2.1 (February 2022) is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://{domain}/01/{14-digit-GTIN}[/{qualifier-AI}/{value}][?{attribute-AI}={value}]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking this down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain&lt;/strong&gt; — Can be &lt;code&gt;id.gs1.org&lt;/code&gt; (GS1's global resolver) or any domain you operate a GS1-conformant resolver on. The standard is domain-agnostic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;/01/&lt;/strong&gt; — The Application Identifier (AI) for GTIN. The forward slash before and after is required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;14-digit GTIN&lt;/strong&gt; — Always 14 digits, zero-padded on the left. A 12-digit UPC (e.g., 614141123452) becomes &lt;code&gt;00614141123452&lt;/code&gt; in the URI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qualifier AIs (path segments)&lt;/strong&gt; — Optional. Encoded in the URL path. Common qualifiers: &lt;code&gt;/10/&lt;/code&gt; (batch/lot), &lt;code&gt;/21/&lt;/code&gt; (serial number). Order follows GS1 AI concatenation rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribute AIs (query string)&lt;/strong&gt; — Optional. Encoded as query parameters. Common attributes: &lt;code&gt;17&lt;/code&gt; (expiration date, YYMMDD format), &lt;code&gt;11&lt;/code&gt; (production date).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A real-world pharmaceutical example with all fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://id.gs1.org/01/05901234123457/10/BATCH-A/21/SN0000123?17=271231
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This encodes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GTIN: &lt;code&gt;05901234123457&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Batch/Lot: &lt;code&gt;BATCH-A&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Serial number: &lt;code&gt;SN0000123&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Expiration date: December 31, 2027 (encoded as &lt;code&gt;271231&lt;/code&gt; in YYMMDD format)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A retail consumer product — a can of coffee, say — might only need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://id.gs1.org/01/09780201379624
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resolver at &lt;code&gt;id.gs1.org&lt;/code&gt; looks up that GTIN and redirects appropriately: to a product page for a consumer scan, or returns structured data for a machine scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Resolver Works
&lt;/h2&gt;

&lt;p&gt;The resolver is what makes GS1 Digital Link contextually aware. When a device scans the QR code, it sends an HTTP GET request to the resolver URL. The resolver reads the URI path (extracting the GTIN and any qualifiers), then uses request context — user agent, Accept headers, locale, or scanner-specific flags — to return the appropriate response.&lt;/p&gt;

&lt;p&gt;For a consumer smartphone:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Camera app scans QR, extracts URI: &lt;code&gt;https://id.gs1.org/01/09780201379624&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Browser sends GET request to &lt;code&gt;id.gs1.org&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Resolver identifies it as a browser request (HTML Accept header)&lt;/li&gt;
&lt;li&gt;Resolver returns HTTP 307 redirect to the brand's consumer product page&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a pharmacy dispensing system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scanner reads QR, extracts same URI&lt;/li&gt;
&lt;li&gt;System sends GET with &lt;code&gt;Accept: application/ld+json&lt;/code&gt; or similar structured-data header&lt;/li&gt;
&lt;li&gt;Resolver returns JSON-LD with product identity data, verification links, and regulatory status&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The GS1 conformant resolver specification (available at &lt;code&gt;ref.gs1.org&lt;/code&gt;) defines the exact response formats and link type vocabulary: &lt;code&gt;gs1:pip&lt;/code&gt; for product information page, &lt;code&gt;gs1:safetyInfo&lt;/code&gt;, &lt;code&gt;gs1:recallStatus&lt;/code&gt;, and others.&lt;/p&gt;

&lt;p&gt;GS1 makes its resolver source code available as open-source software. Brands that want full control can run their own resolver, use a third-party managed resolver, or route through GS1's global instance at &lt;code&gt;id.gs1.org&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Steps: From GTIN to Printed QR Code
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Obtain or Verify Your GTIN
&lt;/h3&gt;

&lt;p&gt;A GTIN is the mandatory primary key. GTINs are issued by GS1 Member Organizations — in the US through GS1 US, in the UK through GS1 UK. If your products already carry UPC or EAN barcodes, you already have GTINs; you need to zero-pad them to 14 digits.&lt;/p&gt;

&lt;p&gt;GTINs purchased from third-party resellers are often non-compliant for GS1 Digital Link. The resolver at &lt;code&gt;id.gs1.org&lt;/code&gt; validates GTIN prefix ownership. If your prefix is not registered to your company in the GS1 registry, the resolver cannot confirm brand ownership and cannot serve conformant responses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Determine Which Qualifiers You Need
&lt;/h3&gt;

&lt;p&gt;The qualifiers you include are driven by your use case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retail consumer engagement only&lt;/strong&gt;: GTIN alone. URI ends after the 14-digit GTIN. Keeps the code short and the QR module density low.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retail with fresh/perishable inventory&lt;/strong&gt;: GTIN + batch (&lt;code&gt;/10/&lt;/code&gt;) + expiration (&lt;code&gt;?17=&lt;/code&gt;). Allows store systems to track lot-level freshness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pharmaceutical (EU FMD / US DSCSA)&lt;/strong&gt;: GTIN + lot (&lt;code&gt;/10/&lt;/code&gt;) + serial (&lt;code&gt;/21/&lt;/code&gt;) + expiry (&lt;code&gt;?17=&lt;/code&gt;). Full four-component encoding for unit-level traceability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logistics shipping label&lt;/strong&gt;: SSCC-based URI using AI &lt;code&gt;/00/&lt;/code&gt; as the primary key instead of GTIN.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Encoding more qualifiers than necessary is one of the most common mistakes in the field. Each added qualifier increases URI length, increases QR code density, and ties the printed code to data that may change. Codes that encode expiry dates have a fixed shelf life — when the expiry date passes, the code still scans but returns stale qualifier data that may confuse conformant readers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Choose Your Resolver Strategy
&lt;/h3&gt;

&lt;p&gt;Three options exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GS1 Global Resolver (id.gs1.org)&lt;/strong&gt;: Works immediately for brands with registered GTINs. Limited customization of redirect targets without additional GS1 registry entries. Best for brands just starting with Digital Link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party managed resolver&lt;/strong&gt;: Services like Digimarc and Sato offer managed resolver infrastructure. You control redirect targets, link types, and analytics without running your own servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Own resolver&lt;/strong&gt;: Maximum control. GS1 provides open-source resolver software and a conformance test suite. Requires server infrastructure and ongoing maintenance. Right for large brands with dedicated engineering teams.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 4: Generate and Validate the URI
&lt;/h3&gt;

&lt;p&gt;Before encoding into a QR code, validate the URI against GS1's Digital Link validator at &lt;code&gt;ref.gs1.org/tools/gs1-digital-link-toolkit&lt;/code&gt;. The toolkit checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GTIN structure and check digit&lt;/li&gt;
&lt;li&gt;AI ordering (primary key before qualifiers before attributes)&lt;/li&gt;
&lt;li&gt;Character set conformance for each AI's value&lt;/li&gt;
&lt;li&gt;Correct use of path vs. query string positions for each AI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the URI fails validation, it will still encode into a QR code — the QR format has no awareness of the URI contents. The failure surfaces at the resolver, in scanner software that validates GS1 structure, or in conformance audits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Generate the QR Code at Correct Specification
&lt;/h3&gt;

&lt;p&gt;GS1 recommends QR Code Model 2, Error Correction Level M (15% recovery) for most packaged goods. For pharmaceutical serialized codes where module density is high and print precision matters, Error Correction Level H (30%) is recommended.&lt;/p&gt;

&lt;p&gt;Minimum size for reliable scanning: 15mm x 15mm at 150 dpi. For retail environments where codes may be scanned at distance or in poor lighting, 20mm x 20mm or larger. Quiet zone: 4 modules on all sides, minimum.&lt;/p&gt;

&lt;p&gt;The QR code must encode the URI as UTF-8. Do not add any GS1 Application Identifier prefix string before the domain — the URI format makes the GTIN extractable without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Use Cases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Retail: The Sunrise 2027 Transition
&lt;/h3&gt;

&lt;p&gt;GS1's Sunrise 2027 initiative requires POS systems in 48+ countries to accept 2D barcodes by the end of 2027. Walmart, Carrefour, Woolworths, and Albertsons have all published 2D-barcode acceptance roadmaps. Suppliers that cannot deliver scannable GS1 Digital Link QR codes face listing risk with their largest accounts.&lt;/p&gt;

&lt;p&gt;For retail, the key constraint is that the same code must checkout-scan correctly at existing POS infrastructure while also serving consumer engagement through the resolver. The GS1 Digital Link URI satisfies both simultaneously — existing GS1 Application Identifier parsing software extracts the GTIN from the URI path without any modification.&lt;/p&gt;

&lt;h3&gt;
  
  
  Healthcare: EU FMD and US DSCSA Compliance
&lt;/h3&gt;

&lt;p&gt;The EU Falsified Medicines Directive requires unique identifier verification on prescription medicines. The US Drug Supply Chain Security Act requires unit-level traceability. Both require GTIN + lot + serial + expiry, the full four-component GS1 Digital Link encoding.&lt;/p&gt;

&lt;p&gt;A GS1 Digital Link QR code on a pharmaceutical pack satisfies both regulatory frameworks from a single symbol, and simultaneously allows pharmacists to scan the code to retrieve patient-facing product information through the resolver. The EU Digital Product Passport regulation, entering phased enforcement from 2027, adds a fifth use case: consumers accessing sustainability and circularity data through the same code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Logistics: Scan4Transport
&lt;/h3&gt;

&lt;p&gt;The GS1 Scan4Transport (S4T) standard applies GS1 Digital Link URI syntax to logistics labels, using SSCC (Serial Shipping Container Code, AI 00) as the primary key. A pallet label encoded with a GS1 Digital Link URI allows logistics operators to scan with any 2D-capable reader and immediately access shipment documentation, chain of custody records, and customs data — without proprietary scanner software or EDI lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Pitfalls That Break Production Deployments
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pitfall 1: Incorrect GTIN Padding
&lt;/h3&gt;

&lt;p&gt;Every GTIN in a GS1 Digital Link URI must be exactly 14 digits, zero-padded on the left. A 12-digit UPC like &lt;code&gt;614141123452&lt;/code&gt; must appear as &lt;code&gt;00614141123452&lt;/code&gt;. Generating the code without padding produces a URI that fails AI parsing in conformant scanners, even though the QR code scans and the browser opens correctly. The resolver may accept it, but the code fails standards compliance audits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 2: Deprecated Convenience Alphas
&lt;/h3&gt;

&lt;p&gt;Early versions of the standard allowed named aliases: &lt;code&gt;/gtin/&lt;/code&gt; instead of &lt;code&gt;/01/&lt;/code&gt;, &lt;code&gt;/lot/&lt;/code&gt; instead of &lt;code&gt;/10/&lt;/code&gt;. These "convenience alphas" were removed from GS1 Digital Link 1.2.1 and will not appear in future versions. Any implementation using them produces non-conformant URIs that fail validation. If your implementation vendor's code generator still outputs &lt;code&gt;/gtin/&lt;/code&gt; paths, require them to update.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 3: Over-Encoding Mutable Data
&lt;/h3&gt;

&lt;p&gt;Encoding promotion codes, campaign identifiers, or current-season marketing data in the GS1 Digital Link URI path locks that data into the printed symbol. When the promotion ends, every printed package carries a URI with stale qualifier data. Use the resolver to serve context-appropriate content. The URI should encode stable identity data only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 4: Skipping Conformance Testing
&lt;/h3&gt;

&lt;p&gt;GS1 provides a Digital Link Toolkit at &lt;code&gt;ref.gs1.org&lt;/code&gt; that validates URI structure. Many implementations skip this step, assuming that if the QR code scans and the page opens, it is correct. It is not. Conformance testing catches GTIN check-digit errors, AI ordering violations, and character set problems before they reach print runs. At scale (millions of packages), a conformance error requires a packaging recall. Testing costs nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall 5: Short-Link Redirect Layer on Top of the URI
&lt;/h3&gt;

&lt;p&gt;Some implementations route the GS1 Digital Link URI through a URL shortener or redirect service: the QR code encodes &lt;code&gt;https://bit.ly/xyz&lt;/code&gt; which redirects to &lt;code&gt;https://id.gs1.org/01/00614141123452&lt;/code&gt;. This breaks everything. The short link is not a GS1 Digital Link URI. Conformant scanners cannot extract structured identifier data from a redirect. POS systems that parse the scanned string for GTIN data receive a short URL with no parseable AI structure. Use the GS1 Digital Link URI directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  When GS1 Digital Link Is Not What You Need
&lt;/h2&gt;

&lt;p&gt;GS1 Digital Link is the right choice if your product requires POS checkout integration, regulatory traceability, or supply chain visibility that relies on GTIN-structured data. It is not the right choice for every QR code use case.&lt;/p&gt;

&lt;p&gt;Restaurant menus, event registrations, business cards, promotional landing pages — none of these need GS1 Digital Link. A standard dynamic QR code pointing to a URL gives you redirect flexibility without the implementation complexity of resolver infrastructure, GTIN registration, and AI ordering rules.&lt;/p&gt;

&lt;p&gt;Think of it this way: GS1 Digital Link is a supply chain identity standard that happens to use QR codes. Standard QR codes are a communication format that happens to be convenient for consumer engagement. Mixing them up produces either unnecessary complexity or non-compliance.&lt;/p&gt;

&lt;p&gt;For GS1 Digital Link, you need a GTIN, a resolver, and a conformant URI — that infrastructure is separate from general-purpose QR generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Verify a GS1 Digital Link QR Code
&lt;/h2&gt;

&lt;p&gt;Before printing at scale, run these three checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;URI structure validation&lt;/strong&gt;: Paste the URI into the GS1 Digital Link Toolkit at &lt;code&gt;ref.gs1.org/tools/gs1-digital-link-toolkit&lt;/code&gt;. It confirms GTIN check digit, AI ordering, qualifier positions, and character set conformance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolver resolution test&lt;/strong&gt;: Scan the QR code with a standard smartphone camera and confirm it resolves to the correct consumer page. Then test with a GS1 conformant resolver test tool (available in the GS1 Digital Link Toolkit) to confirm structured data responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;POS scan simulation&lt;/strong&gt;: Use GS1's scan engine test (or a compatible point-of-sale emulator) to confirm the GTIN is correctly extracted from the URI path. This is the test that matters for Sunrise 2027 compliance.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Business Case: One Code for Every Purpose
&lt;/h2&gt;

&lt;p&gt;Before GS1 Digital Link, brands running multiple compliance, supply chain, and consumer engagement programs typically printed multiple codes or separate barcodes for each function. A pharmaceutical pack might carry a 1D barcode for FMD verification, a 2D DataMatrix for lot and serial tracking, and a separate QR code for patient-facing product information.&lt;/p&gt;

&lt;p&gt;GS1 Digital Link collapses all three into one symbol. For brand owners, that is a direct reduction in label real estate, a simpler print production workflow, and a single point of truth for product identity across every supply chain touchpoint.&lt;/p&gt;

&lt;p&gt;According to GS1 data cited in its 2024 Sunrise 2027 preparedness report, fewer than 30% of consumer packaged goods companies had completed a GS1 Digital Link readiness assessment as of Q4 2024. The Sunrise 2027 deadline is not a distant planning horizon. For brands that need to update packaging artwork, run conformance testing, and onboard resolver infrastructure, the effective implementation window is now.&lt;/p&gt;

&lt;p&gt;GS1's implementation guideline (available at &lt;a href="https://gs1.org/docs/Digital-Link/GS1_DigitalLink_Imp_Guide_i1.pdf" rel="noopener noreferrer"&gt;gs1.org/docs/Digital-Link/GS1_DigitalLink_Imp_Guide_i1.pdf&lt;/a&gt;) is the definitive reference for conformant GS1 Digital Link deployment.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why QR Codes Expire (It's Not the Code — It's the Server)</title>
      <dc:creator>Nacho González</dc:creator>
      <pubDate>Mon, 11 May 2026 07:10:12 +0000</pubDate>
      <link>https://dev.to/nchgzl/why-qr-codes-expire-its-not-the-code-its-the-server-3e34</link>
      <guid>https://dev.to/nchgzl/why-qr-codes-expire-its-not-the-code-its-the-server-3e34</guid>
      <description>&lt;p&gt;Most explanations of QR code expiration say "your subscription expired" as if that's the full story. It's not. &lt;strong&gt;QR codes don't expire — the redirect infrastructure that dynamic codes depend on gets switched off, and every scan after that moment hits a dead server instead of your content.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's how it works at the architecture level, and why it matters if you're building anything that touches physical print.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static vs dynamic: two completely different architectures
&lt;/h2&gt;

&lt;p&gt;A static QR code encodes your destination URL directly into the pixel pattern using the ISO/IEC 18004 standard. No server, no lookup, no dependency. Scan it and the camera decodes the URL from the image itself. It will keep working as long as the physical print is readable and the destination URL is live.&lt;/p&gt;

&lt;p&gt;A dynamic QR code encodes a &lt;em&gt;short URL owned by the QR platform&lt;/em&gt; — something like &lt;code&gt;qrtg.io/abc123&lt;/code&gt;. The camera sends an HTTP GET to that short URL. The platform's redirect server looks up where &lt;code&gt;abc123&lt;/code&gt; maps to and returns a 302 to your real destination.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scan → GET https://qrtg.io/abc123 → 302 → https://yoursite.com/landing-page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That redirect server is what expires. The QR image never changes. The infrastructure behind it does.&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%2F1f93n6mppn5lrjgn4isr.webp" 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%2F1f93n6mppn5lrjgn4isr.webp" alt="How a dynamic QR code redirect works: phone to redirect server to destination" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways the redirect stops working
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Subscription cancellation
&lt;/h3&gt;

&lt;p&gt;Payment fails or you cancel → platform downgrades your account → redirect server stops responding for your codes. On most platforms this is instant. No grace period. A billing failure at 2 AM means codes are dead before your staff shows up at 8 AM.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Trial end
&lt;/h3&gt;

&lt;p&gt;You created codes during a 30-day evaluation. Didn't convert. Codes created during the trial deactivate on day 31. If you printed those codes before deciding not to subscribe, the materials are already broken.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Scan cap hit
&lt;/h3&gt;

&lt;p&gt;Free tiers usually cap dynamic codes by scan count. As of April 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;QR Tiger free: 500 total scans per code&lt;/li&gt;
&lt;li&gt;Flowcode free: 2 active codes, 500-scan analytics limit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hit the cap and the redirect returns an error, even if your account is fully active. The counterintuitive part: a high-traffic QR code burns through its cap faster. Success triggers failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Platform shutdown
&lt;/h3&gt;

&lt;p&gt;The redirect domain goes offline permanently. Every QR code that ever pointed at &lt;code&gt;platform.io/r/...&lt;/code&gt; is unrecoverable. The QR code industry saw real consolidation in 2023–2024 — smaller platforms shut down or got acquired, and physical materials printed with those platforms' short URLs became permanently dead.&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%2Frvur592qzi5qaco4s6ma.webp" 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%2Frvur592qzi5qaco4s6ma.webp" alt="The four ways a QR code redirect stops working" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a harder problem than it looks
&lt;/h2&gt;

&lt;p&gt;The subscription cycle and the physical materials lifecycle operate on completely different timescales.&lt;/p&gt;

&lt;p&gt;A business prints 10,000 product boxes with a QR code. The boxes have an 18-month shelf life. Annual subscription renews fine the first year. Card gets updated, auto-renewal misses, subscription lapses six months before the last box ships.&lt;/p&gt;

&lt;p&gt;Every box still on shelves now has a broken QR code. No one on the vendor's side knows. The platform sends no notification. The brand finds out when a customer mentions it.&lt;/p&gt;

&lt;p&gt;Our support ticket data at QR Nova shows the average gap between a dynamic code going offline and the owner finding out via a customer report is &lt;strong&gt;4 days&lt;/strong&gt;. That's 4 days of scans hitting error pages for a product that's physically present and being handled by end users.&lt;/p&gt;

&lt;p&gt;The silent failure mode is the real problem. When a code dies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The scanner gets a generic &lt;code&gt;404 Not Found&lt;/code&gt; or a platform error page&lt;/li&gt;
&lt;li&gt;No message saying "this QR code is deactivated"&lt;/li&gt;
&lt;li&gt;The user assumes their phone has a bug, or the product is broken&lt;/li&gt;
&lt;li&gt;The brand takes the hit invisibly&lt;/li&gt;
&lt;/ul&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%2Fmazshrb9bfdpitpn6t19.webp" 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%2Fmazshrb9bfdpitpn6t19.webp" alt="Physical print materials with QR codes: menu, product box, business card" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually matters when choosing a QR platform for print
&lt;/h2&gt;

&lt;p&gt;If you're embedding QR codes in anything physical — packaging, signage, product inserts, printed menus — the most important question isn't "what features do you offer." It's:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What happens to my codes if I cancel tomorrow?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A platform that immediately deactivates all dynamic codes on subscription change is a liability for print use cases. A platform with a grace period or permanent code retention is not.&lt;/p&gt;

&lt;p&gt;Other things worth checking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scan cap alerts&lt;/strong&gt; — does the platform email you before you hit the cap, or after?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom redirect domain&lt;/strong&gt; — if you can point your own domain at the redirect service, you can migrate platforms without reprinting anything&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export options&lt;/strong&gt; — can you get your redirect rules out if the platform shuts down?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operating history&lt;/strong&gt; — a platform that's been running for several years with a clear business model is meaningfully less likely to disappear than a well-funded startup with no obvious revenue&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architectural choice that avoids the problem
&lt;/h2&gt;

&lt;p&gt;For any destination that won't change, skip dynamic codes entirely. Static QR codes have no server dependency. The URL is baked into the image. There's nothing to cancel.&lt;/p&gt;

&lt;p&gt;For destinations that need to be editable — campaign URLs, seasonal redirects — dynamic codes are necessary, but the redirect service itself doesn't have to be gated by billing state. That's an architectural choice platforms make, not a technical necessity.&lt;/p&gt;

&lt;p&gt;At &lt;a href="https://qrcodenova.com" rel="noopener noreferrer"&gt;QR Nova&lt;/a&gt;, we built the redirect service so codes stay active regardless of billing status. The redirect lookup is not coupled to account state. It's a simpler system to operate and it removes the liability for anyone printing QR codes on anything physical.&lt;/p&gt;

&lt;p&gt;Static codes are free with no account required. Dynamic codes keep working after you close the tab.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://qrcodenova.com/en/blog/why-do-qr-codes-expire" rel="noopener noreferrer"&gt;QR Nova&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>programming</category>
      <category>devops</category>
    </item>
    <item>
      <title>Track QR Code Scans in GA4 Without Losing Attribution</title>
      <dc:creator>Nacho González</dc:creator>
      <pubDate>Wed, 06 May 2026 18:44:13 +0000</pubDate>
      <link>https://dev.to/nchgzl/track-qr-code-scans-in-ga4-without-losing-attribution-2cja</link>
      <guid>https://dev.to/nchgzl/track-qr-code-scans-in-ga4-without-losing-attribution-2cja</guid>
      <description>&lt;p&gt;You launch a print campaign. Posters go up, menus get new QR codes, product packaging ships with a fresh scan prompt. Two weeks later you open GA4 to measure performance and see a spike in direct traffic. No campaign, no source, no medium. Your QR scans are in there somewhere, mixed in with people who typed your URL by hand. This is the default behavior for QR code GA4 tracking, and it will keep breaking attribution on every physical campaign you run until you fix it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;QR scans arrive in GA4 as direct traffic because phones send no HTTP referrer when opening a URL from a camera app.&lt;/li&gt;
&lt;li&gt;The fix is always-on UTM tagging: every QR destination URL needs at minimum &lt;code&gt;utm_source&lt;/code&gt;, &lt;code&gt;utm_medium&lt;/code&gt;, and &lt;code&gt;utm_campaign&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;GA4 has no built-in QR channel — create a custom channel group so tagged QR traffic is not bucketed as "Unassigned."&lt;/li&gt;
&lt;li&gt;Use a consistent lowercase naming convention or your data fragments into separate line items that look like different sources.&lt;/li&gt;
&lt;li&gt;QR platform analytics and GA4 serve different purposes — use both, not one or the other.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why QR codes break GA4 attribution
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Key concept:&lt;/strong&gt; GA4 classifies every session by traffic source. When no UTM parameters and no HTTP referrer are present, GA4 defaults to "direct / (none)." That is exactly what happens with every untagged QR code scan.&lt;/p&gt;

&lt;p&gt;GA4 identifies traffic source from two places: UTM parameters in the URL and the HTTP referrer header sent by the browser. Click a link on a webpage and that page's URL gets passed along as the referrer. Click an email link and the email client usually strips the referrer, which is why email marketers have been tagging URLs with UTM parameters for years.&lt;/p&gt;

&lt;p&gt;QR codes are the same problem, only worse. Someone scans a QR code and their phone opens the URL in a fresh browser tab. No previous page to reference. No referrer sent. GA4 sees an empty source and files the session under "direct / (none)."&lt;/p&gt;

&lt;p&gt;Every untagged QR campaign you run inflates your direct traffic number and vanishes from campaign reports. You cannot prove which placement drove visits, which campaign generated conversions, or whether your print spend was worth a dime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The foundation: UTM parameters for QR campaigns
&lt;/h2&gt;

&lt;p&gt;UTM parameters are query string tags appended to a destination URL. GA4 reads them on arrival and stores them as session-scoped dimensions, overriding whatever referrer data the browser would have sent. For QR codes, UTMs are not optional. They are the only attribution signal you have.&lt;/p&gt;

&lt;h3&gt;
  
  
  The five UTM parameters
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;utm_source&lt;/code&gt; — Where the traffic originates. For QR codes, use &lt;code&gt;qr&lt;/code&gt; as a standard value across all campaigns. This makes it easy to create a single GA4 channel rule that captures all QR traffic regardless of format.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_medium&lt;/code&gt; — The channel type. Options include &lt;code&gt;offline&lt;/code&gt;, &lt;code&gt;print&lt;/code&gt;, &lt;code&gt;packaging&lt;/code&gt;, &lt;code&gt;outdoor&lt;/code&gt;, &lt;code&gt;event&lt;/code&gt;. Pick the one that reflects the physical format.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_campaign&lt;/code&gt; — The campaign name. Keep it descriptive and lowercase: &lt;code&gt;summer_menu_2026&lt;/code&gt;, &lt;code&gt;trade_show_april&lt;/code&gt;, &lt;code&gt;product_launch_q2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_content&lt;/code&gt; — The specific placement within a campaign. &lt;code&gt;utm_content=lobby_poster&lt;/code&gt; versus &lt;code&gt;utm_content=table_card&lt;/code&gt; versus &lt;code&gt;utm_content=receipt_footer&lt;/code&gt; gives you placement-level data inside a single campaign.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_term&lt;/code&gt; — Skip it for QR campaigns. It was designed for paid search keyword tracking and adds noise without value in an offline context.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A practical QR UTM URL structure
&lt;/h3&gt;

&lt;p&gt;A properly tagged URL for a restaurant running a summer menu campaign:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/menu?utm_source=qr&amp;amp;utm_medium=print&amp;amp;utm_campaign=summer_menu_2026&amp;amp;utm_content=table_card
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same campaign, different placement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/menu?utm_source=qr&amp;amp;utm_medium=print&amp;amp;utm_campaign=summer_menu_2026&amp;amp;utm_content=entrance_poster
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In GA4, these sessions show up in Traffic Acquisition under source &lt;code&gt;qr&lt;/code&gt;, medium &lt;code&gt;print&lt;/code&gt;, campaign &lt;code&gt;summer_menu_2026&lt;/code&gt;. Break down by &lt;code&gt;utm_content&lt;/code&gt; to see which placement drove more scans or deeper engagement.&lt;/p&gt;

&lt;h3&gt;
  
  
  One rule: always use lowercase
&lt;/h3&gt;

&lt;p&gt;GA4 is case-sensitive for UTM parameter values. &lt;code&gt;utm_source=QR&lt;/code&gt; and &lt;code&gt;utm_source=qr&lt;/code&gt; show up as two different sources. Pick your values, document them, enforce them. This is far and away the most common UTM mistake in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up GA4 to recognize QR traffic
&lt;/h2&gt;

&lt;p&gt;Even with perfect UTM tagging, GA4 will not know what to do with sessions where &lt;code&gt;session_source = qr&lt;/code&gt; and &lt;code&gt;session_medium = offline&lt;/code&gt;. The default channel grouping has no rule for these values, so they land in "Unassigned." You need a custom channel group.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: open channel group settings
&lt;/h3&gt;

&lt;p&gt;Go to &lt;strong&gt;GA4 Admin&lt;/strong&gt; and select your property. Under &lt;strong&gt;Data Display&lt;/strong&gt;, click &lt;strong&gt;Channel Groups&lt;/strong&gt;. Click &lt;strong&gt;Create new channel group&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: create the QR code channel
&lt;/h3&gt;

&lt;p&gt;Name your channel group "QR Campaigns" or copy the default channel group and add a new channel. Click &lt;strong&gt;Add new channel&lt;/strong&gt; and name it "QR Code." Set the rule condition:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dimension: &lt;code&gt;Session source&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Condition: &lt;em&gt;exactly matches&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Value: &lt;code&gt;qr&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your team already uses multiple source values, add multiple conditions with OR logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: position the rule correctly
&lt;/h3&gt;

&lt;p&gt;GA4 evaluates channel rules from top to bottom and stops at the first match. Place your QR Code channel above the broad catch-all rules like "Unassigned" or "Direct" so QR sessions are claimed before they fall through to a default bucket.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: save and publish
&lt;/h3&gt;

&lt;p&gt;Click &lt;strong&gt;Save&lt;/strong&gt;. One caveat: this rule is not retroactive. Sessions collected before you created it stay where they are.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verifying it works
&lt;/h3&gt;

&lt;p&gt;Use GA4's &lt;strong&gt;Realtime&lt;/strong&gt; report to test right away. Scan one of your tagged QR codes on your phone and watch the active users report. Within a few seconds the session should appear with the correct source, medium, and campaign. If you see direct traffic instead, a redirect probably stripped the UTM parameters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where UTM tags break: redirects and dynamic QR codes
&lt;/h2&gt;

&lt;p&gt;URL redirect chains trip up even experienced marketers. If your tagged URL goes through a redirect that strips query parameters, the UTM values disappear before they reach the landing page.&lt;/p&gt;

&lt;p&gt;The typical failure: you tag a URL, shorten it, and the shortener's redirect does not preserve query strings. The user lands on your page with a clean URL and GA4 records a direct session. Test every redirect in your chain before printing anything.&lt;/p&gt;

&lt;p&gt;Dynamic QR codes sidestep this. Instead of encoding a long UTM-tagged URL directly, the QR code points to a short redirect URL managed by your QR platform. The platform redirects to your destination with UTM parameters intact and lets you change the destination URL later without reprinting. For long-running campaigns, that flexibility alone is worth the switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  QR platform analytics vs. GA4: use both
&lt;/h2&gt;

&lt;p&gt;These tools track different things, and you need both.&lt;/p&gt;

&lt;p&gt;A QR platform captures scan-level data: total scans, unique scans, device type, OS, location by country and city, time-of-day patterns. All of this is recorded the moment someone scans, &lt;strong&gt;before&lt;/strong&gt; they reach your website. It tells you how many people scanned and where.&lt;/p&gt;

&lt;p&gt;GA4 picks up after the landing page loads. Pageviews, events, conversions, session duration, goal completions. It tells you what scanners did once they arrived and how many converted.&lt;/p&gt;

&lt;p&gt;A QR campaign might show 400 scans in the platform dashboard but only 220 sessions in GA4. That gap is real: users who bounced before the GA4 script fired, or users on devices with JavaScript disabled. Comparing scan data against session data is the only way to understand drop-off at the scan-to-landing-page step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five attribution mistakes that kill QR campaign data
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. No UTM parameters at all
&lt;/h3&gt;

&lt;p&gt;The most common one, and the most painful. Every QR code destination URL must be tagged. No exceptions. If you already have QR codes printed on materials without UTM parameters, that attribution data is gone. You cannot recover it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Tagging internal links
&lt;/h3&gt;

&lt;p&gt;Adding UTM parameters to links between pages on your own website overwrites the session source mid-visit. A user arrives from your QR code, clicks a UTM-tagged internal banner, and GA4 reassigns the session to the banner as the source. Keep UTM parameters on external-to-your-site links only.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. One generic QR code per campaign
&lt;/h3&gt;

&lt;p&gt;Same tagged URL across five different placements means five placements sharing one line item in GA4. Worth creating a unique QR code for each placement, even if it takes a few extra minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Inconsistent naming across campaigns
&lt;/h3&gt;

&lt;p&gt;Someone on your team launches a new campaign with &lt;code&gt;utm_source=qr-code&lt;/code&gt; while every previous campaign used &lt;code&gt;utm_source=qr&lt;/code&gt;. Now you have a separate source row in GA4 that cannot be merged after the fact. A shared UTM naming doc, reviewed before each launch, prevents this.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Not testing before print
&lt;/h3&gt;

&lt;p&gt;Printing 10,000 flyers with a broken QR code is an expensive lesson. Scan your QR code with GA4 Realtime open before sending anything to print. Confirm the UTM parameters survive every redirect in the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a QR attribution report in GA4
&lt;/h2&gt;

&lt;p&gt;With tagging and the channel group in place, build a focused QR campaign report in &lt;strong&gt;Explore&lt;/strong&gt; using a free-form exploration.&lt;/p&gt;

&lt;p&gt;Dimensions to include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Session campaign&lt;/code&gt; — separates individual campaigns&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Session source / medium&lt;/code&gt; — confirms QR sessions are properly tagged&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Session manual ad content&lt;/code&gt; — surfaces your &lt;code&gt;utm_content&lt;/code&gt; placement data&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Landing page + query string&lt;/code&gt; — useful for catching untagged QR sessions that slipped through&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Metrics to include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sessions&lt;/li&gt;
&lt;li&gt;Engaged sessions&lt;/li&gt;
&lt;li&gt;Engagement rate&lt;/li&gt;
&lt;li&gt;Key events (conversions)&lt;/li&gt;
&lt;li&gt;User engagement duration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Filter the exploration to &lt;code&gt;Session source exactly matches qr&lt;/code&gt; to isolate QR traffic. Save it once and revisit across campaigns without rebuilding.&lt;/p&gt;

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

&lt;p&gt;QR code attribution in GA4 is not a platform limitation. It is a setup problem you can solve in an afternoon. Tag every destination URL with consistent UTM parameters, create a custom channel group so GA4 knows what to do with that traffic, and test before you print. Those three steps recover attribution that most marketers are losing to the direct channel bucket right now.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://qrcodenova.com/en/blog/qr-code-ga4-tracking" rel="noopener noreferrer"&gt;QR Nova&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Dynamic QR Code Redirect Architecture at the Edge</title>
      <dc:creator>Nacho González</dc:creator>
      <pubDate>Fri, 24 Apr 2026 20:13:50 +0000</pubDate>
      <link>https://dev.to/nchgzl/dynamic-qr-code-redirect-architecture-at-the-edge-88k</link>
      <guid>https://dev.to/nchgzl/dynamic-qr-code-redirect-architecture-at-the-edge-88k</guid>
      <description>&lt;p&gt;A dynamic QR code is a short URL baked into a static image. The "dynamic" part is a database record: a slug-to-destination mapping you can update without reprinting. The image never changes.&lt;/p&gt;

&lt;p&gt;Everything interesting happens in the redirect layer. Every scan is a live HTTP request that has to resolve before a user moves on. What follows is what that layer looks like, how to build it right, and where it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "dynamic" actually means
&lt;/h2&gt;

&lt;p&gt;The QR image encodes a fixed string like &lt;code&gt;go.yourcompany.com/r/abc123&lt;/code&gt;. What changes is the database record mapping &lt;code&gt;abc123&lt;/code&gt; to a destination URL.&lt;/p&gt;

&lt;p&gt;The reliability of every printed QR code is exactly equal to the reliability of that redirect server. Nothing in the image acts as a fallback. Slow server = slow scan. Down server = failed scan. Cancelled account = error page. The infrastructure is the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The redirect chain
&lt;/h2&gt;

&lt;p&gt;Full sequence from camera tap to destination:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scan&lt;/strong&gt;: device reads the QR pattern, decodes the short URL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS resolution&lt;/strong&gt;: resolves the redirect domain to nearest IP (edge) or one fixed IP (single-origin)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP + TLS handshake&lt;/strong&gt;: 50–150ms cold, depending on distance and network&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP GET&lt;/strong&gt;: &lt;code&gt;GET /r/abc123&lt;/code&gt; hits the redirect server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lookup&lt;/strong&gt;: server checks cache or KV store for the destination URL mapped to that slug&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async log&lt;/strong&gt;: queues scan event (timestamp, IP, User-Agent) without blocking the response&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP 302&lt;/strong&gt;: returns &lt;code&gt;Location: &amp;lt;destination&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Browser follows the redirect&lt;/li&gt;
&lt;li&gt;Destination loads&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 3–7 are what the redirect infrastructure controls. That window is what edge computing compresses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Single-origin: how most platforms are built
&lt;/h2&gt;

&lt;p&gt;Simplest implementation: one server in Virginia or Frankfurt. Every scan in the world hits that box.&lt;/p&gt;

&lt;p&gt;Two problems surface at scale.&lt;/p&gt;

&lt;p&gt;Latency first. Sydney to Virginia is about 180ms round-trip just to receive a 200-byte redirect response. On a slow mobile network where TCP setup already costs 100ms, you're at 300ms before the destination even starts loading.&lt;/p&gt;

&lt;p&gt;Then there's the single point of failure. A bad deploy, a DDoS, or a data center incident takes every QR code on the platform offline at once. No second region to fail over to.&lt;/p&gt;

&lt;p&gt;Most small QR platforms run exactly this. Fine at low volume. Problems show up at scale, and by then codes are already printed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge-first architecture
&lt;/h2&gt;

&lt;p&gt;Edge-first moves redirect logic to globally distributed nodes. Cloudflare Workers and Fastly Compute run JS/Wasm at 200+ cities. DNS resolves via anycast to the nearest edge node.&lt;/p&gt;

&lt;p&gt;TTFB for Sydney to nearest edge: 5–20ms instead of 150–200ms.&lt;/p&gt;

&lt;p&gt;At the node, the lookup reads from an in-memory cache or distributed KV. Cloudflare Workers KV replicates writes globally within ~60s and reads with single-digit millisecond latency from any node. One KV read, not a database query with joins.&lt;/p&gt;

&lt;p&gt;Minimal Cloudflare Worker redirect handler:&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;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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;ctx&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;slug&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="c1"&gt;// strip /r/&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;destination&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIRECTS&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="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;destination&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;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not found&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// fire-and-forget, doesn't block the redirect&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;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;logScan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;logScan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&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;SCAN_QUEUE&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;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="s2"&gt;CF-Connecting-IP&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="s2"&gt;CF-IPCountry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="s2"&gt;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;ts&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="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;
  
  
  Numbers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Single-origin&lt;/th&gt;
&lt;th&gt;Edge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TTFB (same region as server)&lt;/td&gt;
&lt;td&gt;20–40ms&lt;/td&gt;
&lt;td&gt;5–15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTFB (opposite side of world)&lt;/td&gt;
&lt;td&gt;150–250ms&lt;/td&gt;
&lt;td&gt;15–30ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P99 under traffic spike&lt;/td&gt;
&lt;td&gt;800ms–2s+&lt;/td&gt;
&lt;td&gt;30–60ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regional outage&lt;/td&gt;
&lt;td&gt;100% of scans fail&lt;/td&gt;
&lt;td&gt;auto-failover, zero user impact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost at scale&lt;/td&gt;
&lt;td&gt;Vertical scale-out&lt;/td&gt;
&lt;td&gt;Per-request&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  302, not 301
&lt;/h2&gt;

&lt;p&gt;Always use 302 (temporary redirect), not 301 (permanent).&lt;/p&gt;

&lt;p&gt;301 gets cached aggressively by browsers. If a user previously scanned and their browser cached the old destination, updating the database does nothing; they get the old URL until their cache expires. 302 tells browsers not to cache, so every scan hits the redirect server fresh.&lt;/p&gt;

&lt;p&gt;This is the most common redirect architecture mistake. Invisible during testing, only shows up for repeat visitors in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching at the edge
&lt;/h2&gt;

&lt;p&gt;Not every destination changes frequently. Cache the slug-to-destination mapping at the edge to skip the KV read on hot slugs.&lt;/p&gt;

&lt;p&gt;TTL of 30–120s covers most use cases. Fast enough for campaign changes, cheap at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale-while-revalidate&lt;/strong&gt; serves the cached destination immediately and refreshes in the background:&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;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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;ctx&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;slug&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`dest:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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;cached&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CACHE&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="nx"&gt;cacheKey&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;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// return immediately, refresh cache in background&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;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;fetchFromOrigin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
          &lt;span class="nx"&gt;dest&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;CACHE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&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;destination&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;fetchFromOrigin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;destination&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;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not found&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&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;waitUntil&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;CACHE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&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;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;logScan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&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;Users never wait for cache misses. The miss penalty is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Geo-routing at the edge
&lt;/h2&gt;

&lt;p&gt;Some use cases need different destinations per location. A global brand might send US users to a US landing page and EU users to a different one with localized copy and compliance language. A restaurant chain might send users to the nearest location's menu.&lt;/p&gt;

&lt;p&gt;Edge workers get geolocation headers on every request, no external API call needed. On Cloudflare, &lt;code&gt;CF-IPCountry&lt;/code&gt; gives you the ISO country code. Store a &lt;code&gt;slug:country&lt;/code&gt; key in KV and do the lookup in one read.&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;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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;ctx&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;slug&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="s2"&gt;CF-IPCountry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;XX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// try geo-specific destination first, fall back to default&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;destination&lt;/span&gt; &lt;span class="o"&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIRECTS&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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;country&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="o"&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIRECTS&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="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;destination&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;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not found&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&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;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;logScan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&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;KV key structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;abc123&lt;/code&gt; → default destination&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;abc123:US&lt;/code&gt; → US destination&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;abc123:DE&lt;/code&gt; → German destination&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One KV read for geo-specific, two reads for fallback. Still single-digit millisecond latency, no round-trip to a central routing service.&lt;/p&gt;

&lt;p&gt;It's more useful than it looks. Regional A/B tests, localized landing pages, GDPR compliance redirects: all of it becomes a KV write from the dashboard instead of a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Analytics: async or you're doing it wrong
&lt;/h2&gt;

&lt;p&gt;Writing to a database synchronously before returning the redirect is the worst architecture choice here. It puts a write operation (lock contention, index updates, network round-trip to a central DB) directly in the hot path of every single scan.&lt;/p&gt;

&lt;p&gt;Two patterns that work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queue-based:&lt;/strong&gt; the Worker sends a lightweight event to Cloudflare Queues or SQS and returns the redirect immediately. A separate consumer drains the queue, enriches events with geo-lookup and device parsing, and writes to the analytics store. If the pipeline backs up or errors, scans keep working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edge streaming:&lt;/strong&gt; log events go to Cloudflare Logpush, then object storage, then Kafka or Kinesis, then the analytics DB in batches. More infrastructure, but scales to millions of scans per day without per-event writes.&lt;/p&gt;

&lt;p&gt;Same principle either way: redirect response latency stays fixed. Analytics write latency is irrelevant to users.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to monitor in production
&lt;/h2&gt;

&lt;p&gt;A redirect service has a short list of things worth tracking.&lt;/p&gt;

&lt;p&gt;Redirect TTFB by region: track P95 and P99, not average. Average hides tail latency that shows up for users on slow mobile networks. If P99 in Asia spikes to 800ms, something is wrong with KV replication or a regional node.&lt;/p&gt;

&lt;p&gt;Cache hit rate: a sudden drop means something invalidated the edge cache. Catch it before it becomes a latency regression.&lt;/p&gt;

&lt;p&gt;Scan error rate: 404s are expected for deleted slugs. 500s, timeouts, and connection resets need alerting. One bad deploy should not silently fail millions of scans.&lt;/p&gt;

&lt;p&gt;Queue depth: if the scan event queue is backing up, the analytics pipeline has a problem. The redirect still works, but lag accumulates. Left long enough, you'll either drop events or overwhelm the consumer when it catches up.&lt;/p&gt;

&lt;p&gt;In Cloudflare Workers, Workers Analytics Engine is a time-series store built for this pattern. Push a data point per request, query with SQL-like syntax via the Analytics Engine API.&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;// inside fetch handler, non-blocking&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;waitUntil&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;ANALYTICS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeDataPoint&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;blobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="s2"&gt;CF-Ray&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;doubles&lt;/span&gt;&lt;span class="p"&gt;:&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="na"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&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;Query it later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;blob1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;blob2&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;scans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;quantilesMerge&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;95&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;quantilesState&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;95&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;double1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;p95_ts&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SCAN_EVENTS&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt; &lt;span class="n"&gt;HOUR&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;scans&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One endpoint. No external observability stack in the hot path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failover
&lt;/h2&gt;

&lt;p&gt;When a node goes unhealthy, anycast DNS shifts requests to the next-nearest healthy one automatically. Latency ticks up slightly; the service stays up. With single-origin, one failure is a complete outage.&lt;/p&gt;

&lt;p&gt;The resilience pattern most people skip: &lt;strong&gt;stale-on-error&lt;/strong&gt;. If the origin data store is unreachable, serve the last-cached destination instead of returning an error. Destination changes are rare; the cached value is almost always right. A slightly stale redirect beats an error page every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that's not a performance problem
&lt;/h2&gt;

&lt;p&gt;The short URL encoded in a printed QR code is permanent. Once it's on packaging, signage, or business cards, you can't change it without reprinting.&lt;/p&gt;

&lt;p&gt;If that URL is &lt;code&gt;qrtiger.io/r/abc123&lt;/code&gt;, every printed code's operational status is permanently tied to QR Tiger. Cancel the subscription and you get error pages. Platform shuts down, error pages. Price increase, you negotiate from zero leverage.&lt;/p&gt;

&lt;p&gt;Owning the redirect domain fixes this. &lt;code&gt;go.yourcompany.com/r/abc123&lt;/code&gt; can point at any infrastructure at any time. Update a DNS record to switch platforms or self-host. The printed codes never change; what's behind them does.&lt;/p&gt;

&lt;p&gt;Most QR platforms charge a premium for custom domains or don't support them at all. That's not incidental. A platform hosting your redirect domain has permanent leverage over materials that cost real money to replace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build vs. buy
&lt;/h2&gt;

&lt;p&gt;A self-hosted redirect on Cloudflare Workers costs about $5/month for the Workers subscription plus under $0.50/million redirect reads. The Worker is 50–100 lines. A few hours to deploy.&lt;/p&gt;

&lt;p&gt;What a platform adds: destination management UI, analytics dashboard, QR generation tooling, reliability guarantees. For teams without dedicated infra engineers, that management layer is the actual product. That's probably why most teams should buy rather than build.&lt;/p&gt;

&lt;p&gt;Either way: edge compute for the redirect hop, analytics out of the hot path, short-TTL caching with stale-while-revalidate, stale-on-error resilience, and a redirect domain you control.&lt;/p&gt;

&lt;p&gt;The QR image is just the entry point. The redirect layer is the product.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://qrcodenova.com" rel="noopener noreferrer"&gt;QR Nova&lt;/a&gt;, a QR platform built on this architecture. Happy to answer questions in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>cloudflare</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
