<?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: Nick van der Meij</title>
    <description>The latest articles on DEV Community by Nick van der Meij (@nick_10_).</description>
    <link>https://dev.to/nick_10_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4089984%2F07edbc3d-50a4-4598-9c82-e91a2f91a6d8.png</url>
      <title>DEV Community: Nick van der Meij</title>
      <link>https://dev.to/nick_10_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nick_10_"/>
    <language>en</language>
    <item>
      <title>Offline-First Point of Sale for Festivals: What Actually Breaks When 70,000 Phones Share One Network</title>
      <dc:creator>Nick van der Meij</dc:creator>
      <pubDate>Sat, 22 Aug 2026 17:05:05 +0000</pubDate>
      <link>https://dev.to/nick_10_/offline-first-point-of-sale-for-festivals-what-actually-breaks-when-70000-phones-share-one-network-3c7h</link>
      <guid>https://dev.to/nick_10_/offline-first-point-of-sale-for-festivals-what-actually-breaks-when-70000-phones-share-one-network-3c7h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A festival network is rarely "down". It is connected, slow, and randomly dropping uploads, which is worse for a point-of-sale app than a clean outage. We ship a PWA POS that runs at events, and the things that hurt us were not the obvious ones: &lt;code&gt;navigator.onLine&lt;/code&gt;, service worker updates, Background Sync assumptions, and device clocks. This post walks through each one with the fixes we landed on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Design for lie-fi, not offline. Measured stadium networks deliver a P90 time-to-first-byte of nearly 6 seconds on 5G while still reporting "connected".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;navigator.onLine&lt;/code&gt; is a hint. Drive your offline state from request timeouts and a health probe.&lt;/li&gt;
&lt;li&gt;Persist every sale to IndexedDB with an idempotency key before the UI says "paid". Replay against a server that owns the truth.&lt;/li&gt;
&lt;li&gt;Order transactions by server sequence, never by device time.&lt;/li&gt;
&lt;li&gt;Service workers on a tab that never closes need a different update strategy than a marketing site.&lt;/li&gt;
&lt;li&gt;Background Sync is missing on Safari and Firefox. Treat it as a bonus, not a plan.&lt;/li&gt;
&lt;li&gt;Safari can evict your storage after 7 days of no use. Installed home-screen apps are exempt.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The network at a festival is not "offline", it is lie-fi
&lt;/h2&gt;

&lt;p&gt;The dominant failure mode at a crowded venue is a connection that looks fine and behaves terribly. The web.dev offline cookbook calls this "lie-fi": the radio is attached, the status bar shows bars, and every request crawls or dies (&lt;a href="https://web.dev/articles/offline-cookbook" rel="noopener noreferrer"&gt;web.dev, Offline Cookbook&lt;/a&gt;). A binary online/offline model does not describe it.&lt;/p&gt;

&lt;p&gt;The best public measurements we know of come from a July 2026 study at Notre Dame Stadium, which seats up to 77,622 people. On game day, P90 browsing time-to-first-byte was 5,983 ms on Verizon 5G NSA and 2,916 ms on AT&amp;amp;T. Note that those are P90 figures, not medians. WhatsApp image uploads failed 46.2% of the time on AT&amp;amp;T and 44.4% on Verizon. Page loads failed 24.7% to 36.6% of the time on cellular, versus 3.9% on the stadium's Wi-Fi 6 GHz network (&lt;a href="https://arxiv.org/html/2607.16008v1" rel="noopener noreferrer"&gt;arXiv:2607.16008&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The same study shows why Wi-Fi is the better rail when you can get it. Median TTFB on Wi-Fi 6 GHz was 573 ms during games, against 447 ms on non-game days, and median Wi-Fi latency was 19 ms. Median game-day downlink was 53 to 60.8 Mbps on both Wi-Fi and T-Mobile 5G SA (&lt;a href="https://arxiv.org/html/2607.16008v1" rel="noopener noreferrer"&gt;arXiv:2607.16008&lt;/a&gt;). The medians look healthy. The tails are where your card reader times out.&lt;/p&gt;

