<?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: Mohamed Haizoun</title>
    <description>The latest articles on DEV Community by Mohamed Haizoun (@mohamed_haizoun_ca3869828).</description>
    <link>https://dev.to/mohamed_haizoun_ca3869828</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%2F4022550%2F77bd1ebe-052c-4059-a970-b8c1c3785433.png</url>
      <title>DEV Community: Mohamed Haizoun</title>
      <link>https://dev.to/mohamed_haizoun_ca3869828</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohamed_haizoun_ca3869828"/>
    <language>en</language>
    <item>
      <title>ASO Is Product Work, Not Marketing — A Developer's Case</title>
      <dc:creator>Mohamed Haizoun</dc:creator>
      <pubDate>Thu, 09 Jul 2026 10:08:57 +0000</pubDate>
      <link>https://dev.to/mohamed_haizoun_ca3869828/aso-is-product-work-not-marketing-a-developers-case-42pe</link>
      <guid>https://dev.to/mohamed_haizoun_ca3869828/aso-is-product-work-not-marketing-a-developers-case-42pe</guid>
      <description>&lt;p&gt;In most teams, App Store Optimization lands on whoever owns "marketing." The keywords get handed to a growth contractor, the screenshots go to a designer, and the developers go back to building features. I want to argue that this org chart is backwards. ASO is not a marketing channel you bolt on after the app is built. It is product work, and treating it as anything else leaves compounding growth on the table.&lt;/p&gt;

&lt;p&gt;I've shipped and iterated on enough store listings to have changed my mind about this. Here's the case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The store listing is your first feature
&lt;/h2&gt;

&lt;p&gt;Before a user taps a single button in your app, they interact with a product surface you built: the icon, the title, the screenshots, the first three lines of the description. That surface has a conversion rate. It has a funnel. It responds to iteration. By every definition we apply to in-app screens, the store listing &lt;em&gt;is&lt;/em&gt; a screen — it's just the one that runs before install.&lt;/p&gt;

&lt;p&gt;When a designer treats the onboarding flow as product work but the store listing as "marketing assets," they're drawing a line that the user doesn't experience. To the user it's one continuous journey: search result → listing → install → onboarding → aha moment. Optimizing four of those five steps and outsourcing the middle one is a strange way to build a product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keywords are user research in disguise
&lt;/h2&gt;

&lt;p&gt;Here's what changed my thinking most. Keyword research is usually framed as a marketing tactic — find high-volume, low-competition terms, stuff them in the metadata. But look at what a keyword actually is: &lt;strong&gt;the exact language a user typed when they had the problem your app solves.&lt;/strong&gt; That is the purest user research you will ever get. It's unprompted, it's at the moment of intent, and there's a lot of it.&lt;/p&gt;

&lt;p&gt;If your app is a habit tracker and the data shows people searching "stop doom scrolling" far more than "habit tracker," that is not a metadata insight. That's a &lt;em&gt;positioning&lt;/em&gt; insight. It tells you what job users are hiring your app for, in their words. A marketing team will use it to rank. A product team will use it to rename features, rewrite onboarding copy, and maybe reprioritize the roadmap. The second team wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention and ranking are the same signal
&lt;/h2&gt;

&lt;p&gt;The old model of ASO was mechanical: right keywords in the right fields, watch the rank. That model is mostly dead. Modern store algorithms lean heavily on &lt;strong&gt;behavioral signals&lt;/strong&gt; — install-to-open rate, day-1 and day-7 retention, session frequency, uninstall rate. The store is asking, in effect, "when we send someone this app, are they happy a week later?"&lt;/p&gt;

&lt;p&gt;Sit with what that means. &lt;strong&gt;You cannot optimize your ranking without optimizing your retention.&lt;/strong&gt; The single highest-leverage ASO move available to most apps is not a keyword change — it's fixing the day-1 drop-off. And fixing day-1 drop-off is, unambiguously, product work: onboarding, activation, the first-run experience, the moment the user first gets value. The store now rewards the same thing your PM already wanted. ASO and product strategy have collapsed into the same activity, and a lot of teams haven't noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;If you accept that ASO is product work, a few things change about how you operate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Developers read reviews.&lt;/strong&gt; Not because it's their job to reply, but because one-star reviews are bug reports and feature requests with a distribution penalty attached. A review that says "crashes when I add a photo" is telling you about a bug &lt;em&gt;and&lt;/em&gt; dragging your rating &lt;em&gt;and&lt;/em&gt; suppressing your rank. Three problems, one root cause, and it's engineering's to fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots get versioned like features.&lt;/strong&gt; You A/B test them, you write them down, you attribute conversion changes to specific edits. "We changed screenshot 2 and install rate moved 4%" is a product experiment, and it should live in the same place as your other experiments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The listing ships with the release.&lt;/strong&gt; New feature that changes what the app is for? The store listing changes in the same cycle, because the listing is part of the product's story, not an afterthought handled two sprints later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The counterargument, fairly stated
&lt;/h2&gt;

