<?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: Ayesha Iftikhar</title>
    <description>The latest articles on DEV Community by Ayesha Iftikhar (@ayeshaiftikhar).</description>
    <link>https://dev.to/ayeshaiftikhar</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%2F3407569%2F81cfd268-140a-435e-88ea-73398ecf6eea.jpg</url>
      <title>DEV Community: Ayesha Iftikhar</title>
      <link>https://dev.to/ayeshaiftikhar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ayeshaiftikhar"/>
    <language>en</language>
    <item>
      <title>A Flutter App Startup That Never Waits on the Network</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Mon, 02 Nov 2026 16:08:00 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/a-flutter-app-startup-that-never-waits-on-the-network-lnk</link>
      <guid>https://dev.to/ayeshaiftikhar/a-flutter-app-startup-that-never-waits-on-the-network-lnk</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;em&gt;What runs before runApp() in Mindleau, in what order, and why every one of those awaits is local.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; In Mindleau, everything awaited before &lt;code&gt;runApp()&lt;/code&gt; reads only from the device: Firebase's local config, the &lt;code&gt;.env&lt;/code&gt; file, SharedPreferences and the SQLite database. Anything that needs the network, such as sync, ads, purchases and account cleanup, is started afterwards with &lt;code&gt;unawaited()&lt;/code&gt; or a post-frame callback, and none of it can throw out of &lt;code&gt;main()&lt;/code&gt;. The result is an app that opens the same way on a train with no signal as it does on Wi-Fi.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most Flutter apps grow their &lt;code&gt;main()&lt;/code&gt; one &lt;code&gt;await&lt;/code&gt; at a time. Each line is reasonable on its own: initialize Firebase, load config, set up analytics, sign in, fetch remote config, start the ad SDK. Then one day someone opens the app in a lift and stares at the splash screen for twenty seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://mindleau.web.app/?utm_source=dev.to"&gt;Mindleau&lt;/a&gt; is a journaling app. People open it when their head is crowded, which is often when they're somewhere inconvenient. So the startup has one rule:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing before &lt;code&gt;runApp()&lt;/code&gt; is allowed to wait on the network.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's how the startup is structured to keep that rule, line by line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole startup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;WidgetsFlutterBinding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureInitialized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Firebase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initializeApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;options:&lt;/span&gt; &lt;span class="n"&gt;DefaultFirebaseOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentPlatform&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="n"&gt;FlutterError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;               &lt;span class="c1"&gt;// crash routing (below)&lt;/span&gt;
  &lt;span class="n"&gt;PlatformDispatcher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onError&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="n"&gt;FirebaseCrashlytics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCrashlyticsCollectionEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;screenshotMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'MINDLEAU_SCREENSHOT_MODE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;fallback:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AnalyticsService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FirebaseAnalytics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;screenshotMode:&lt;/span&gt; &lt;span class="n"&gt;screenshotMode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;purchases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;PurchaseService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;screenshotMode:&lt;/span&gt; &lt;span class="n"&gt;screenshotMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;analytics:&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AdService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;adsRemoved:&lt;/span&gt; &lt;span class="n"&gt;purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;adsRemoved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ads&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;adsRemoved&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;adsRemoved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NotificationService&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;syncService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebaseSyncService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppDatabase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SanctuaryRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;syncService:&lt;/span&gt; &lt;span class="n"&gt;syncService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="n"&gt;purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bindIdentity&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
  &lt;span class="n"&gt;runApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;repository:&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;analytics:&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;ads:&lt;/span&gt; &lt;span class="n"&gt;ads&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;Here's what each &lt;code&gt;await&lt;/code&gt; in that function actually touches:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Await&lt;/th&gt;
&lt;th&gt;Touches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Firebase.initializeApp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bundled config only. No network round trip.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;setCrashlyticsCollectionEnabled&lt;/code&gt;, &lt;code&gt;dotenv.load&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Local files and flags.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PurchaseService.initialize&lt;/code&gt;, &lt;code&gt;AdService.initialize&lt;/code&gt;, &lt;code&gt;FirebaseSyncService.initialize&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;SharedPreferences, plus SDK setup that is either local or explicitly not awaited.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;notifications.initialize&lt;/code&gt;, &lt;code&gt;repository.initialize&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Local plugins and the SQLite database.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The network work happens, but none of it is on this list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: optional SDKs initialize to "disabled", never to an exception
&lt;/h2&gt;

&lt;p&gt;Ads, purchases and cloud sync are all optional from the user's point of view. Journaling works without any of them. So their initializers are written to never throw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Never throws: a missing API key, unsupported platform or SDK failure&lt;/span&gt;
&lt;span class="c1"&gt;/// yields a disabled service, and the app keeps showing ads as before.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PurchaseService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;({...})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside, every external call is wrapped, logged and turned into a state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PurchasesConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_configured&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;Purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addCustomerInfoUpdateListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_applyCustomerInfo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// network: not awaited&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="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stackTrace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'RevenueCat configuration failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s"&gt;'mindleau.purchases'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;error:&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;stackTrace:&lt;/span&gt; &lt;span class="n"&gt;stackTrace&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="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI asks &lt;code&gt;purchases.enabled&lt;/code&gt; and hides the buttons that can't work. A broken purchases SDK costs the business some revenue for a while. It never costs the user their journal.&lt;/p&gt;

&lt;p&gt;The same pattern covers a missing &lt;code&gt;.env&lt;/code&gt; key: &lt;code&gt;dotenv.getBool(..., fallback: false)&lt;/code&gt; instead of the bare &lt;code&gt;getBool&lt;/code&gt;, which throws when the key is absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: heavy SDKs wait for the first frame
&lt;/h2&gt;

&lt;p&gt;The Google Mobile Ads SDK is the heaviest thing Mindleau starts. Its initialization bootstraps a WebView-backed renderer that competes with Flutter for the GPU, and on a slow device or network it can take seconds. So &lt;code&gt;AdService.initialize&lt;/code&gt; returns immediately and schedules the real work for after the first frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;WidgetsBinding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addPostFrameCallback&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_bootstrapSdk&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="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The launch screen dismisses, the first frame paints, and then ads warm up. And for users who bought Remove Ads, the SDK is never started at all, as I described in &lt;a href="https://aishayy.com/blog/flutter-remove-ads-in-app-purchase-revenuecat/?utm_source=dev.to"&gt;the in-app purchase article&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: the database is the only thing the first screen needs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppDatabase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SanctuaryRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;syncService:&lt;/span&gt; &lt;span class="n"&gt;syncService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AppDatabase&lt;/code&gt; opens SQLite lazily through Drift, running any pending schema migrations on first access. &lt;code&gt;repository.initialize()&lt;/code&gt; seeds the bundled guided-session catalogue into the database, then starts the network work in the background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_seedLearnSessions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                               &lt;span class="c1"&gt;// local&lt;/span&gt;

  &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_syncService&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;retryPendingAccountDeletion&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// network&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_canAttemptCloudSync&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;syncNow&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;                                   &lt;span class="c1"&gt;// network&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;Because the UI only reads from the local database, the first screen is complete as soon as SQLite answers. When sync finishes later, Drift's query streams push the new rows to the screen on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: unfinished work is retried on every cold start
&lt;/h2&gt;

&lt;p&gt;Some work can't finish when it's started. The clearest example is account deletion. If the connection is too slow when someone deletes their account, the local data is wiped immediately, and the server-side deletion is recorded as pending in SharedPreferences.&lt;/p&gt;

&lt;p&gt;Every cold start retries it, unawaited, before anything else touches the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_syncService&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;retryPendingAccountDeletion&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sync also checks it first, so new data is never pushed into an account that's supposed to be gone. (The details of that flow are in &lt;a href="https://aishayy.hashnode.dev/offline-first-sync-in-flutter-with-sqlite-firestore" rel="noopener noreferrer"&gt;my Hashnode post on offline-first sync&lt;/a&gt;.) The general pattern: &lt;strong&gt;persist the intent locally, then retry until it succeeds&lt;/strong&gt;, on startup and when connectivity returns. Don't make the user wait for the network to agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 5: crash reporting is off until the user agrees, and quiet about the network
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FirebaseCrashlytics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCrashlyticsCollectionEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crashlytics starts disabled. The welcome screen asks for consent, and Settings has a toggle. A journaling app shouldn't send anything anywhere before the user has said yes.&lt;/p&gt;