&lt;p&gt;The demand curve explains the tails. At Super Bowl 60 in Levi's Stadium (70,823 fans), Verizon carried 40.32 TB, AT&amp;amp;T 24.4 TB, and T-Mobile around 11 TB, with Verizon averaging roughly 955 MB per connected customer. Verizon's total at Super Bowl 50 a decade earlier was 7 TB (&lt;a href="https://www.stadiumtechreport.com/news/verizon-att-combine-for-almost-65-tb-of-cellular-data-used-at-super-bowl-60/" rel="noopener noreferrer"&gt;Stadium Tech Report&lt;/a&gt;, &lt;a href="https://about.att.com/story/2026/big-game-data.html" rel="noopener noreferrer"&gt;AT&amp;amp;T newsroom&lt;/a&gt;). Festivals follow the same curve with worse infrastructure: Vodafone moved 225 TB at Glastonbury 2024, up 33% on 2023, over 10 temporary masts, including 258 GB uploaded during Coldplay's set alone (&lt;a href="https://www.vodafone.co.uk/newscentre/press-release/mobile-network-data-records-broken-at-glastonbury-festival-yet-again/" rel="noopener noreferrer"&gt;Vodafone UK&lt;/a&gt;). In 2025 traffic rose another roughly 25% on 11 masts (&lt;a href="https://www.computerweekly.com/news/366627193/Vodafone-sees-Glastonbury-mobile-data-traffic-usage-rise-25" rel="noopener noreferrer"&gt;Computer Weekly&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Uplink matters more than you think for a POS. At the 2023 FIFA Women's World Cup, Optus saw over 29 TB across three stadiums, with about 25% of it uplink, and needed roughly 7,000 optimisation adjustments across more than 1,100 cells to keep it flowing (&lt;a href="https://www.ericsson.com/en/reports-and-papers/mobility-report/articles/mobile-broadband-indoor-deployment" rel="noopener noreferrer"&gt;Ericsson Mobility Report&lt;/a&gt;). Every sale you post is uplink, competing with tens of thousands of people uploading video of the headliner.&lt;/p&gt;

&lt;p&gt;When the network does fully drop, the public record is blunt. Reading Festival went cashless-only in 2021 and card machines at food stalls failed with no cash fallback, so stalls turned customers away (&lt;a href="https://www.getreading.co.uk/news/reading-berkshire-news/cashless-reading-festival-payment-pandemonium-21431525" rel="noopener noreferrer"&gt;Berkshire Live&lt;/a&gt;). The Rogers outage in Canada on 8 July 2022 took Interac debit down nationwide, leaving food trucks at FVDED in the Park emptied out because only credit cards still worked, CBC reported (&lt;a href="https://www.cbc.ca/news/canada/british-columbia/rogers-debit-outage-bc-1.6514932" rel="noopener noreferrer"&gt;CBC&lt;/a&gt;). The Optus outage in Australia on 8 November 2023 lasted about 14 hours, hit more than 10 million people and around 400,000 businesses, and EFTPOS terminals on Optus SIMs could not process at all (&lt;a href="https://www.infrastructure.gov.au/media-communications/phone/review-optus-outage-8-november-2023" rel="noopener noreferrer"&gt;Australian Government review&lt;/a&gt;). Download Festival's 2015 RFID system had top-ups not appearing and queues over 90 minutes, even though the operator said only about 1% of attendees were affected (&lt;a href="https://www.gigwise.com/news/101100/download-festival-cashless-wristband-system-causes-problems/" rel="noopener noreferrer"&gt;Gigwise&lt;/a&gt;). One percent of a festival is still a very long queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why navigator.onLine is the wrong signal
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;navigator.onLine&lt;/code&gt; tells you whether the device has a network interface attached. It does not tell you whether packets reach your API. MDN's own wording is that the property is "inherently unreliable" and should "only provide hints" (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine" rel="noopener noreferrer"&gt;MDN, Navigator.onLine&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;This bit us with what we now call lying Wi-Fi. Venue Wi-Fi behind a captive portal, or a temporary access point whose backhaul had died, reported &lt;code&gt;online: true&lt;/code&gt;. Our app believed it, took the online path, and each sale hung for the full fetch timeout. Cashiers saw a spinner instead of a receipt and started re-tapping.&lt;/p&gt;