&lt;p&gt;The honest objection: ASO involves real marketing craft — competitive keyword strategy, paid UA interplay, seasonal timing, localization economics. A senior developer is not automatically good at any of that, and pretending the marketing discipline doesn't exist would be its own mistake. That's true. The claim isn't that developers should own every part of ASO or that specialist skill doesn't matter. It's narrower: &lt;strong&gt;the parts of ASO that move the needle most — retention, positioning, the listing-to-onboarding continuity — are product decisions, and they suffer when they're treated as someone else's post-launch cleanup.&lt;/strong&gt; Keep the specialists. Just stop drawing the org-chart line in a place the user never feels.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reframe
&lt;/h2&gt;

&lt;p&gt;Try this the next time you plan a release. Instead of "we'll build the feature, then hand it to growth for ASO," ask: &lt;em&gt;what does this feature change about who searches for us, what they expect, and whether they'll still be here in a week?&lt;/em&gt; Answer those in the same room where you decide what to build. That's not marketing borrowing your engineers. That's product finally including the surface that runs before install.&lt;/p&gt;

&lt;p&gt;The apps that compound aren't the ones with the cleverest keyword stuffing. They're the ones that treated the entire journey — search to aha — as one product, owned by the people who build the product.&lt;/p&gt;

</description>
      <category>aso</category>
      <category>mobile</category>
      <category>marketing</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Wiring RevenueCat Subscriptions into a Flutter App: A Production Setup</title>
      <dc:creator>Mohamed Haizoun</dc:creator>
      <pubDate>Thu, 09 Jul 2026 10:06:27 +0000</pubDate>
      <link>https://dev.to/mohamed_haizoun_ca3869828/wiring-revenuecat-subscriptions-into-a-flutter-app-a-production-setup-3dnf</link>
      <guid>https://dev.to/mohamed_haizoun_ca3869828/wiring-revenuecat-subscriptions-into-a-flutter-app-a-production-setup-3dnf</guid>
      <description>&lt;p&gt;Subscriptions are where a lot of Flutter apps quietly lose money. Not because the paywall is ugly, but because the plumbing is wrong: entitlements checked in the wrong place, restore-purchases missing, receipts validated on the client, no single source of truth for "is this user premium?" &lt;a href="https://www.revenuecat.com/" rel="noopener noreferrer"&gt;RevenueCat&lt;/a&gt; exists to handle that plumbing, but you still have to wire it correctly.&lt;/p&gt;

&lt;p&gt;This is a production-minded walkthrough — not "call &lt;code&gt;purchasePackage&lt;/code&gt; and you're done," but the setup I'd actually ship: a clean entitlement gate, correct restore behavior, and a reactive premium state your whole app can trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use the raw billing APIs?
&lt;/h2&gt;

&lt;p&gt;Apple and Google's billing APIs give you a receipt. They do not give you: cross-platform entitlement state, server-side receipt validation, subscription status webhooks, or an answer to "did this user's trial convert?" You can build all of that yourself. On a solo or small team, you shouldn't. RevenueCat gives you a single &lt;code&gt;CustomerInfo&lt;/code&gt; object that tells you what the user is entitled to, validated server-side, identical on iOS and Android.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modeling products the right way
&lt;/h2&gt;