&lt;p&gt;The error handlers are installed before anything else can fail, and they filter one specific kind of noise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;FlutterError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&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="n"&gt;isRecoverableFirebaseAuthError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Ignored recoverable Firebase auth connectivity error.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s"&gt;'mindleau.crashlytics'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;FirebaseCrashlytics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recordFlutterFatalError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;isRecoverableFirebaseAuthError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;error&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="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;FirebaseAuthException&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;'network-request-failed'&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;Firebase Auth refreshes tokens in the background and reports &lt;code&gt;network-request-failed&lt;/code&gt; as an unhandled error when the device is offline. For a local-first app that's expected behaviour, not a crash. Without the filter, the crash dashboard fills with "the user was on a plane", and the real crashes are harder to see. The filter is deliberately narrow: one exception type and one error code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 6: build-time modes are flags, not code branches
&lt;/h2&gt;

&lt;p&gt;Store screenshots need deterministic data, no ads, no analytics and no review prompts. Rather than a separate build flavour, that's one &lt;code&gt;.env&lt;/code&gt; flag read once in &lt;code&gt;main()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;screenshotMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'MINDLEAU_SCREENSHOT_MODE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;fallback:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's passed into the services that care: analytics turns itself off, purchases skip RevenueCat, and the repository seeds fictional fixture data. No service reads the flag on its own, so there's exactly one place to look when screenshots behave differently from the real app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 7: everything is passed in, so everything can be tested
&lt;/h2&gt;

&lt;p&gt;Every service is constructed in &lt;code&gt;main()&lt;/code&gt; and handed to &lt;code&gt;App&lt;/code&gt; through its constructor, then exposed with &lt;code&gt;provider&lt;/code&gt;. No service is a global singleton that reaches for &lt;code&gt;FirebaseAuth.instance&lt;/code&gt; from inside a widget. The repository takes its &lt;code&gt;SyncService&lt;/code&gt; as an interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SanctuaryRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;syncService:&lt;/span&gt; &lt;span class="n"&gt;syncService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests construct the same repository with &lt;code&gt;AppDatabase.forTesting(NativeDatabase.memory())&lt;/code&gt; and a fake sync service that can be made slow, offline or failing on demand. The startup order in &lt;code&gt;main()&lt;/code&gt; is the same dependency graph the tests build, just with real implementations.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your own startup
&lt;/h2&gt;

&lt;p&gt;Two quick tests show whether your startup has a network dependency hiding in it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cold start in flight mode.&lt;/strong&gt; Force-quit, enable flight mode, open the app. It should reach a usable first screen as quickly as it does online.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold start on a terrible connection.&lt;/strong&gt; Use the Network Link Conditioner (iOS) or an emulator's network throttling at "Very Bad Network" with packet loss. This is harsher than flight mode, because requests start and then hang instead of failing fast. Any &lt;code&gt;await&lt;/code&gt; on the network before &lt;code&gt;runApp()&lt;/code&gt; shows up here as a long splash screen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If either one is slow, search &lt;code&gt;main()&lt;/code&gt; for every &lt;code&gt;await&lt;/code&gt; and ask what it touches. Usually it's one line, and the fix is an &lt;code&gt;unawaited()&lt;/code&gt; and a state the UI can show.&lt;/p&gt;

&lt;p&gt;You can read more about the app in the &lt;a href="https://aishayy.com/work/mindleau/?utm_soruce=dev.to"&gt;Mindleau case study&lt;/a&gt;, or see the related posts on &lt;a href="https://aishayy.com/blog/flutter-remove-ads-in-app-purchase-revenuecat/?utm_soruce=dev.to"&gt;one-time in-app purchases&lt;/a&gt; and &lt;a href="https://aishayy.com/blog/offline-first-sync-failure-cases-flutter/?utm_source=dev.to"&gt;offline-first sync failure cases&lt;/a&gt;. If you'd like your app's architecture reviewed, that's what my &lt;a href="https://aishayy.com/hire#technical-consulting" rel="noopener noreferrer"&gt;technical product consulting&lt;/a&gt; is for.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>architecture</category>
      <category>firebase</category>
      <category>performance</category>
    </item>
    <item>
      <title>Encoding NDEF Records by Hand in Flutter — URIs, Text, vCards and Wi-Fi</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Thu, 29 Oct 2026 05:59:34 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/encoding-ndef-records-by-hand-in-flutter-uris-text-vcards-and-wi-fi-55ch</link>
      <guid>https://dev.to/ayeshaiftikhar/encoding-ndef-records-by-hand-in-flutter-uris-text-vcards-and-wi-fi-55ch</guid>
      <description>&lt;p&gt;Most Flutter NFC tutorials stop at "write a URL". When I built the encoding layer for an NFC app, I needed eleven record types, exact byte counts before writing, and records that every phone would read. Packages like &lt;code&gt;nfc_manager&lt;/code&gt; give you the session and the tag; building the right bytes is still up to you.&lt;/p&gt;

&lt;p&gt;This post walks through four record types — URI, text, vCard and Wi-Fi — at the byte level, with the Dart I ended up with.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 30-second NDEF refresher
&lt;/h2&gt;