&lt;p&gt;The fix is to derive connectivity from what actually matters: did a small request to your own API return in a sane time? The service worker cookbook notes that &lt;code&gt;fetch&lt;/code&gt; inside a service worker has no automatic timeout, so a network-then-cache strategy with a deadline has to be built by hand (&lt;a href="https://web.dev/articles/offline-cookbook" rel="noopener noreferrer"&gt;web.dev, Offline Cookbook&lt;/a&gt;). The same applies in the page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Connectivity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;online&lt;/span&gt;&lt;span class="dl"&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;degraded&lt;/span&gt;&lt;span class="dl"&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;offline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Connectivity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctrl&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;AbortController&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;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&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;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/health&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;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;degraded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;degraded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;online&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;offline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We run this on an interval, on &lt;code&gt;online&lt;/code&gt;/&lt;code&gt;offline&lt;/code&gt; events as a trigger to re-probe (not as truth), and after any request-level timeout. The three-state result drives the UI badge and, more importantly, whether new sales go straight to the queue without attempting the network first. A "degraded" state that queues immediately keeps the line moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The offline mutation queue: persist before success
&lt;/h2&gt;

&lt;p&gt;The rule that keeps money safe is simple to state: a sale is not "done" on the device until it is durably written to IndexedDB with an idempotency key. Only then does the UI show success. Network delivery is a separate, retried step.&lt;/p&gt;

&lt;p&gt;Most offline-first tutorials stop at caching reads. Payments are the hard part, and the core problem is double-application: the server applied the charge, the response was lost on the way back, and the client retries. Stripe's API documents the pattern we copy. The client sends an &lt;code&gt;Idempotency-Key&lt;/code&gt; header; the server stores the first response status and body, including 500s, and replays that stored response for any retry with the same key. Stripe recommends UUID v4 keys, prunes them after at least 24 hours, and returns an error if the same key arrives with different parameters (&lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;Stripe, Idempotent requests&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Why CRDTs are wrong for balances
&lt;/h3&gt;

&lt;p&gt;It is tempting to model a cashless wallet as a CRDT counter and let devices merge. CRDTs guarantee convergence for commutative operations (&lt;a href="https://crdt.tech/" rel="noopener noreferrer"&gt;crdt.tech&lt;/a&gt;). A wallet balance is not commutative in the way that matters: "balance must not go below zero" is a constraint that depends on order. Two stalls each selling a 10 unit item to a wallet holding 15 units will both succeed offline and converge to minus 5. The result converges, and it is wrong.&lt;/p&gt;

&lt;p&gt;So the server is authoritative. Each device replays its queue in order; the server applies or rejects each entry exactly once and returns the same answer on every retry. The device then reconciles its local view to whatever the server decided.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;QueuedSale&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// UUID v4, generated once per sale&lt;/span&gt;
  &lt;span class="nl"&gt;localSeq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// device-local ordering only&lt;/span&gt;
  &lt;span class="nl"&gt;createdAtDevice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// display hint, never for ordering&lt;/span&gt;
  &lt;span class="nl"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SalePayload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&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;applied&lt;/span&gt;&lt;span class="dl"&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;rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;replay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SaleStore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SaleApi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sale&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pending&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;res&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;break&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;applied&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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;Two details matter. A 5xx stops the loop and leaves the entry pending so order is preserved. A 4xx settles the entry as rejected, because Stripe-style servers return the same 4xx on every retry and looping on it wastes the shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clock skew: server sequence numbers, not device time
&lt;/h2&gt;

&lt;p&gt;Never order transactions by device time. Phones and rugged tablets at a festival have user-settable clocks, drift without NTP, and cross time zones in vans. NTPv4 exists precisely because clocks disagree (&lt;a href="https://www.rfc-editor.org/rfc/rfc5905" rel="noopener noreferrer"&gt;RFC 5905&lt;/a&gt;), and an unattended tablet in a field is not running a disciplined NTP client.&lt;/p&gt;

&lt;p&gt;Several popular offline-sync write-ups present last-write-wins on client timestamps as safe. For a notes app, maybe. For a ledger, a device five minutes fast will have its later sales applied before another device's earlier ones, and a refund can land "before" the sale it refunds.&lt;/p&gt;

&lt;p&gt;Our rule: the server assigns a monotonic sequence number when it applies a queued sale, and every downstream view (balance, reports, refunds) orders by that. &lt;code&gt;createdAtDevice&lt;/code&gt; in the queue entry above is stored for the receipt and for debugging, nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service worker traps on a tab that never closes
&lt;/h2&gt;

&lt;p&gt;A POS tab opens at 10 am and closes when the bar shuts. That single fact breaks the assumptions most service worker guides make.&lt;/p&gt;

&lt;h3&gt;
  
  
  The update deadlock
&lt;/h3&gt;