&lt;p&gt;Before touching code, get the mental model straight, because this is where people go wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Products&lt;/strong&gt; are the SKUs you configure in App Store Connect and Google Play (e.g. &lt;code&gt;pro_monthly&lt;/code&gt;, &lt;code&gt;pro_annual&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entitlements&lt;/strong&gt; are what a product &lt;em&gt;unlocks&lt;/em&gt; (e.g. &lt;code&gt;premium&lt;/code&gt;). Your app checks entitlements, never product IDs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offerings&lt;/strong&gt; are the set of products you show on a paywall. You can swap offerings remotely without shipping an update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The golden rule: &lt;strong&gt;your code asks "does the user have the &lt;code&gt;premium&lt;/code&gt; entitlement?" — it never asks "did they buy &lt;code&gt;pro_monthly&lt;/code&gt;?"&lt;/strong&gt; That indirection is what lets you rename SKUs, run price experiments, and add plans without touching app logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Initialization
&lt;/h2&gt;

&lt;p&gt;Initialize once at startup, before any purchase UI can appear:&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;initPurchases&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;Purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setLogLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&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;config&lt;/span&gt; &lt;span class="o"&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;Platform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isIOS&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;'appl_YOUR_IOS_KEY'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'goog_YOUR_ANDROID_KEY'&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;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;config&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 production note: &lt;strong&gt;do not&lt;/strong&gt; set an app user ID here unless you have a real, stable account ID for the user. If you use anonymous IDs now and add login later, RevenueCat's &lt;code&gt;logIn()&lt;/code&gt; will alias the two. Getting this wrong scatters a single customer across multiple identities and breaks your revenue reporting.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reactive premium gate
&lt;/h2&gt;

&lt;p&gt;The whole app needs to know, reactively, whether the user is premium. RevenueCat pushes updates through a listener; wrap it so the rest of your app consumes a simple boolean stream.&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;start&lt;/span&gt;&lt;span class="p"&gt;()&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;_onUpdate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;_refresh&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;_onUpdate&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;active&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="s"&gt;'premium'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;_controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active&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 RevenueCat validates receipts server-side and pushes changes through this listener, your gate updates correctly even when a subscription renews, lapses, or gets refunded in the background — no polling, no client-side receipt parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Presenting the paywall
&lt;/h2&gt;

&lt;p&gt;Fetch the current offering and render its packages. Never hard-code prices — read the localized &lt;code&gt;priceString&lt;/code&gt; so users see the right currency and amount for their store region.&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;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;buy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Package&lt;/span&gt; &lt;span class="n"&gt;package&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;try&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;result&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;Purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;purchasePackage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;package&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;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;customerInfo&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="s"&gt;'premium'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;PlatformException&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;e&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PurchasesErrorHelper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getErrorCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&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;code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;PurchasesErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;purchaseCancelledError&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// user backed out — not an error, don't alert&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;rethrow&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 &lt;code&gt;purchaseCancelledError&lt;/code&gt; branch matters. A huge share of "purchase failed" support tickets are really just users tapping cancel. Treat cancellation as a normal outcome, not a crash.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two things people forget
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Restore purchases.&lt;/strong&gt; Apple will reject your app without it, and users switching devices need it. Call &lt;code&gt;Purchases.restorePurchases()&lt;/code&gt; — the entitlement listener fires automatically, so there's no manual state update to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Trial and intro-offer eligibility.&lt;/strong&gt; If you advertise "7-day free trial" to a user who already used it, the store will charge them immediately and you'll get a chargeback and a one-star review. Check eligibility with &lt;code&gt;checkTrialOrIntroductoryPriceEligibility&lt;/code&gt; before showing trial copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing before you ship
&lt;/h2&gt;

&lt;p&gt;Use sandbox accounts on iOS and a license-tester account on Android. Verify, at minimum: a fresh purchase flips the gate, killing and relaunching the app keeps premium (state is restored from RevenueCat, not local storage), restore works on a second device, and cancelling mid-flow leaves the user unchanged. If all four pass, your plumbing is sound.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;Once this is in place, adding a new plan is a config change in the RevenueCat dashboard, not a release. Running a price experiment is an offering swap. Answering "how many trials converted last month?" is a dashboard glance instead of a data-engineering project. That's the real win — you spent an afternoon on plumbing so you never have to think about it again.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>revenuecat</category>
      <category>monetization</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Offline-First Sync in Flutter with Drift and Riverpod</title>
      <dc:creator>Mohamed Haizoun</dc:creator>
      <pubDate>Thu, 09 Jul 2026 10:03:33 +0000</pubDate>
      <link>https://dev.to/mohamed_haizoun_ca3869828/offline-first-sync-in-flutter-with-drift-and-riverpod-5een</link>
      <guid>https://dev.to/mohamed_haizoun_ca3869828/offline-first-sync-in-flutter-with-drift-and-riverpod-5een</guid>
      <description>&lt;p&gt;Most mobile apps are written as if the network is always there. It isn't. Users open your app in elevators, on planes, on rural roads, and in the ninety seconds before a train enters a tunnel. An offline-first app treats the local database as the source of truth and the network as a background detail — reads and writes never block on connectivity, and the server catches up when it can.&lt;/p&gt;

&lt;p&gt;This tutorial builds a small task app that works fully offline and syncs to a remote backend when a connection is available. We'll use &lt;a href="https://drift.simonbinder.eu/" rel="noopener noreferrer"&gt;Drift&lt;/a&gt; for a reactive local SQLite layer and &lt;a href="https://riverpod.dev/" rel="noopener noreferrer"&gt;Riverpod&lt;/a&gt; to expose that data to the UI. By the end you'll have a pattern you can drop into a real app.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "offline-first" actually means
&lt;/h2&gt;

&lt;p&gt;Three rules define the architecture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The UI only ever reads from the local database.&lt;/strong&gt; It never waits on an HTTP call to show data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes go to the local database first&lt;/strong&gt;, then get queued for the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sync engine reconciles the two&lt;/strong&gt; on a schedule, on reconnect, and on demand.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything below is in service of those three rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up Drift
&lt;/h2&gt;

&lt;p&gt;Add the dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;drift&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^2.16.0&lt;/span&gt;
  &lt;span class="na"&gt;sqlite3_flutter_libs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^0.5.20&lt;/span&gt;
  &lt;span class="na"&gt;path_provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^2.1.2&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^1.9.0&lt;/span&gt;
  &lt;span class="na"&gt;flutter_riverpod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^2.5.1&lt;/span&gt;
  &lt;span class="na"&gt;connectivity_plus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^6.0.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Define a &lt;code&gt;tasks&lt;/code&gt; table. The two columns that make sync possible are &lt;code&gt;updatedAt&lt;/code&gt; (for last-write-wins conflict resolution) and &lt;code&gt;syncStatus&lt;/code&gt; (so we know which rows still need to be pushed). The important detail is that Drift's &lt;code&gt;watch()&lt;/code&gt; returns a &lt;code&gt;Stream&lt;/code&gt; that re-emits every time the underlying rows change — that stream is what makes the UI feel instant, because a local write updates the screen before any network call is even attempted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exposing data through Riverpod
&lt;/h2&gt;

&lt;p&gt;Riverpod turns the Drift stream into something the widget tree can watch. There is nothing about HTTP in this layer — the UI has no idea the network exists.&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;tasksProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StreamProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&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="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;((&lt;/span&gt;&lt;span class="n"&gt;ref&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;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;databaseProvider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watchTasks&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;Creating a task is a local write plus a status flag set to &lt;code&gt;pending&lt;/code&gt;. The moment the write completes, &lt;code&gt;watchTasks()&lt;/code&gt; fires and the list rebuilds. The user sees their task immediately, connection or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sync engine
&lt;/h2&gt;

&lt;p&gt;Now the interesting part: pushing pending rows to the server and pulling remote changes. The engine runs when connectivity returns and when the app asks it to.&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="kd"&gt;sync&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;_running&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;// never overlap two sync passes&lt;/span&gt;
  &lt;span class="n"&gt;_running&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="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;_push&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// send pending rows, mark them synced&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_pull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// fetch remote changes since last pull&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;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Swallow and retry next cycle — offline-first means failure is normal.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_running&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design choices do the heavy lifting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;_running&lt;/code&gt; guard&lt;/strong&gt; prevents two sync passes from racing when connectivity flaps — a surprisingly common source of duplicate writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Last-write-wins on &lt;code&gt;updatedAt&lt;/code&gt;&lt;/strong&gt; is the simplest conflict strategy that actually works for single-user data. If two devices edit the same row, the newer timestamp wins. For collaborative data you'd reach for CRDTs, but don't pay that complexity tax until you need it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;This pattern scales further than you'd expect. Add a &lt;code&gt;deletedAt&lt;/code&gt; tombstone column so deletes propagate. Batch your push into a single request when the server supports it. Add exponential backoff to the catch block. But the core stays the same: &lt;strong&gt;local database is truth, network is a background job, and the UI never waits.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The full benefit shows up the first time a user files a bug that says "the app was slow on the subway" — and you realize your app doesn't have that bug, because it never touched the network to draw a screen.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>tutorial</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