&lt;p&gt;An NDEF message is a list of records. Each record has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;TNF&lt;/strong&gt; (Type Name Format): &lt;em&gt;well-known&lt;/em&gt; (NFC Forum types like &lt;code&gt;U&lt;/code&gt; and &lt;code&gt;T&lt;/code&gt;), &lt;em&gt;media&lt;/em&gt; (a MIME type), &lt;em&gt;external&lt;/em&gt;, and so on&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;type&lt;/strong&gt; (e.g. &lt;code&gt;U&lt;/code&gt;, or &lt;code&gt;text/vcard&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;an optional &lt;strong&gt;ID&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;payload&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With &lt;code&gt;ndef_record&lt;/code&gt;/&lt;code&gt;nfc_manager_ndef&lt;/code&gt;, building one looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NdefRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;typeNameFormat:&lt;/span&gt; &lt;span class="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TypeNameFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;wellKnown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;type:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromList&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mh"&gt;0x55&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="c1"&gt;// 'U'&lt;/span&gt;
  &lt;span class="nl"&gt;identifier:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&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="nl"&gt;payload:&lt;/span&gt; &lt;span class="n"&gt;payloadBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is always the payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. URI records: the one-byte prefix trick
&lt;/h2&gt;

&lt;p&gt;The NFC Forum URI record (&lt;code&gt;U&lt;/code&gt;, &lt;code&gt;0x55&lt;/code&gt;) starts its payload with a &lt;strong&gt;prefix code&lt;/strong&gt;: one byte that stands for a common URI start. &lt;code&gt;0x04&lt;/code&gt; means &lt;code&gt;https://&lt;/code&gt;, &lt;code&gt;0x02&lt;/code&gt; means &lt;code&gt;https://www.&lt;/code&gt;, &lt;code&gt;0x05&lt;/code&gt; means &lt;code&gt;tel:&lt;/code&gt;, &lt;code&gt;0x06&lt;/code&gt; means &lt;code&gt;mailto:&lt;/code&gt; — 36 entries in all, index 0 being "no prefix".&lt;/p&gt;

&lt;p&gt;On an NTAG213 with 144 bytes of user memory, saving 12 bytes on &lt;code&gt;https://www.&lt;/code&gt; matters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;uriPrefixList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'http://www.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'https://www.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'http://'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'https://'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s"&gt;'tel:'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'mailto:'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … the rest of the NFC Forum table … */&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NdefRecord&lt;/span&gt; &lt;span class="nf"&gt;encodeUri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;prefixIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;uriPrefixList&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uriPrefixList&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;prefixIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uriPrefixList&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NdefRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;typeNameFormat:&lt;/span&gt; &lt;span class="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TypeNameFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;wellKnown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;type:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromList&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mh"&gt;0x55&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="nl"&gt;identifier:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&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="nl"&gt;payload:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromList&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;prefixIndex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rest&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;&lt;strong&gt;Gotcha:&lt;/strong&gt; order matters. &lt;code&gt;https://www.&lt;/code&gt; must be checked before &lt;code&gt;https://&lt;/code&gt;. The table's own order puts the &lt;code&gt;www.&lt;/code&gt; variants first, so walking it in order does the right thing.&lt;/p&gt;

&lt;p&gt;The same encoder handles phone (&lt;code&gt;tel:&lt;/code&gt;), SMS (&lt;code&gt;sms:&lt;/code&gt;), email (&lt;code&gt;mailto:&lt;/code&gt;), social profiles and map locations (&lt;code&gt;geo:lat,lng&lt;/code&gt;), because they're all just URIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Text records: a status byte you can't skip
&lt;/h2&gt;

&lt;p&gt;Text records (&lt;code&gt;T&lt;/code&gt;, &lt;code&gt;0x54&lt;/code&gt;) start with a &lt;strong&gt;status byte&lt;/strong&gt;: bit 7 = encoding (0 for UTF-8), low 6 bits = length of the language code that follows. Then the language code, then the text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NdefRecord&lt;/span&gt; &lt;span class="nf"&gt;encodeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// bit 7 clear = UTF-8&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NdefRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;typeNameFormat:&lt;/span&gt; &lt;span class="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TypeNameFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;wellKnown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;type:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromList&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mh"&gt;0x54&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="nl"&gt;identifier:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&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="nl"&gt;payload:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromList&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;If you forget the status byte, some readers show the first character of your text as garbage, and others drop the record entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. vCards: just a media record
&lt;/h2&gt;

&lt;p&gt;Contact cards aren't an NFC Forum type. They're a &lt;strong&gt;media&lt;/strong&gt; record whose type is the MIME string &lt;code&gt;text/vcard&lt;/code&gt; and whose payload is the vCard text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NdefRecord&lt;/span&gt; &lt;span class="nf"&gt;encodeMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;data&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="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NdefRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;typeNameFormat:&lt;/span&gt; &lt;span class="n"&gt;ndef&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TypeNameFormat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;media&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;type:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="nl"&gt;identifier:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&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="nl"&gt;payload:&lt;/span&gt; &lt;span class="n"&gt;Uint8List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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;That's simple to write, but vCards get big fast. A card with name, title, company, phone, email, website and a few social URLs easily passes 144 bytes, so it won't fit an NTAG213. NTAG215 (504 bytes) is the practical minimum. Because each record can be encoded before writing, the app can show the exact size and warn before a write fails halfway.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Wi-Fi: TLVs inside a MIME record
&lt;/h2&gt;

&lt;p&gt;Wi-Fi credentials use the Wi-Fi Alliance's Simple Configuration format: a media record of type &lt;code&gt;application/vnd.wfa.wsc&lt;/code&gt; whose payload is nested &lt;strong&gt;TLVs&lt;/strong&gt; (2-byte type, 2-byte length, value, all big-endian).&lt;/p&gt;

&lt;p&gt;The outer TLV is a &lt;em&gt;Credential&lt;/em&gt; (&lt;code&gt;0x100E&lt;/code&gt;) containing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;TLV&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Network Index&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x1026&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x1045&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;network name, UTF-8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth Type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x1003&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x0020&lt;/code&gt; WPA2-Personal, &lt;code&gt;0x0001&lt;/code&gt; open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encryption Type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x100F&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x0008&lt;/code&gt; AES, &lt;code&gt;0x0002&lt;/code&gt; WEP, &lt;code&gt;0x0001&lt;/code&gt; none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network Key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x1027&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;password (omitted for open networks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MAC Address&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x1020&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FF FF FF FF FF FF&lt;/code&gt; (any)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_tlv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;[..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_tlv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x1026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_tlv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x1045&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ssid&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_tlv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x1003&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x0020&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="c1"&gt;// WPA2-Personal&lt;/span&gt;
  &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_tlv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x100F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_u16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x0008&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="c1"&gt;// AES&lt;/span&gt;
  &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_tlv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x1027&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_tlv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x1020&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_tlv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x100E&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Platform note:&lt;/strong&gt; Android's NFC stack reads this record and offers to join the network. iPhones don't join Wi-Fi from NFC tags, so if your users include iPhone owners, pair the tag with a Wi-Fi QR code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count bytes before you write
&lt;/h2&gt;

&lt;p&gt;Every encoder above returns a real &lt;code&gt;NdefRecord&lt;/code&gt;, so &lt;code&gt;NdefMessage(records: [...]).byteLength&lt;/code&gt; gives the exact size before you touch a tag. Compare that with the tag's capacity (the app reads it during the scan) and you can refuse an oversized write with a clear message, instead of a vague platform error halfway through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The NDEF spec is small enough to implement by hand, and doing so pays off. You control every byte, you can test encoders without hardware, and you can tell users exactly what fits on their tag.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I built this for &lt;a href="https://nfc-wizard.web.app/?utm_source=dev.to"&gt;NFC Wizard&lt;/a&gt;, an NFC toolkit for iPhone and Android - Coming Soon(disclosure: it's my app). If you're choosing tags for a project, I wrote a plain-language guide to &lt;a href="https://nfc-wizard.web.app/blog/choose-the-right-nfc-tag?utm_source=dev.to"&gt;NTAG213 vs NTAG215 vs NTAG216&lt;/a&gt; and one on &lt;a href="https://nfc-wizard.web.app/blog/write-wifi-to-nfc-tag?utm_source=dev.to"&gt;writing Wi-Fi to a tag&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>nfc</category>
      <category>ios</category>
    </item>
    <item>
      <title>Offline-First Sync Failures That Flight Mode Won't Show You</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Mon, 26 Oct 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/a-flutter-app-startup-that-never-waits-on-the-network-2c1c</link>
      <guid>https://dev.to/ayeshaiftikhar/a-flutter-app-startup-that-never-waits-on-the-network-2c1c</guid>
      <description>&lt;p&gt;*&lt;em&gt;Clock skew, deletes that come back, duplicate entries on a flaky connection, and old databases: the tests I added after auditing my own Flutter app.&lt;br&gt;
*&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; Testing offline-first sync by switching on flight mode only tests the polite case: the network is either there or it isn't. The bugs that lose people's data come from messier conditions: a device with the wrong clock, a delete on one phone while another phone edits the same entry, a connection that drops just after a write was sent, two syncs running at once, and a database created by an older version of the app. This post walks through each one in a real Flutter app, what goes wrong under last-write-wins, and the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I build &lt;a href="https://mindleau.web.app/?utm_source=dev.to"&gt;Mindleau&lt;/a&gt;, a brain dump journal and mood tracker. It's local-first: every entry is written to SQLite (via Drift) on the phone, and a sync layer pushes it to Firestore in the background. I've written about &lt;a href="https://aishayy.com/blog/local-first-flutter-app-drift-firebase-mindleau/?utm_source=dev.to"&gt;the local-first architecture&lt;/a&gt; and about &lt;a href="https://aishayy.hashnode.dev/offline-first-sync-in-flutter-with-sqlite-firestore/?utm_source=dev.to"&gt;anonymous auth, tombstones and timeouts&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Those posts describe the design. This one is about testing it against conditions that are worse than "offline". I went through my own sync code looking for the cases that would lose someone's journal entry, and found more than I expected.&lt;/p&gt;
&lt;h2&gt;
  
  
  The design under test
&lt;/h2&gt;

&lt;p&gt;Briefly, so the failures make sense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every row has an &lt;code&gt;updatedAt&lt;/code&gt; timestamp (set from the device clock), a &lt;code&gt;syncStatus&lt;/code&gt; (&lt;code&gt;synced&lt;/code&gt;, &lt;code&gt;pending_create&lt;/code&gt;, &lt;code&gt;pending_update&lt;/code&gt;) and, for deletes, a &lt;code&gt;deletedAt&lt;/code&gt; tombstone.&lt;/li&gt;
&lt;li&gt;A sync runs &lt;strong&gt;pull, then push&lt;/strong&gt;. Pull fetches remote rows and merges them: the local row is kept only if its &lt;code&gt;updatedAt&lt;/code&gt; is newer, otherwise the remote row replaces it. Push sends every non-synced row with &lt;code&gt;set(..., merge: true)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Each row gets a &lt;code&gt;remoteId&lt;/code&gt; (a UUID) the first time it's pushed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's last-write-wins (LWW), the default for most hand-rolled sync. It's simple and it's usually fine. Here's where it isn't.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. The device with its clock ahead
&lt;/h2&gt;

&lt;p&gt;LWW compares timestamps from two different clocks. If one phone's clock runs an hour fast, its edits beat every other device's edits for an hour, even edits made later in real time.&lt;/p&gt;

&lt;p&gt;In code, the merge looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;remoteUpdatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_parseRemoteDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'updated_at'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;brainDumpRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remoteId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remoteId&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSingleOrNull&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="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remoteUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// otherwise: overwrite local with remote&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what it doesn't check: whether the local row has an edit that hasn't been pushed yet. Phone B edits an entry offline. Phone A (clock fast) edited it earlier. B reconnects, pulls first, sees A's "newer" timestamp, and overwrites its own unpushed edit. Nobody is told.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt; inject the clock. Make &lt;code&gt;DateTime.now()&lt;/code&gt; a dependency of the repository so tests can give each simulated device its own skew. Edit the same entry on both, sync both, and assert that both pieces of text still exist somewhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; two parts. Use a server timestamp (Firestore's &lt;code&gt;FieldValue.serverTimestamp()&lt;/code&gt;) as the ordering for remote writes, so the server orders writes instead of trusting phone clocks. And never overwrite a row that is still pending locally. That's a conflict, and conflicts get the treatment in section 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A delete on one phone, an edit on the other
&lt;/h2&gt;

&lt;p&gt;Tombstones solve the classic problem of a deleted row being downloaded again. They don't solve this one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Phone A, offline, deletes an entry: &lt;code&gt;deletedAt = 10:00&lt;/code&gt;, &lt;code&gt;updatedAt = 10:00&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Phone B, online, edits the same entry at 10:05 and syncs. Its push sends &lt;code&gt;deleted_at: null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Phone A reconnects. Pull sees remote &lt;code&gt;updated_at = 10:05&lt;/code&gt;, which is newer, and replaces the local row, including &lt;code&gt;deletedAt = null&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The entry the user deleted comes back.&lt;/strong&gt; In a journal, that's worse than losing an edit. People delete entries because they don't want them to exist.&lt;/p&gt;

&lt;p&gt;Reverse the order (edit first, delete later) and the edit is silently lost instead. Both orderings, and the case where the timestamps are equal, need a test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; treat delete-versus-edit as a conflict instead of letting a timestamp decide it. My rule is that an edit made after a delete keeps the entry alive, but as a conflict copy the user can see and delete again. Being asked twice is better than losing the text, and much better than the deleted text coming back unannounced.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Conflict copies before CRDTs
&lt;/h2&gt;

&lt;p&gt;When two devices change the same entry, there are three options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick a winner&lt;/strong&gt; (LWW). Simple, and silently loses one side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge automatically&lt;/strong&gt; (CRDTs, operational transforms). Correct for collaborative editing, but heavy machinery for one person writing free text on two phones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep both&lt;/strong&gt; and let the person decide.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a personal journal, option 3 is the right middle ground. Conflicts are rare, the user is the only author, and they know which version they meant. The rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the local row is pending &lt;strong&gt;and&lt;/strong&gt; the remote row changed since the last sync, don't overwrite. Save the local version as a new entry marked "conflicted copy", then accept the remote version.&lt;/li&gt;
&lt;li&gt;Show conflicted copies plainly in the history so the user can keep, merge or delete them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a few dozen lines in the merge function and no new dependencies. If the app ever becomes collaborative, that's the time for a CRDT.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The connection that comes back just long enough
&lt;/h2&gt;

&lt;p&gt;Flight mode is binary. Real connections aren't: a train goes through a tunnel, a café's Wi-Fi accepts the request and drops the response. This is the case I found the most interesting, because it creates duplicates.&lt;/p&gt;

&lt;p&gt;Here's the push for a new entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;remoteId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remoteId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="n"&gt;_uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsertBrainDump&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s"&gt;'id'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;remoteId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...});&lt;/span&gt;
  &lt;span class="c1"&gt;// only now is remoteId saved locally:&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;brainDumpRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BrainDumpRowsCompanion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;remoteId:&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remoteId&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;Every network call has a 15-second timeout, which is right: without one, a flaky connection leaves the app stuck on a spinner. But a timeout only means &lt;em&gt;we stopped waiting&lt;/em&gt;. Firestore may already have queued the write, and it lands later. The local row never saved its &lt;code&gt;remoteId&lt;/code&gt;, so the next sync generates a &lt;strong&gt;new&lt;/strong&gt; UUID and pushes the same entry again. On the user's other phone, it now appears twice.&lt;/p&gt;

&lt;p&gt;The general rule: &lt;strong&gt;an operation that can be retried must be idempotent, and its identity has to exist before the first attempt.&lt;/strong&gt; The fix is to assign the &lt;code&gt;remoteId&lt;/code&gt; when the row is created (or save it before pushing), so a retry overwrites the same document instead of creating a new one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt; a fake sync service that applies the write and then throws a timeout. Run sync twice and assert one remote document, not two.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Two syncs at once
&lt;/h2&gt;

&lt;p&gt;Sync starts after every save, and again whenever connectivity comes back. On a flapping connection those overlap. With no guard, two syncs can both read the same pending row, both generate a UUID, and both push. That's the same duplicate as above, through a different door.&lt;/p&gt;

&lt;p&gt;The fix is a single in-flight sync that other callers wait on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_inFlight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;syncNow&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="n"&gt;_inFlight&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="n"&gt;_runSync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;whenComplete&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_inFlight&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If something changes while a sync is running, mark it dirty and run once more when it finishes, rather than starting a parallel one.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. An old database that already holds someone's journal
&lt;/h2&gt;

&lt;p&gt;A fresh install is the easiest test case and the least realistic one. The users who matter most have a database written by an older version of the app, with real entries in it.&lt;/p&gt;

&lt;p&gt;Mindleau's schema is on version 5. The migration from version 1 added sync columns like this:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;brain_dump_rows&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DEFAULT 0&lt;/code&gt; is the Unix epoch, 1 January 1970. Every row that existed before the upgrade claims it was last edited in 1970, so under LWW any remote copy of it wins, including a stale one. Not a problem on a fresh install, and a real problem on an upgraded one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt; check in a real database file from each old schema version (Drift can generate schema snapshots for this with &lt;code&gt;drift_dev schema dump&lt;/code&gt;), fill it with entries, run the migration, then run a sync against a remote that holds older versions of the same entries. Assert the local text survives. The backfill fix is one statement in the migration: set &lt;code&gt;updated_at = created_at&lt;/code&gt; where it's 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. "Saved on this phone" is not "backed up"
&lt;/h2&gt;

&lt;p&gt;Not a sync bug, but a product one that the sync design creates. Local-first apps make two different promises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Saved on this phone:&lt;/strong&gt; written to SQLite, survives restarts and offline use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backed up:&lt;/strong&gt; pushed to the server, and restorable if the phone is lost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mindleau shows a "pending changes" count in Settings, but nowhere near the entry itself. There's also a third state that's easy to miss: an entry synced to an &lt;em&gt;anonymous&lt;/em&gt; account is on the server, but the user can't restore it on a new phone until they link an email. Calling that "backed up" promises something the app can't deliver.&lt;/p&gt;

&lt;p&gt;The interface should say which promise has actually been kept, per entry or at least on the main screen: &lt;em&gt;saved on this phone&lt;/em&gt;, &lt;em&gt;backed up&lt;/em&gt;, or &lt;em&gt;backed up, add an email to restore it anywhere&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test list
&lt;/h2&gt;

&lt;p&gt;If you're testing an offline-first app, flight mode is the first test, not the last. This is the list I'd start from:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An upgrade from every old schema version, with real data in it.&lt;/li&gt;
&lt;li&gt;Two devices editing the same entry offline, then syncing in both orders.&lt;/li&gt;
&lt;li&gt;A device with its clock ahead (and one behind).&lt;/li&gt;
&lt;li&gt;A delete on one device while another edits the same entry, in both orders and with equal timestamps.&lt;/li&gt;
&lt;li&gt;A write that succeeds on the server but times out on the client, then a retry.&lt;/li&gt;
&lt;li&gt;Two syncs started at once.&lt;/li&gt;
&lt;li&gt;The app killed between "pushed" and "marked as synced".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these show up in a demo. They're the difference between an app people trust with their private thoughts and one they quietly stop using.&lt;/p&gt;

&lt;p&gt;You can read more about the app in the &lt;a href="https://aishayy.com/work/mindleau/?utm_source=dev.to"&gt;Mindleau case study&lt;/a&gt;. If you're building something local-first and want the sync reviewed against cases like these, that's part of my &lt;a href="https://aishayy.com/hire#technical-consulting" rel="noopener noreferrer"&gt;technical product consulting&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>firebase</category>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Adding a One-Time "Remove Ads" Purchase to a Local-First Flutter App</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:00:14 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/adding-a-one-time-remove-ads-purchase-to-a-local-first-flutter-app-4b0h</link>
      <guid>https://dev.to/ayeshaiftikhar/adding-a-one-time-remove-ads-purchase-to-a-local-first-flutter-app-4b0h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; Mindleau sells "Remove Ads" as a single non-consumable purchase through RevenueCat. A &lt;code&gt;PurchaseService&lt;/code&gt; owns the entitlement and caches it on the device. A single &lt;code&gt;ValueNotifier&amp;lt;bool&amp;gt;&lt;/code&gt; on the &lt;code&gt;AdService&lt;/code&gt; gates every banner, interstitial and app-open ad. If the user has already paid, the Google Mobile Ads SDK is never started. The RevenueCat customer is tied to the app's Firebase user ID, so a purchase follows the person, not the install.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://mindleau.web.app/?utm_source=dev.to"&gt;Mindleau&lt;/a&gt; is a free brain dump journal and mood tracker for iOS and Android. Every feature is free and there is no subscription. It's paid for by a small number of ads, kept out of writing and reflection moments. Some people would rather pay once than see any ads at all, so I added one in-app purchase: &lt;strong&gt;Remove Ads&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It sounds like a small feature. The purchase itself is one API call. The architecture around it is where the decisions are, and those decisions are what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The requirements
&lt;/h2&gt;

&lt;p&gt;I wrote these down before choosing anything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One-time, not a subscription.&lt;/strong&gt; Pay once, never see an ad again, nothing to cancel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No account required to buy.&lt;/strong&gt; Mindleau doesn't have a login wall, and the purchase shouldn't add one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A paying user must never see an ad.&lt;/strong&gt; Not on a cold start, not while offline, not for the half-second before the store SDK answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restorable on a new phone&lt;/strong&gt;, using the store's own restore flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The app must work if purchases are broken.&lt;/strong&gt; A missing key, an unsupported platform or a store outage means "show ads as before", not a crash.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deleting an account must remove the person from the purchase backend too.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why RevenueCat instead of &lt;code&gt;in_app_purchase&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Flutter's official &lt;code&gt;in_app_purchase&lt;/code&gt; plugin can do a non-consumable purchase. What it doesn't do is answer the question "does this user own it?" reliably. You either trust the device's purchase history or build receipt validation on a server.&lt;/p&gt;

&lt;p&gt;RevenueCat validates receipts on its servers and gives you an entitlement: a named yes or no (&lt;code&gt;ads_free&lt;/code&gt;) that you can check from any platform. Its free tier covers an app at Mindleau's scale, and it saves me from running a validation backend for an app that otherwise doesn't need one.&lt;/p&gt;

&lt;p&gt;The cost is another third-party SDK that receives an identifier for the user. That has to be listed in the privacy policy and in the store privacy labels.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            ┌───────────────────┐
            │  PurchaseService  │  owns: adsRemoved (cached), the package, identity
            └─────────┬─────────┘
                      │ addListener
                      ▼
            ┌───────────────────┐
            │     AdService     │  adsRemoved: ValueNotifier&amp;lt;bool&amp;gt;
            └─────────┬─────────┘
          ┌───────────┼─────────────┐
          ▼           ▼             ▼
       banners   interstitials   app-open
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two services, one direction. &lt;code&gt;PurchaseService&lt;/code&gt; knows about RevenueCat and nothing about ads. &lt;code&gt;AdService&lt;/code&gt; knows about AdMob and nothing about purchases. &lt;code&gt;main.dart&lt;/code&gt; connects them with one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;purchases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;PurchaseService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;screenshotMode:&lt;/span&gt; &lt;span class="n"&gt;screenshotMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;analytics:&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AdService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;adsRemoved:&lt;/span&gt; &lt;span class="n"&gt;purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;adsRemoved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ads&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;adsRemoved&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;adsRemoved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters. Purchases are initialized &lt;em&gt;before&lt;/em&gt; ads, so &lt;code&gt;AdService&lt;/code&gt; already knows on its first line whether this user has paid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirement 3: no ad flash on a cold start
&lt;/h2&gt;

&lt;p&gt;RevenueCat answers over the network. Ask it "does this user own &lt;code&gt;ads_free&lt;/code&gt;?" at launch and you'll wait somewhere between 100 ms and forever, depending on the connection. If you show ads while you wait, paying users see ads, which is the one thing they paid to avoid.&lt;/p&gt;

&lt;p&gt;So the last known answer is cached in &lt;code&gt;SharedPreferences&lt;/code&gt; and read synchronously at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PurchaseService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;entitlementId:&lt;/span&gt; &lt;span class="n"&gt;entitlementId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;prefs:&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;analytics:&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;adsRemoved:&lt;/span&gt;
      &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;screenshotMode&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;getBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_adsRemovedPrefsKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RevenueCat is then configured, and the real answer is fetched in the background with &lt;code&gt;unawaited(service.refresh())&lt;/code&gt;. Every time RevenueCat reports customer info, whether from a purchase, a restore, a refresh or its own update listener, it goes through one function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;_applyCustomerInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CustomerInfo&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;adsRemoved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entitlements&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;active&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;containsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entitlementId&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="n"&gt;adsRemoved&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;_adsRemoved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;_adsRemoved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adsRemoved&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_prefs&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;setBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_adsRemovedPrefsKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adsRemoved&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="n"&gt;notifyListeners&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;There's one writer for the cached value and one place that notifies. The cache can't drift from what the UI shows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; the cache is trusted until RevenueCat says otherwise. If someone is refunded while offline, they keep an ad-free app until the device reconnects. For a "remove ads" product that's the right side to err on: the cost of a wrong "yes" is a few ad impressions, and the cost of a wrong "no" is a paying customer seeing ads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't even start the ad SDK for paying users
&lt;/h2&gt;

&lt;p&gt;Starting the Google Mobile Ads SDK isn't free. It spins up its own WebView-backed renderer, which competes with Flutter for the GPU during the first frame. If the user has paid, there's no reason to pay that cost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&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="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;adsRemoved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_sdkReady&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_sdkReady&lt;/code&gt; is completed anyway, so any code awaiting it moves on. It just never gets an ad.&lt;/p&gt;

&lt;h2&gt;
  
  
  One gate for every ad format
&lt;/h2&gt;

&lt;p&gt;Mindleau has three ad formats, and each one has its own load and show path. Every one of those paths checks the same notifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&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="n"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;interstitialEnabled&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;adsRemoved&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a purchase completes mid-session, ads that are already loaded have to go too, not only future ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;_onAdsRemovedChanged&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;adsRemoved&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;_pendingLaunchAppOpenShow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;_interstitialAd&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;_interstitialAd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;_appOpenAd&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;_appOpenAd&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Banners are widgets, so they listen to the same &lt;code&gt;ValueNotifier&lt;/code&gt; and collapse when it flips. The user taps "Remove Ads", the store sheet closes, and the banner under their journal is gone, with no restart needed.&lt;/p&gt;

&lt;p&gt;Note the asymmetry: removing ads takes effect immediately, while &lt;em&gt;restoring&lt;/em&gt; ads after a refund only happens on the next cold start, because the SDK was never started. That's deliberate. It keeps the "never show a paying user an ad" path simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirement 2 and 4: identity without a login wall
&lt;/h2&gt;

&lt;p&gt;Mindleau already has a user ID for everyone: the Firebase Auth UID, anonymous until the user links an email. RevenueCat lets you log in a customer with your own ID, so the purchase is attached to that UID.&lt;/p&gt;

&lt;p&gt;The tricky part is &lt;em&gt;when&lt;/em&gt;. The Firebase user can appear after RevenueCat is configured (first launch), change (email restore on a new phone) or disappear (account deletion). So &lt;code&gt;PurchaseService&lt;/code&gt; doesn't take an ID. It takes a function that reads the current one, plus a stream that says "it might have changed":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bindIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;read:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;userId:&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;displayName:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;changes:&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;syncStateChanges&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;syncIdentity()&lt;/code&gt; is idempotent and cheap. It remembers what it last sent and skips anything already in sync. That makes it safe to call often, and it's called in three places: at startup, on every identity change, and &lt;strong&gt;right before every purchase or restore&lt;/strong&gt;. The last one matters. If the identity sync failed earlier because the user was offline, the purchase still lands on the right customer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PurchaseOutcome&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;purchaseRemoveAds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;package&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_removeAdsPackage&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="n"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;package&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;PurchaseOutcome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unavailable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;syncIdentity&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restore uses the store's own mechanism (&lt;code&gt;Purchases.restorePurchases()&lt;/code&gt;), which works from the App Store or Google Play account, so a user who never linked an email can still restore on a new phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirement 6: account deletion reaches the purchase backend
&lt;/h2&gt;

&lt;p&gt;When someone deletes their Mindleau account, the RevenueCat customer shouldn't keep their Firebase ID and display name. After the Firebase account is deleted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;clearIdentity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_syncedUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;_syncedDisplayName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAnonymous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// An empty value deletes the reserved $displayName attribute.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDisplayName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;_applyCustomerInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logOut&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="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Identity reset failed: &lt;/span&gt;&lt;span class="si"&gt;$error&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s"&gt;'mindleau.purchases'&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;&lt;code&gt;syncIdentity()&lt;/code&gt; also calls this on its own if it sees the Firebase UID go from a value to &lt;code&gt;null&lt;/code&gt;, so deletion paths I haven't thought of are still covered.&lt;/p&gt;

&lt;p&gt;One consequence to be honest about: after deleting their account, a paying user is a fresh anonymous customer with no entitlement, so ads come back until they tap &lt;strong&gt;Restore Purchases&lt;/strong&gt;. The store account still owns the purchase, so nothing is lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirement 5: purchases must fail closed, to "ads as before"
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;PurchaseService.initialize&lt;/code&gt; never throws. A missing API key, an unsupported platform (web, desktop) or an SDK error produces a disabled service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Never throws: a missing API key, unsupported platform or SDK failure&lt;/span&gt;
&lt;span class="c1"&gt;/// yields a disabled service, and the app keeps showing ads as before.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The settings tiles for Remove Ads and Restore Purchases are hidden when &lt;code&gt;enabled&lt;/code&gt; is false, so nobody taps a button that can't work.&lt;/p&gt;

&lt;p&gt;Purchase results are an enum, not a boolean or an exception:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;PurchaseOutcome&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nothingToRestore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unavailable&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A boolean can't tell "the user changed their mind" from "the store is down", and the UI should respond very differently to each: silence for a cancel, a gentle message for "nothing to restore", an error for a failure. The enum makes the UI handle every case, and the compiler checks the &lt;code&gt;switch&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring it
&lt;/h2&gt;

&lt;p&gt;Purchase analytics are logged inside &lt;code&gt;PurchaseService&lt;/code&gt;, not in the UI, so every path is counted the same way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;remove_ads_purchase_started&lt;/code&gt;, &lt;code&gt;remove_ads_purchased&lt;/code&gt; (with price and currency), &lt;code&gt;remove_ads_purchase_cancelled&lt;/code&gt;, &lt;code&gt;remove_ads_purchase_failed&lt;/code&gt; (with error code)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;purchases_restore_started&lt;/code&gt;, &lt;code&gt;purchases_restored&lt;/code&gt;, &lt;code&gt;purchases_restore_failed&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One case is worth calling out: the store reports success but the entitlement isn't active (for example a misconfigured product in the RevenueCat dashboard). That's logged as a failure with &lt;code&gt;entitlement_inactive&lt;/code&gt;, because from the user's point of view it is one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do the same way again
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache the entitlement and trust it at launch.&lt;/strong&gt; It's the only way to guarantee no ad flash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One notifier, checked by every ad path.&lt;/strong&gt; Ad formats get added over time; a single gate keeps them honest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity as a function plus a change stream&lt;/strong&gt;, re-synced before every purchase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never-throwing initializers&lt;/strong&gt; for every optional SDK. Monetization breaking should never break journaling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're adding monetization to a Flutter app and want a second pair of eyes on the architecture, you can read the &lt;a href="https://aishayy.com/work/mindleau" rel="noopener noreferrer"&gt;Mindleau case study&lt;/a&gt; or see how I help with &lt;a href="https://aishayy.com/hire#technical-consulting" rel="noopener noreferrer"&gt;technical product consulting&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>mobile</category>
      <category>architecture</category>
      <category>admob</category>
    </item>
    <item>
      <title>Compressing PDFs entirely in the browser with pdf.js, OffscreenCanvas and a Web Worker</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:38:22 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/compressing-pdfs-entirely-in-the-browser-with-pdfjs-offscreencanvas-and-a-web-worker-203c</link>
      <guid>https://dev.to/ayeshaiftikhar/compressing-pdfs-entirely-in-the-browser-with-pdfjs-offscreencanvas-and-a-web-worker-203c</guid>
      <description>&lt;p&gt;Most "free online PDF compressor" sites work the same way: you upload the file, a server shrinks it, and you download the result. When I built &lt;a href="https://pdfperfect.org/?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=backlinks"&gt;PDF Perfect&lt;/a&gt;, I wanted the opposite. The file should never leave the user's device, and you should be able to prove that by switching your Wi-Fi off halfway through.&lt;/p&gt;

&lt;p&gt;Merging, splitting and rotating in the browser are fairly well-trodden: &lt;a href="https://pdf-lib.js.org/?utm_soruce=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=backlinks"&gt;pdf-lib&lt;/a&gt; copies pages between documents without re-encoding anything. Compression was the interesting part, so this post covers how it works, the trade-offs I accepted, and what I'd do differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why compression is hard in the browser
&lt;/h2&gt;

&lt;p&gt;Desktop tools like Ghostscript compress a PDF by walking its object tree: they downsample embedded images, subset fonts and strip unused objects. That needs a full PDF rewriter with image codecs, and there's no small, well-maintained JavaScript library that does all of it.&lt;/p&gt;

&lt;p&gt;What the browser &lt;em&gt;does&lt;/em&gt; have is an excellent PDF renderer (Mozilla's pdf.js, the one inside Firefox) and a fast JPEG encoder (&lt;code&gt;canvas.convertToBlob&lt;/code&gt;). So the approach is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Render each page to a canvas at a chosen scale.&lt;/li&gt;
&lt;li&gt;Encode the canvas as a JPEG at a chosen quality.&lt;/li&gt;
&lt;li&gt;Build a new PDF where each page is just that image.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's a rasterising compressor. For scans and photo-heavy PDFs, which are the files people actually need to shrink, it works very well. For text documents it has a real cost, which I'll come back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it off the main thread
&lt;/h2&gt;

&lt;p&gt;Rendering 200 pages blocks the main thread for a long time, so it all runs in a Web Worker. Workers don't have a DOM, but they do have &lt;code&gt;OffscreenCanvas&lt;/code&gt;, and pdf.js can render into one.&lt;/p&gt;

&lt;p&gt;The core loop, simplified from the real worker:&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;const&lt;/span&gt; &lt;span class="nx"&gt;pdf&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;pdfjsLib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;arrayBuffer&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;promise&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;outDoc&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;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// pdf-lib&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&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;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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;viewport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getViewport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;promise&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;jpeg&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;canvas&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertToBlob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;image&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;outDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedJpg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;outDoc&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&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="na"&gt;y&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="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numPages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&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;outDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One canvas, reused.&lt;/strong&gt; The worker creates a single &lt;code&gt;OffscreenCanvas&lt;/code&gt; and resizes it for each page, rather than allocating a new canvas per page, which keeps memory steadier on long documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transfer, don't copy.&lt;/strong&gt; The result goes back to the main thread as an &lt;code&gt;ArrayBuffer&lt;/code&gt; in the transfer list, so a 300 MB output isn't duplicated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A timeout.&lt;/strong&gt; The main thread gives the worker 5 minutes. A tab that silently spins forever on a huge file is worse than a clear error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pdf.js worker inside my worker.&lt;/strong&gt; pdf.js wants its own worker script. Importing it with Vite's &lt;code&gt;?url&lt;/code&gt; suffix and setting &lt;code&gt;GlobalWorkerOptions.workerSrc&lt;/code&gt; works in both dev and production builds, but the server has to send &lt;code&gt;.mjs&lt;/code&gt; files as &lt;code&gt;text/javascript&lt;/code&gt;. My Apache host didn't, and the result was a very confusing production-only failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quality levels and "compress to 1 MB"
&lt;/h2&gt;

&lt;p&gt;The three presets are just (JPEG quality, render scale) pairs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Preset&lt;/th&gt;
&lt;th&gt;JPEG quality&lt;/th&gt;
&lt;th&gt;Scale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High Quality&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balanced&lt;/td&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum&lt;/td&gt;
&lt;td&gt;0.4&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Users often don't care about presets, though. They have a job portal that says "max 2 MB". So there's a Target File Size mode, which walks a ladder of five settings from best to smallest and returns the &lt;strong&gt;first&lt;/strong&gt; result that fits:&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="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;settings&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ladder&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;result&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;attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;settings&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compressedSize&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;targetBytes&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;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;smallest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;smallest&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compressedSize&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;smallest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compressedSize&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;smallest&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;smallest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// couldn't hit the target; show how close we got&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A binary search over quality would need fewer passes. But with only five meaningful steps and output size that doesn't change smoothly with quality, the linear ladder was simpler to reason about and gives the user the &lt;em&gt;best-looking&lt;/em&gt; file that fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off I had to be honest about
&lt;/h2&gt;

&lt;p&gt;Rasterising means text in the compressed file is no longer text. You can't select it, search it or have a screen reader read it, and at scale 1.0 very small print looks soft when you zoom in. For a scanned form that's irrelevant. For a 40-page contract exported from Word, it's a real loss, and the file can even get &lt;em&gt;bigger&lt;/em&gt;, because vector text is tiny.&lt;/p&gt;

&lt;p&gt;So the UI shows before and after sizes up front, the docs say plainly what happens, and the guides recommend re-exporting text documents from the source app instead. I'd rather lose a user to the right tool than hand them a worse file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the file never leaves
&lt;/h2&gt;

&lt;p&gt;"We don't upload your files" is easy to claim, so I wanted it to be easy to check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline test:&lt;/strong&gt; load a tool, turn off Wi-Fi and process a file. It still works. (For compression, pdf.js is lazy-loaded, so run it once while online first.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network tab:&lt;/strong&gt; process a file with DevTools open. You'll see the page, fonts and analytics, and no request with a body anywhere near your file's size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://pdfperfect.org/how-it-works?utm_source=dev.to"&gt;How it works&lt;/a&gt; page walks through both tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A structural compressor for text PDFs: downsample only the embedded images with pdf-lib and leave the text alone. It's harder, but it's the right answer for Word exports.&lt;/li&gt;
&lt;li&gt;A streaming approach for very large files, since the whole document is currently held in memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try it, the &lt;a href="https://pdfperfect.org/compress?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=backlinks"&gt;compressor is here&lt;/a&gt;. I'd love feedback from anyone who's fought pdf.js in a worker too.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm the developer of PDF Perfect.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pdf</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How Uber Computes Estimated Time of Arrival (ETA)</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Sat, 26 Sep 2026 17:05:43 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/how-uber-computes-estimated-time-of-arrival-eta-7eh</link>
      <guid>https://dev.to/ayeshaiftikhar/how-uber-computes-estimated-time-of-arrival-eta-7eh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Short answer: how does Uber compute ETA?&lt;/strong&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Uber snaps noisy GPS pings to the road network (map matching), finds candidate routes on a road graph, and predicts travel time for each road segment with machine-learning models fed by real-time traffic. It then sums and corrects the route estimate, models its uncertainty, and keeps refreshing the Uber ETA as the trip progresses.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a ride-hailing platform like Uber, Estimated Time of Arrival (ETA) is not a cosmetic feature. It is a core real-time decisioning system that directly influences conversion, matching quality, pricing behavior, and user trust.&lt;/p&gt;

&lt;p&gt;An inaccurate ETA increases cancellations, reduces marketplace liquidity, and weakens reliability perception. At scale, even small percentage errors can have measurable business impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Problem Definition
&lt;/h2&gt;

&lt;p&gt;Uber must estimate two different time horizons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pickup ETA:&lt;/strong&gt; Time for the driver to reach the rider&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trip ETA:&lt;/strong&gt; Time from pickup to destination&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This happens under hard constraints: real-time computation, global traffic variability, sparse-data regions, continuous driver movement, and noisy GPS streams.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. System Architecture Overview
&lt;/h2&gt;

&lt;p&gt;Uber's ETA stack is a multi-layer system where each layer solves a specific sub-problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Data ingestion for live GPS and traffic signals&lt;/li&gt;
&lt;li&gt;Map matching to align noisy locations to roads&lt;/li&gt;
&lt;li&gt;Routing to generate path candidates&lt;/li&gt;
&lt;li&gt;Feature engineering for temporal and contextual signals&lt;/li&gt;
&lt;li&gt;Machine learning for segment-level travel-time prediction&lt;/li&gt;
&lt;li&gt;Online serving for low-latency ETA responses&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Data Inputs that Drive Accuracy
&lt;/h2&gt;

&lt;p&gt;ETA quality depends on combining real-time, historical, and context-rich features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;**Real-time: **driver GPS, speed vectors, congestion, live traffic feeds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Historical:&lt;/strong&gt; time-of-day trends, weekday patterns, road speed distributions, weather and holiday effects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contextual:&lt;/strong&gt; city density, special events, roadwork, and construction zones&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Map Matching and Graph Routing
&lt;/h2&gt;

&lt;p&gt;Raw GPS is inherently noisy. Before route estimation, positions are snapped to likely road segments using probabilistic map-matching methods, often including Hidden Markov Model-style inference.&lt;/p&gt;

&lt;p&gt;The road network is then represented as a weighted directed graph where intersections are nodes and road segments are edges. Edge weights represent traversal time, not just distance.&lt;/p&gt;

&lt;p&gt;Routing engines apply optimized shortest-path methods such as Dijkstra, A*, and contraction hierarchies with real-time weight updates from live traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Travel Time Estimation via Machine Learning
&lt;/h2&gt;

&lt;p&gt;Uber does not rely purely on static shortest-path output. It predicts travel time at the road-segment level using machine learning.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Current speed and congestion&lt;/li&gt;
&lt;li&gt;Historical average segment speed&lt;/li&gt;
&lt;li&gt;Time bucket and day effects&lt;/li&gt;
&lt;li&gt;Weather condition&lt;/li&gt;
&lt;li&gt;Road type and intersection complexity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model evolution typically progresses from linear models to gradient boosting and then to deep temporal architectures that better capture time-series traffic dynamics.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Pickup ETA Computation Logic
&lt;/h2&gt;

&lt;p&gt;Pickup ETA is often harder than trip ETA because drivers may be moving, finishing another ride, or deviating in dense traffic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Pickup ETA ≈ Remaining current segment time + optimal route time + intersection delay adjustments.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The system also computes uncertainty margins so user-facing predictions are robust under variance.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Real-Time Recalculation
&lt;/h2&gt;

&lt;p&gt;ETA is continuously refreshed every few seconds using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live GPS deltas&lt;/li&gt;
&lt;li&gt;Congestion updates&lt;/li&gt;
&lt;li&gt;Route deviation detection&lt;/li&gt;
&lt;li&gt;Dynamic re-routing logic
If traffic spikes or route conditions shift, ETA is recomputed immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. Offline Training and Online Serving
&lt;/h2&gt;

&lt;p&gt;The architecture is split into two stages for scale and latency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline:&lt;/strong&gt; train on billions of trips, build region-specific models, retrain continuously&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Online:&lt;/strong&gt; low-latency inference with feature store lookups, caching, and approximate serving optimizations
Production latency targets are strict, commonly sub-100 ms for inference pathways.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9. Uncertainty and Confidence Modeling
&lt;/h2&gt;

&lt;p&gt;ETA systems must model variance explicitly rather than output a single deterministic number.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Confidence intervals&lt;/li&gt;
&lt;li&gt;Percentile ETAs (for example P50 and P90)&lt;/li&gt;
&lt;li&gt;Error calibration curves&lt;/li&gt;
&lt;li&gt;This enables messaging like &lt;strong&gt;"Arriving in 5–7 minutes"&lt;/strong&gt; and improves perceived reliability.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. Global Scaling Challenges
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;City diversity:&lt;/strong&gt; each city has distinct traffic behavior requiring regionalized models&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sparse regions:&lt;/strong&gt; cold-start geographies rely more on map priors, traffic APIs, and transfer learning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPS distortion:&lt;/strong&gt; urban canyons require smoothing with Kalman filters and trajectory correction&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  11. Continuous Improvement Loop
&lt;/h2&gt;

&lt;p&gt;Uber improves ETA with a feedback cycle that combines A/B testing, prediction error pipelines, cancellation correlation analysis, and operational feedback from drivers and riders.&lt;/p&gt;

&lt;p&gt;When ETA error patterns correlate with cancellation spikes, retraining and feature adjustments are prioritized.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Business Impact
&lt;/h2&gt;

&lt;p&gt;Accurate ETA improves ride acceptance, reduces cancellations, raises driver utilization, and increases marketplace efficiency. Even small gains in ETA precision create significant revenue and trust impact at global scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Lessons for Engineers
&lt;/h2&gt;

&lt;p&gt;Do not rely only on shortest-path algorithms&lt;br&gt;
Predict at segment level instead of full-route level&lt;br&gt;
Separate heavy offline ML from low-latency online serving&lt;br&gt;
Model uncertainty explicitly, not optionally&lt;br&gt;
Retrain continuously as traffic behavior shifts&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaway
&lt;/h2&gt;

&lt;p&gt;Uber's ETA engine is far more than distance divided by speed. It is a distributed, real-time intelligence system combining graph theory, probabilistic modeling, deep learning, and infrastructure engineering under strict latency and reliability constraints.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>machinelearning</category>
      <category>scalability</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>How I Built a Local-First Journaling App with Flutter, Drift and Firebase</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Wed, 23 Sep 2026 12:33:13 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/how-i-built-a-local-first-journaling-app-with-flutter-drift-and-firebase-1i34</link>
      <guid>https://dev.to/ayeshaiftikhar/how-i-built-a-local-first-journaling-app-with-flutter-drift-and-firebase-1i34</guid>
      <description>&lt;p&gt;I build &lt;a href="https://mindleau.web.app/?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=backlinks"&gt;Mindleau&lt;/a&gt;, a free brain-dump journal and mood tracker for iOS and Android. From day one, it had one hard requirement: &lt;strong&gt;you should be able to open the app and start writing without creating an account or having an internet connection.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Journal entries are some of the most private things people type, and the moment someone needs to empty their head is probably the worst possible time to show them a sign-up form.&lt;/p&gt;

&lt;p&gt;That requirement shaped the whole architecture. Here's how it's built, including the tradeoffs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flutter&lt;/strong&gt; for iOS and Android from one codebase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift&lt;/strong&gt; (typed SQLite) as the source of truth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firebase Auth + Cloud Firestore&lt;/strong&gt; for optional background sync&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;provider&lt;/strong&gt; + &lt;code&gt;ChangeNotifier&lt;/code&gt; / &lt;code&gt;ValueNotifier&lt;/code&gt; for state. No Riverpod, no Bloc. The app is small enough that it didn't need them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central rule: &lt;strong&gt;the UI never talks to Firestore.&lt;/strong&gt; Every screen reads and writes through a single repository, and the repository only writes to SQLite. Sync happens afterwards, in the background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Screens ──► SanctuaryRepository ──► Drift (SQLite)   ◄── source of truth
                      │
                      └──(background)──► SyncService ──► Firestore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1. Every syncable table carries its own sync state
&lt;/h2&gt;

&lt;p&gt;Instead of a separate outbox table, each syncable row records whether it has been pushed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BrainDumpRows&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;IntColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;autoIncrement&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;remoteId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;DateTimeColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;DateTimeColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;updatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)();&lt;/span&gt;
  &lt;span class="n"&gt;DateTimeColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;deletedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;   &lt;span class="c1"&gt;// soft delete&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;mood&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;focusTagsCsv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()();&lt;/span&gt;
  &lt;span class="n"&gt;TextColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;syncStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Constant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'synced'&lt;/span&gt;&lt;span class="p"&gt;))();&lt;/span&gt;       &lt;span class="c1"&gt;// pending_create | pending_update | synced&lt;/span&gt;
  &lt;span class="n"&gt;DateTimeColumn&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;lastSyncedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nullable&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;Four columns carry the sync logic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;remoteId&lt;/code&gt; maps the local autoincrement ID to the Firestore document ID.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;updatedAt&lt;/code&gt; drives conflict resolution (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deletedAt&lt;/code&gt; is a tombstone. Hard-deleting a row locally would leave nothing to tell the server it's gone.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;syncStatus&lt;/code&gt; makes "what still needs pushing?" a single indexed query.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Writes are local and synchronous; sync is fire-and-forget
&lt;/h2&gt;

&lt;p&gt;Saving a brain dump is a plain SQLite insert, marked as pending, followed by a sync attempt the UI doesn't wait for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;saveBrainDump&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;mood&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;focusTags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;into&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;brainDumpRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;BrainDumpRowsCompanion&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;createdAt:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;updatedAt:&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="nl"&gt;mood:&lt;/span&gt; &lt;span class="n"&gt;mood&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;focusTagsCsv:&lt;/span&gt; &lt;span class="n"&gt;_encodeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;focusTags&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="nl"&gt;syncStatus:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'pending_create'&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="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;syncNow&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// never blocks the save&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user sees their entry immediately. Whether sync then succeeds, fails, or can't run because there's no signal changes nothing on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Sync is pull-then-push, last-write-wins
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;syncNow()&lt;/code&gt; runs the same steps every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;syncNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_canAttemptCloudSync&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureSignedIn&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pullRemoteChanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pushPreferences&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pushBrainDumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pushMoodCheckIns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pushLearnProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&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="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stackTrace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Sync failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;error:&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;stackTrace:&lt;/span&gt; &lt;span class="n"&gt;stackTrace&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;Pulling first means remote changes (from another device) are merged before local pending rows are pushed. The merge rule is plain &lt;strong&gt;last-write-wins on &lt;code&gt;updatedAt&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;brainDumpRows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remoteId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remoteId&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSingleOrNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Local copy is newer: keep it; it'll be pushed next.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remoteUpdatedAt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tradeoff:&lt;/strong&gt; LWW can lose an edit if the same entry is changed on two offline devices. For a personal journal, where one person is almost always on one device at a time, that's acceptable. A CRDT would add a lot of complexity to prevent a conflict that almost never happens here.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Anonymous auth first, email later
&lt;/h2&gt;

&lt;p&gt;"No account required" and "sync across devices" pull in opposite directions. Firebase anonymous auth solves it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On first sync, the app signs in anonymously. The user never sees a form, but their data has a stable &lt;code&gt;users/{uid}&lt;/code&gt; path in Firestore.&lt;/li&gt;
&lt;li&gt;If they later add an email, the anonymous account is &lt;strong&gt;linked&lt;/strong&gt; to an email-link credential, so the same UID and data carry over.&lt;/li&gt;
&lt;li&gt;On a new phone, the email link restores that account.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If anonymous auth is disabled or unreachable, &lt;code&gt;ensureSignedIn()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; and sync is skipped. The app keeps working from SQLite either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Bound every network call
&lt;/h2&gt;

&lt;p&gt;This bug took me a while to understand. Firestore and Firebase Auth calls &lt;strong&gt;have no built-in timeout&lt;/strong&gt;. On a flaky connection that isn't fully offline, an &lt;code&gt;await&lt;/code&gt; can hang forever, leaving a spinner with no way out. Every network call now goes through one wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;_networkTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_withTimeout&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;_networkTimeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;onTimeout:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NetworkTimeoutException&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;Because the UI never awaits sync, a timeout just means "try again later". When connectivity comes back, a listener calls &lt;code&gt;syncNow()&lt;/code&gt; and every row that isn't &lt;code&gt;synced&lt;/code&gt; goes up.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Migrations are part of the product
&lt;/h2&gt;

&lt;p&gt;Moving from "local only" to "local-first with sync" meant adding sync columns to tables that already held real user data. Drift's &lt;code&gt;MigrationStrategy&lt;/code&gt; makes this explicit and versioned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nd"&gt;@override&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;schemaVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@override&lt;/span&gt;
&lt;span class="n"&gt;MigrationStrategy&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;migration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MigrationStrategy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;onUpgrade:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&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="n"&gt;from&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&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="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brainDumpRows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brainDumpRows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remoteId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brainDumpRows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brainDumpRows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;syncStatus&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="c1"&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="n"&gt;from&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&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="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stillnessSessionRows&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One lesson: SQLite won't add a &lt;code&gt;NOT NULL&lt;/code&gt; column without a SQL-level default, and Drift's &lt;code&gt;clientDefault&lt;/code&gt; only exists in Dart. So &lt;code&gt;updated_at&lt;/code&gt; needed a raw &lt;code&gt;ALTER TABLE ... ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0&lt;/code&gt; for the rows already on users' phones.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Not everything has to sync
&lt;/h2&gt;

&lt;p&gt;Guided stillness sessions (a timer-based breathing pause) are &lt;strong&gt;local-only&lt;/strong&gt; on purpose. They only feed streaks and the 30-day heatmap, so syncing them wasn't worth the extra code and the extra data leaving the device. Deciding what &lt;em&gt;not&lt;/em&gt; to sync is part of the privacy design.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with the sync columns.&lt;/strong&gt; Adding them in migration v2 was avoidable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store tags as a JSON column, not CSV.&lt;/strong&gt; CSV was quick at first and has been awkward to work with since.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the timeout wrapper on day one.&lt;/strong&gt; Hanging awaits are the worst kind of offline bug because they only show up on bad networks, never at your desk.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If you're building anything personal (journals, habit trackers, notes), local-first is less work than it sounds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make SQLite the source of truth.&lt;/li&gt;
&lt;li&gt;Put a &lt;code&gt;syncStatus&lt;/code&gt; and &lt;code&gt;updatedAt&lt;/code&gt; on every row.&lt;/li&gt;
&lt;li&gt;Never let the UI wait for the network.&lt;/li&gt;
&lt;li&gt;Use anonymous auth so "no account" and "sync" can coexist.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can see the result in &lt;a href="https://mindleau.web.app/?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=backlinks"&gt;Mindleau&lt;/a&gt;. It's free on iOS and Android, and you can start writing without signing up. Questions about the sync design are welcome in the comments.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>firebase</category>
      <category>architecture</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How to Generate a Secure JWT Secret Key in Node.js</title>
      <dc:creator>Ayesha Iftikhar</dc:creator>
      <pubDate>Wed, 13 Aug 2025 10:35:52 +0000</pubDate>
      <link>https://dev.to/ayeshaiftikhar/how-to-generate-a-secure-jwt-secret-key-in-nodejs-335f</link>
      <guid>https://dev.to/ayeshaiftikhar/how-to-generate-a-secure-jwt-secret-key-in-nodejs-335f</guid>
      <description>&lt;p&gt;When working with JWT (JSON Web Tokens) in a Node.js application, your secret key plays a critical role in signing and verifying tokens.&lt;/p&gt;

&lt;p&gt;A weak or predictable key can compromise your application’s security, so it’s best to generate it randomly.&lt;/p&gt;

&lt;p&gt;Here’s how you can easily create a strong JWT secret key using Node.js:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add Script to &lt;code&gt;package.json&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open your package.json and inside the "scripts" section, add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"scripts": {
  "jwt-key": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Run the script&lt;/strong&gt;&lt;br&gt;
In your terminal run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm run jwt-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a47f7c20e4f9a87c6d0af6e6c8c4bc25d2a8e7c4a9f5c30f72db7d9a48f1c3d2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy that string into your &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you use this often, you can even combine it with a direct write to .env so it replaces the JWT secret automatically:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"scripts": {
  "jwt-key": "echo JWT_SECRET=$(node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\") &amp;gt; .env"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will overwrite &lt;code&gt;.env&lt;/code&gt; with the new secret each time.&lt;/p&gt;

</description>
      <category>node</category>
      <category>jwt</category>
      <category>npm</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