&lt;p&gt;The service worker lifecycle keeps exactly one version active per scope. A new worker sits in "waiting" until every client of the old one is gone (&lt;a href="https://web.dev/articles/service-worker-lifecycle" rel="noopener noreferrer"&gt;web.dev, Service worker lifecycle&lt;/a&gt;). A kiosk tab that never closes means the waiting worker never activates. Devices ran the old build for the rest of the event.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forced skipWaiting drops precache
&lt;/h3&gt;

&lt;p&gt;The obvious escape is &lt;code&gt;skipWaiting()&lt;/code&gt;. The lifecycle docs are clear that this means the new worker serves fetches for pages loaded by the old one (&lt;a href="https://web.dev/articles/service-worker-lifecycle" rel="noopener noreferrer"&gt;web.dev, Service worker lifecycle&lt;/a&gt;). In our case the new worker's precache manifest did not contain the chunks the running page was lazily loading, so mid-shift a route change fetched a hashed file that no longer existed. Lazy imports failed. The cure: prompt-to-reload between shifts using the Workbox pattern (&lt;a href="https://developer.chrome.com/docs/workbox/handling-service-worker-updates" rel="noopener noreferrer"&gt;Chrome, Handling SW updates&lt;/a&gt;), and never force activation while a session is live.&lt;/p&gt;

&lt;h3&gt;
  
  
  Never revalidate the app shell offline
&lt;/h3&gt;

&lt;p&gt;Our navigation handler once tried network-first for the shell with a cache fallback. Under lie-fi the network branch eventually rejected, the &lt;code&gt;respondWith&lt;/code&gt; promise rejected, and the browser showed its own error page. The tab was now unrecoverable without connectivity. The shell is precached; serve it from cache on navigation and revalidate only on an explicit, online update check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Queue your telemetry too
&lt;/h3&gt;

&lt;p&gt;We instrumented offline failures by &lt;code&gt;postMessage&lt;/code&gt; from the worker to the page. The sessions we most wanted to see were the ones where no page was listening: the shell had failed, or the tab had been killed. Telemetry about offline failures must go into the same durable queue as the sales, and replay the same way.&lt;/p&gt;

&lt;h3&gt;
  
  
  clients.matchAll can be empty during install
&lt;/h3&gt;

&lt;p&gt;During install and early activation, &lt;code&gt;clients.matchAll()&lt;/code&gt; returned nothing for us even though a tab was open. Any code that assumes "there is a page, tell it something" needs a guard and a fallback that persists state for the next page to pick up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background Sync is not a strategy
&lt;/h2&gt;

&lt;p&gt;Background Sync lets a service worker replay requests once connectivity returns, even with the page closed. It is only available to about 76.73% of global users: Chrome 49+, Edge 79+, and Samsung Internet 5+, with no support in Firefox or Safari on macOS or iOS (&lt;a href="https://caniuse.com/background-sync" rel="noopener noreferrer"&gt;caniuse&lt;/a&gt;). MDN lists it as limited availability and not Baseline (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;). A festival fleet of iPads gets none of it.&lt;/p&gt;

&lt;p&gt;The Workbox plugin's fallback semantics are worth reading twice. Failed requests are stored in IndexedDB; without Background Sync the queue is replayed only when the service worker starts. The plugin retries only thrown network errors, not 4xx or 5xx responses. And the DevTools "offline" checkbox does not affect service worker fetches, so your offline test may not be testing what you think (&lt;a href="https://developer.chrome.com/docs/workbox/modules/workbox-background-sync" rel="noopener noreferrer"&gt;Chrome, workbox-background-sync&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Our position: own the queue in application code as shown above, replay from the page on every reconnect and on every app start, and treat Background Sync as an opportunistic extra on browsers that have it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;Background Sync&lt;/th&gt;
&lt;th&gt;Best-effort storage quota&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome / Edge&lt;/td&gt;
&lt;td&gt;Yes (Chrome 49+, Edge 79+)&lt;/td&gt;
&lt;td&gt;Up to 80% of disk per browser, 60% per origin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Samsung Internet&lt;/td&gt;
&lt;td&gt;Yes (5+)&lt;/td&gt;
&lt;td&gt;Chromium-based; follows Chrome limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;50% of disk, 2 GB per eTLD+1 group&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari (macOS / iOS)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;About 1 GB, then prompts in 200 MB steps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://caniuse.com/background-sync" rel="noopener noreferrer"&gt;caniuse&lt;/a&gt;, &lt;a href="https://web.dev/articles/storage-for-the-web" rel="noopener noreferrer"&gt;web.dev, Storage for the web&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage quotas and Safari's 7-day eviction
&lt;/h2&gt;

&lt;p&gt;Your queue lives in IndexedDB, so its durability is only as good as the browser's storage policy. Chrome allows up to 80% of disk per browser and 60% per origin; Firefox allows 50% of disk with a 2 GB cap per eTLD+1 group; Safari allows around 1 GB before prompting the user in 200 MB increments. All of it is best-effort by default and subject to LRU eviction when the disk is low (&lt;a href="https://web.dev/articles/storage-for-the-web" rel="noopener noreferrer"&gt;web.dev, Storage for the web&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Safari adds a time bomb. WebKit caps script-writable storage at 7 days of Safari use without user interaction with the site; after that, IndexedDB and friends can be wiped. Home-screen web apps are exempt (&lt;a href="https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/" rel="noopener noreferrer"&gt;WebKit blog&lt;/a&gt;). A tablet configured two weeks before the gates open, then left in a box, can arrive with an empty queue store and no cached shell. Install the PWA to the home screen, request persistent storage, and verify both on the morning of the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before your next event: a checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run a 3 second timeout probe against your own API; do not trust &lt;code&gt;navigator.onLine&lt;/code&gt; for anything beyond a re-probe trigger.&lt;/li&gt;
&lt;li&gt;Write every sale to IndexedDB with a UUID v4 idempotency key before the UI shows success.&lt;/li&gt;
&lt;li&gt;Make the server store and replay the first response per key, including 500s, and reject reuse with different parameters.&lt;/li&gt;
&lt;li&gt;Stop replay on 5xx, settle on 4xx, and preserve queue order.&lt;/li&gt;
&lt;li&gt;Assign server sequence numbers on apply; never sort money by device time.&lt;/li&gt;
&lt;li&gt;Serve the app shell from precache on navigation; never let &lt;code&gt;respondWith&lt;/code&gt; reject offline.&lt;/li&gt;
&lt;li&gt;Use prompt-to-reload between shifts; never call &lt;code&gt;skipWaiting()&lt;/code&gt; while a session is live.&lt;/li&gt;
&lt;li&gt;Queue telemetry with the sales; assume no page is listening.&lt;/li&gt;
&lt;li&gt;Install to home screen on iOS, request persistent storage, and confirm the cache is warm on event morning.&lt;/li&gt;
&lt;li&gt;Test with a real captive portal and a throttled uplink, not only the DevTools offline checkbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I skip the queue and just retry the fetch?
&lt;/h3&gt;

&lt;p&gt;No. A retry without an idempotency key can double-charge when the server applied the first attempt and the response was lost. Persist the sale with a key first, then retry with the same key so the server replays its stored response (&lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Background Sync work on iPads?
&lt;/h3&gt;

&lt;p&gt;No. Safari on iOS and macOS does not implement the Background Sync API, nor does Firefox (&lt;a href="https://caniuse.com/background-sync" rel="noopener noreferrer"&gt;caniuse&lt;/a&gt;). On those browsers, a Workbox queue replays only when the service worker starts, so trigger replay from the page on reconnect and on app launch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is venue Wi-Fi better than cellular for a POS?
&lt;/h3&gt;

&lt;p&gt;Measured at Notre Dame Stadium, yes: page-load failures were 3.9% on Wi-Fi 6 GHz versus 24.7% to 36.6% on cellular, with a median Wi-Fi TTFB of 573 ms during games (&lt;a href="https://arxiv.org/html/2607.16008v1" rel="noopener noreferrer"&gt;arXiv:2607.16008&lt;/a&gt;). But venue Wi-Fi is also the source of lying-Wi-Fi captive portals, so the probe still has to decide.&lt;/p&gt;

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

&lt;p&gt;The festival network is not a wall you hit; it is a swamp you wade through with the status bar insisting everything is fine. The engineering response is to stop asking the browser whether you are online and start asking your own API, to treat every sale as a durable, idempotent, server-ordered fact, and to run your service worker with the humility a day-long kiosk tab demands. None of this requires exotic infrastructure. It requires deciding, before the gates open, that "connected" is not the same as "working".&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by the engineering team at &lt;a href="https://chi.app" rel="noopener noreferrer"&gt;CHI&lt;/a&gt;, who build ticketing and cashless payment tools for live events.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pwa</category>
      <category>javascript</category>
      <category>payments</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
