<?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: Ahmed ElFirgany</title>
    <description>The latest articles on DEV Community by Ahmed ElFirgany (@saqrelfirgany).</description>
    <link>https://dev.to/saqrelfirgany</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%2F3947211%2F88c20583-c5ad-4f7d-a244-327b4e229c09.png</url>
      <title>DEV Community: Ahmed ElFirgany</title>
      <link>https://dev.to/saqrelfirgany</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saqrelfirgany"/>
    <language>en</language>
    <item>
      <title>Stacked Pull Requests: how I would split a Flutter feature into four reviewable layers</title>
      <dc:creator>Ahmed ElFirgany</dc:creator>
      <pubDate>Sun, 02 Aug 2026 08:59:42 +0000</pubDate>
      <link>https://dev.to/saqrelfirgany/stacked-pull-requests-how-i-would-split-a-flutter-feature-into-four-reviewable-layers-41gl</link>
      <guid>https://dev.to/saqrelfirgany/stacked-pull-requests-how-i-would-split-a-flutter-feature-into-four-reviewable-layers-41gl</guid>
      <description>&lt;h2&gt;
  
  
  The problem is not the code
&lt;/h2&gt;

&lt;p&gt;I build Flutter apps. A normal feature touches four layers at once.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Models and json parsing&lt;/li&gt;
&lt;li&gt;Repository and API client&lt;/li&gt;
&lt;li&gt;State management&lt;/li&gt;
&lt;li&gt;Screens and widgets&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When I ship that as one branch, the pull request is somewhere between 1,000 and 4,000 lines. And then one of two things happens.&lt;/p&gt;

&lt;p&gt;Either the reviewer opens 40 files, scrolls for ten minutes, and writes "looks good". That is not a review. That is a signature.&lt;/p&gt;

&lt;p&gt;Or the reviewer does the job properly, takes three days, and leaves a comment on the models file. Now every screen above it has to change.&lt;/p&gt;

&lt;p&gt;The writing was fast. The review was slow. That is the real bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old workaround
&lt;/h2&gt;

&lt;p&gt;You already know it. Split the work into branches yourself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; feat/booking-models
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; feat/booking-repo     &lt;span class="c"&gt;# branched off models&lt;/span&gt;
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; feat/booking-state    &lt;span class="c"&gt;# branched off repo&lt;/span&gt;
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; feat/booking-ui       &lt;span class="c"&gt;# branched off state&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for about one day. Then a reviewer asks for a change in &lt;code&gt;feat/booking-models&lt;/code&gt;, and you rebase three branches by hand. Then it happens again. Most people give up and go back to the giant branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stacked pull requests change
&lt;/h2&gt;

&lt;p&gt;On July 30 2026 GitHub put &lt;a href="https://github.blog/changelog/2026-07-30-stacked-pull-requests-are-now-in-public-preview/" rel="noopener noreferrer"&gt;stacked pull requests&lt;/a&gt; into public preview.&lt;/p&gt;

&lt;p&gt;The idea is small. A stack is an ordered series of pull requests. Each pull request targets the one below it instead of targeting &lt;code&gt;main&lt;/code&gt;. Only the bottom one targets &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 plaintext"&gt;&lt;code&gt;PR #4  screens and widgets     -&amp;gt; targets PR #3
PR #3  cubits and state        -&amp;gt; targets PR #2
PR #2  repository and api      -&amp;gt; targets PR #1
PR #1  models and parsing      -&amp;gt; targets main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because each pull request only contains its own layer, opening PR #3 shows you the state management diff and nothing else. Not the models. Not the widgets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Flutter example
&lt;/h2&gt;

&lt;p&gt;Say I am building a booking flow. Here is the same feature, sliced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PR 1 - models and json parsing. Around 190 lines. Targets main.&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;class&lt;/span&gt; &lt;span class="nc"&gt;Booking&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&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;DateTime&lt;/span&gt; &lt;span class="n"&gt;startsAt&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;BookingStatus&lt;/span&gt; &lt;span class="n"&gt;status&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;Booking&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&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="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startsAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;factory&lt;/span&gt; &lt;span class="n"&gt;Booking&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Map&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;,&lt;/span&gt; &lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;json&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;Booking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;startsAt:&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'starts_at'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nl"&gt;status:&lt;/span&gt; &lt;span class="n"&gt;BookingStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;byName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&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;Pure data. A reviewer can check the json keys and the null handling in five minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PR 2 - repository and API client. Around 310 lines. Targets PR 1.&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;class&lt;/span&gt; &lt;span class="nc"&gt;BookingRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;BookingRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_client&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;ApiClient&lt;/span&gt; &lt;span class="n"&gt;_client&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;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Booking&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;fetchUpcoming&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;res&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;_client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'/bookings?filter=upcoming'&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;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&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="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Booking&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;Map&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;,&lt;/span&gt; &lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&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;Now the backend minded reviewer has something to read. Endpoints, error handling, caching. They do not need to wait for my widgets to exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PR 3 - cubits and state. Around 240 lines. Targets PR 2.&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;class&lt;/span&gt; &lt;span class="nc"&gt;BookingCubit&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;Cubit&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BookingState&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;BookingCubit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_repo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;super&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;BookingState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initial&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;BookingRepository&lt;/span&gt; &lt;span class="n"&gt;_repo&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;load&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;emit&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;BookingState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loading&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="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BookingState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loaded&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;_repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fetchUpcoming&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BookingState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&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;This is where most real bugs live. Loading states, race conditions, what happens when the user leaves the screen mid request. It deserves its own focused review, and here it gets one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PR 4 - screens and widgets. Around 380 lines. Targets PR 3.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The UI layer. The designer or the mobile reviewer looks at spacing, accessibility, and empty states. Nothing else is in the diff.&lt;/p&gt;

&lt;p&gt;Same feature. Same total lines. Four reviewers can now work in parallel instead of one reviewer working through everything in sequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  How you build a stack
&lt;/h2&gt;

&lt;p&gt;Install the extension.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh extension &lt;span class="nb"&gt;install &lt;/span&gt;github/gh-stack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also work with stacks on github.com, in the GitHub mobile app, or with a coding agent through the &lt;code&gt;gh-stack&lt;/code&gt; skill.&lt;/p&gt;

&lt;p&gt;The manual shape is the same idea you already know. Create a branch and open a pull request for your first layer. Then branch off that, and open the next pull request with the previous branch as its base.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh &lt;span class="nb"&gt;pr &lt;/span&gt;create &lt;span class="nt"&gt;--base&lt;/span&gt; main                  &lt;span class="c"&gt;# PR 1&lt;/span&gt;
gh &lt;span class="nb"&gt;pr &lt;/span&gt;create &lt;span class="nt"&gt;--base&lt;/span&gt; feat/booking-models   &lt;span class="c"&gt;# PR 2&lt;/span&gt;
gh &lt;span class="nb"&gt;pr &lt;/span&gt;create &lt;span class="nt"&gt;--base&lt;/span&gt; feat/booking-repo     &lt;span class="c"&gt;# PR 3&lt;/span&gt;
gh &lt;span class="nb"&gt;pr &lt;/span&gt;create &lt;span class="nt"&gt;--base&lt;/span&gt; feat/booking-state    &lt;span class="c"&gt;# PR 4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference is that GitHub now understands this is one stack. Every pull request shows a stack map at the top so a reviewer can see which layer they are looking at and where it sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merging
&lt;/h2&gt;

&lt;p&gt;This is the part that used to hurt, and it is the part that got fixed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Merge the latest ready pull request and it lands, along with every unmerged layer below it, in one operation.&lt;/li&gt;
&lt;li&gt;Or merge only one or more lower layers. The pull requests above stay open and automatically rebase and retarget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No manual rebase chain. And your existing branch protections and required checks still govern what reaches &lt;code&gt;main&lt;/code&gt;, so none of your rules change.&lt;/p&gt;

&lt;p&gt;Merge queue support is rolling out progressively over the coming weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is worth it
&lt;/h2&gt;

&lt;p&gt;Not every change needs a stack. A bug fix is a bug fix.&lt;/p&gt;

&lt;p&gt;It pays off when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The feature crosses architecture layers, which for mobile is most features&lt;/li&gt;
&lt;li&gt;More than one person should review, and they care about different things&lt;/li&gt;
&lt;li&gt;The bottom layer is stable and could ship on its own value&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is probably overkill when the whole change is under a couple hundred lines, or when you are the only reviewer anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual takeaway
&lt;/h2&gt;

&lt;p&gt;We spend a lot of energy trying to write code faster. AI made that part much faster for a lot of teams, which is exactly why review became the visible bottleneck.&lt;/p&gt;

&lt;p&gt;Stacked pull requests do not make you write faster. They make your work reviewable. On a real team that is usually where the days are lost.&lt;/p&gt;




&lt;p&gt;If you build mobile apps, I would like to know: what is the biggest pull request anyone has ever asked you to review, and did you actually review it?&lt;/p&gt;

</description>
      <category>github</category>
      <category>flutter</category>
      <category>git</category>
      <category>codereview</category>
    </item>
    <item>
      <title>Flutter web will not render ★ — and I shipped that to a live game</title>
      <dc:creator>Ahmed ElFirgany</dc:creator>
      <pubDate>Sat, 01 Aug 2026 10:43:29 +0000</pubDate>
      <link>https://dev.to/saqrelfirgany/flutter-web-will-not-render-and-i-shipped-that-to-a-live-game-12f6</link>
      <guid>https://dev.to/saqrelfirgany/flutter-web-will-not-render-and-i-shipped-that-to-a-live-game-12f6</guid>
      <description>&lt;p&gt;I spent eight days building a small physics game in Flutter and put it online. Then, before telling anyone about it, I opened the live link and played it like someone who had never seen it.&lt;/p&gt;

&lt;p&gt;The first level took one arrow. The tower came down. The win panel slid up and said &lt;strong&gt;Level Complete&lt;/strong&gt;, and under it, where the star rating goes, were three empty rectangles.&lt;/p&gt;

&lt;p&gt;It had been like that for two days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line
&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;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;'★'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stars&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;'☆'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stars&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;style:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;TextStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;color:&lt;/span&gt; &lt;span class="n"&gt;Cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;targetColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;fontSize:&lt;/span&gt; &lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;letterSpacing:&lt;/span&gt; &lt;span class="mi"&gt;4&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;Three filled stars for a clean clear, fewer as you spend more arrows. It reads fine. It runs fine. On macOS in the simulator it looked exactly like I wanted.&lt;/p&gt;

&lt;p&gt;On the web build it was three tofu boxes, because the star is U+2605 and the font the page was drawing with does not have that glyph.&lt;/p&gt;

&lt;p&gt;The mute button had the same problem. It was a Text widget holding two speaker emoji. Same result: a small round button with a broken square inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this does not show up where you are looking
&lt;/h2&gt;

&lt;p&gt;Flutter web draws text through CanvasKit, using the fonts you bundle plus a fallback set it fetches at runtime. That fallback covers a lot, but it is not a promise about every symbol in Unicode, and when it misses, you get the box. Locally you often never see it, because your desktop or simulator has a system font sitting there with the glyph in it.&lt;/p&gt;

&lt;p&gt;Two more reasons it survived:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests do not catch it.&lt;/strong&gt; In a Flutter widget test the default font draws every character as a box already, so a golden test cannot tell a working star from a broken one. There was nothing to fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I stopped reading my own UI.&lt;/strong&gt; By day three the win panel was furniture. I was watching the physics, not the panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is boring
&lt;/h2&gt;

&lt;p&gt;Material icons are an icon font that Flutter bundles. They always resolve, on every platform, offline.&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;Row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;mainAxisSize:&lt;/span&gt; &lt;span class="n"&gt;MainAxisSize&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;children:&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;0&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="mi"&gt;3&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="n"&gt;Padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;padding:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;EdgeInsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;symmetric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;horizontal:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;Icon&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;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stars&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Icons&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;star_rounded&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Icons&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;star_border_rounded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;color:&lt;/span&gt; &lt;span class="n"&gt;Cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;targetColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;size:&lt;/span&gt; &lt;span class="mi"&gt;38&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;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the mute button:&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;Icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;muted&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Icons&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;volume_off_rounded&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Icons&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;volume_up_rounded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;color:&lt;/span&gt; &lt;span class="n"&gt;Colors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;white&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;size:&lt;/span&gt; &lt;span class="mi"&gt;20&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 funny part is that the level select screen in the same game was already doing it the right way, with Icons.star_rounded. Two screens, two authors, eight days apart. Both of them me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I use now
&lt;/h2&gt;

&lt;p&gt;Never draw UI with a decorative Unicode character or an emoji inside a Text widget. Either use an Icon, or bundle a font you have checked yourself and set fontFamily on that text explicitly. If a character is not in ASCII and it is load bearing for the interface, treat it as an asset, not as text.&lt;/p&gt;

&lt;p&gt;A quick way to find them all in your own project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"[^&lt;/span&gt;&lt;span class="se"&gt;\x&lt;/span&gt;&lt;span class="s2"&gt;00-&lt;/span&gt;&lt;span class="se"&gt;\x&lt;/span&gt;&lt;span class="s2"&gt;7F]"&lt;/span&gt; lib/ &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.dart"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That printed seven lines for me. Two of them were bugs, one was an em dash that was fine, and the rest were comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is not about fonts
&lt;/h2&gt;

&lt;p&gt;While I was in there playing properly, I also found that the heads up display still said &lt;strong&gt;BALLS 3&lt;/strong&gt;. Two days earlier the ball had become an arrow, with a shaft and a fletching and a bow to fire it from. The counter never got the message. Neither did the title screen, which was still telling people to fling something.&lt;/p&gt;

&lt;p&gt;None of this was a physics bug. The Box2D world was correct the whole time. Every one of these lived in the first thirty seconds of the game, which is the only part most people ever see.&lt;/p&gt;

&lt;p&gt;I think that is the actual distance between code that works and a product that ships. The engine was right. The experience was not, and no test I could reasonably have written would have told me. The only thing that told me was opening the deployed URL and playing it with my hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  The game
&lt;/h2&gt;

&lt;p&gt;It is called &lt;strong&gt;Topple&lt;/strong&gt;. A bow and arrow knock down physics game in Flutter, using &lt;a href="https://pub.dev/packages/flame" rel="noopener noreferrer"&gt;Flame&lt;/a&gt; and &lt;a href="https://pub.dev/packages/flame_forge2d" rel="noopener noreferrer"&gt;flame_forge2d&lt;/a&gt; for real Box2D physics. Thirteen levels, three materials: wood, stone that works as a wall, and glass that shatters into shards and lets the arrow through. Nothing about a collapse is animated. Every block has its own mass and friction and finds its own way down.&lt;/p&gt;

&lt;p&gt;Play it: &lt;strong&gt;&lt;a href="https://saqrelfirgany.github.io/topple/" rel="noopener noreferrer"&gt;https://saqrelfirgany.github.io/topple/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Code and a devlog with one entry per day: &lt;strong&gt;&lt;a href="https://github.com/saqrelfirgany/topple" rel="noopener noreferrer"&gt;https://github.com/saqrelfirgany/topple&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you have a Flutter web build up somewhere, go and open it on a machine that is not yours. I would like to know what you find.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>webdev</category>
      <category>gamedev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Built a 3D Game in Flutter — With No Game Engine</title>
      <dc:creator>Ahmed ElFirgany</dc:creator>
      <pubDate>Sat, 25 Jul 2026 03:48:47 +0000</pubDate>
      <link>https://dev.to/saqrelfirgany/i-built-a-3d-game-in-flutter-with-no-game-engine-48b2</link>
      <guid>https://dev.to/saqrelfirgany/i-built-a-3d-game-in-flutter-with-no-game-engine-48b2</guid>
      <description>&lt;p&gt;Everyone says the same thing: &lt;em&gt;Flutter is for apps, not games.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I decided to find out where that's actually true — by building a &lt;strong&gt;3D endless runner&lt;/strong&gt; in Flutter. From scratch. No Unity, no Unreal, no game engine at all. Just Dart and Flutter's own rendering stack.&lt;/p&gt;

&lt;p&gt;It runs in your browser right now: &lt;strong&gt;&lt;a href="https://saqrelfirgany.github.io/flutter-scene-runner/" rel="noopener noreferrer"&gt;▶️ Play it live&lt;/a&gt;&lt;/strong&gt; &lt;em&gt;(desktop, keyboard controls — &lt;code&gt;A&lt;/code&gt;/&lt;code&gt;D&lt;/code&gt; to switch lanes, &lt;code&gt;Space&lt;/code&gt; to jump).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's how it works, and what building it taught me about how far Flutter can actually go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack: Flutter GPU + &lt;code&gt;flutter_scene&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The whole thing sits on two pieces most Flutter developers have never touched:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flutter GPU&lt;/strong&gt; — a low-level rendering API that talks almost directly to the GPU through &lt;strong&gt;Impeller&lt;/strong&gt; (the engine that replaced Skia). This is what makes real-time 3D possible at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://pub.dev/packages/flutter_scene" rel="noopener noreferrer"&gt;&lt;code&gt;flutter_scene&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — a higher-level 3D scene API on top of Flutter GPU. It gives you the building blocks a game needs: a scene graph of &lt;strong&gt;nodes&lt;/strong&gt;, a &lt;strong&gt;perspective camera&lt;/strong&gt;, meshes, and glTF model loading.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You build a tree of nodes, point a camera at it, and render it every frame inside a normal Flutter widget. That last part still surprises me — the 3D world is just a &lt;code&gt;CustomPaint&lt;/code&gt;-style surface living inside an otherwise ordinary Flutter app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Faking an infinite world with a handful of objects
&lt;/h2&gt;

&lt;p&gt;An "endless" runner obviously can't build an endless world — you'd run out of memory in seconds. The trick is &lt;strong&gt;object pooling&lt;/strong&gt;: you keep a small pool of track segments and obstacles, and as they scroll past the camera behind the player, you recycle them back to the front with new positions.&lt;/p&gt;

&lt;p&gt;The player never actually moves forward. &lt;strong&gt;The world moves toward the player&lt;/strong&gt;, and a fixed number of segments cycle forever. Same idea for obstacles and coins. It means the game runs at a constant, tiny memory footprint — which is exactly what keeps it smooth on weaker devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts that were genuinely hard
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Collision that &lt;em&gt;feels&lt;/em&gt; fair.&lt;/strong&gt; Detecting a collision is easy. Making it feel &lt;em&gt;right&lt;/em&gt; is not. Too strict and the player rages at hits that "clearly missed." Too loose and it feels cheap. Most of the tuning time went into the hit-boxes and the exact frame timing, not the math.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The camera.&lt;/strong&gt; A perspective camera that follows an endless track has to sell speed and depth without making the player motion-sick. Small changes to field-of-view and follow distance completely change how fast the game &lt;em&gt;feels&lt;/em&gt; at the same actual speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swapping the primitive for a real model.&lt;/strong&gt; The player started life as a literal spinning cube. Importing a real &lt;strong&gt;glTF&lt;/strong&gt; model meant wrestling with scale, orientation, and lighting until it sat in the world correctly instead of floating sideways in the dark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bug that flipped the world upside down.&lt;/strong&gt; One inverted axis, and the entire scene rendered mirrored — obstacles coming from the wrong direction, the track pointing the wrong way. Three hours of staring. The fix was a single line. That's build-in-public for you: I get to show you the wins &lt;em&gt;and&lt;/em&gt; the facepalms.&lt;/p&gt;

&lt;h2&gt;
  
  
  So — is Flutter for games?
&lt;/h2&gt;

&lt;p&gt;For a AAA title? No. That's not what this experiment was about.&lt;/p&gt;

&lt;p&gt;But for real-time 3D, running cross-platform (mobile, desktop, &lt;strong&gt;and the web build you just played&lt;/strong&gt;) from a single Dart codebase — it went much further than "Flutter is only for apps" would ever suggest. The fastest way to learn a tool's real limits is to take it somewhere it "isn't meant to go."&lt;/p&gt;

&lt;p&gt;I'm building this &lt;strong&gt;in public&lt;/strong&gt;, one increment at a time — collision, scoring, a start menu, a persistent leaderboard, particle effects. The code is all here: &lt;strong&gt;&lt;a href="https://github.com/saqrelfirgany/flutter-scene-runner" rel="noopener noreferrer"&gt;github.com/saqrelfirgany/flutter-scene-runner&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you've pushed Flutter somewhere it "wasn't meant to go," I'd love to hear about it in the comments. And if you want to follow the build, this is &lt;strong&gt;part 1&lt;/strong&gt; of the series. 👇&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Ahmed ElFirgany&lt;/strong&gt; — Mobile Expert | Flutter · Android Native · iOS Native | Expanding into Backend (PHP · Laravel) | performance &amp;amp; Clean Architecture; building a 3D game in Flutter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let's connect:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;💼 LinkedIn — &lt;a href="https://www.linkedin.com/in/ahmed-elfirgany" rel="noopener noreferrer"&gt;linkedin.com/in/ahmed-elfirgany&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💻 GitHub — &lt;a href="https://github.com/saqrelfirgany" rel="noopener noreferrer"&gt;github.com/saqrelfirgany&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🎮 Play the game (live) — &lt;a href="https://saqrelfirgany.github.io/flutter-scene-runner/" rel="noopener noreferrer"&gt;saqrelfirgany.github.io/flutter-scene-runner&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐦 X — &lt;a href="https://x.com/saqrelfirgany" rel="noopener noreferrer"&gt;@saqrelfirgany&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 WhatsApp — &lt;a href="https://wa.me/201025592065" rel="noopener noreferrer"&gt;wa.me/201025592065&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;✉️ &lt;a href="mailto:saqrelfirgany@gmail.com"&gt;saqrelfirgany@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>gamedev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Offline-First Flutter: How We Built a CRM That Manages 100K+ Leads With No Internet</title>
      <dc:creator>Ahmed ElFirgany</dc:creator>
      <pubDate>Sat, 23 May 2026 07:05:51 +0000</pubDate>
      <link>https://dev.to/saqrelfirgany/offline-first-flutter-how-we-built-a-crm-that-manages-100k-leads-with-no-internet-81g</link>
      <guid>https://dev.to/saqrelfirgany/offline-first-flutter-how-we-built-a-crm-that-manages-100k-leads-with-no-internet-81g</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcp8075nhij4celrfr649.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcp8075nhij4celrfr649.png" alt=" "&gt;&lt;/a&gt;Most apps quietly assume the network is always there. Then a real user walks into a basement, a half-built apartment tower, or an elevator — and the app falls apart.&lt;/p&gt;

&lt;p&gt;For a real-estate sales agent, that moment isn't a glitch. It's a lost lead, and a lost commission.&lt;/p&gt;

&lt;p&gt;When we built the &lt;strong&gt;AM Live CRM&lt;/strong&gt; at Aqarmap (Egypt's largest property platform), our field agents were managing &lt;strong&gt;100,000+ leads a month&lt;/strong&gt; — and a huge chunk of their day happened exactly in those dead zones: new developments, underground parking, remote plots with one bar of signal.&lt;/p&gt;

&lt;p&gt;A "cache the last response" approach wasn't enough. We needed the app to be fully usable with &lt;strong&gt;zero connectivity&lt;/strong&gt; — create a lead, move it through the pipeline, log a call — and have all of it sync cleanly the moment the network came back.&lt;/p&gt;

&lt;p&gt;Here's the architecture we landed on, and the mistakes worth avoiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: the local database is the source of truth
&lt;/h2&gt;

&lt;p&gt;The biggest mental shift in offline-first is this: &lt;strong&gt;the UI never talks to the network directly.&lt;/strong&gt; It talks to the local database. The network is just a background process that keeps the local store and the server eventually consistent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UI / BLoC  -&amp;gt;  Repository  -&amp;gt;  Local DB (SQLite)  &amp;lt;-&amp;gt;  Sync engine  &amp;lt;-&amp;gt;  API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every read comes from SQLite. Every write goes to SQLite first, then gets queued for the server. The user never waits on a request, and never sees a spinner that depends on signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pillar 1 — Write locally, queue the intent
&lt;/h2&gt;

&lt;p&gt;When an agent edits a lead, we do two things in one transaction: update the local row, and record the &lt;em&gt;intent&lt;/em&gt; to sync it.&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;SyncOperation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// uuid&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// 'lead', 'call_log', ...&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;entityId&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;OpType&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// create | update | delete&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;Map&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;,&lt;/span&gt; &lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;localVersion&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// bumped on every local edit&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;createdAt&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;SyncOperation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&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;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;updateLead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Lead&lt;/span&gt; &lt;span class="n"&gt;lead&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;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;txn&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;txn&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="s"&gt;'leads'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toMap&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nl"&gt;where:&lt;/span&gt; &lt;span class="s"&gt;'id = ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;whereArgs:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lead&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;txn&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="s"&gt;'sync_queue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;SyncOperation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;id:&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="nl"&gt;entity:&lt;/span&gt; &lt;span class="s"&gt;'lead'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;entityId:&lt;/span&gt; &lt;span class="n"&gt;lead&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="nl"&gt;type:&lt;/span&gt; &lt;span class="n"&gt;OpType&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="nl"&gt;payload:&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toMap&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
          &lt;span class="nl"&gt;localVersion:&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;version&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="nl"&gt;createdAt:&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="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toMap&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;Because the row and the queue entry are written in the &lt;strong&gt;same transaction&lt;/strong&gt;, you can never end up in a state where the UI shows a change that will never be synced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pillar 2 — Drain the queue when connectivity returns
&lt;/h2&gt;

&lt;p&gt;A single listener watches connectivity and kicks off a drain. The drain processes operations &lt;strong&gt;in order&lt;/strong&gt;, one entity at a time, and only removes an operation from the queue after the server confirms it.&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;connectivity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onStatusChange&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;status&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;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isOnline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;listen&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="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_syncEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;drain&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="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;drain&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;_isSyncing&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 run two drains at once&lt;/span&gt;
  &lt;span class="n"&gt;_isSyncing&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;ops&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;_queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;limit:&lt;/span&gt; &lt;span class="mi"&gt;50&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;final&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ops&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;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;_push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&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;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isConflict&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;_resolveConflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;,&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;serverState&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;_queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&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="c1"&gt;// only after success&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_isSyncing&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 details that saved us a lot of pain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A guard flag&lt;/strong&gt; (&lt;code&gt;_isSyncing&lt;/code&gt;) so a flaky connection toggling on/off doesn't spawn overlapping syncs that duplicate writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove-after-confirm.&lt;/strong&gt; If the app dies mid-sync, the operation is still in the queue and simply replays next time. Idempotent server endpoints (keyed by the operation &lt;code&gt;id&lt;/code&gt;) make replays safe.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pillar 3 — Resolve conflicts on purpose, not by accident
&lt;/h2&gt;

&lt;p&gt;The dangerous case: an agent edits a lead offline while a colleague edits the same lead on the server. If you blindly push, you silently overwrite their work.&lt;/p&gt;

&lt;p&gt;We versioned every record. When the server reports a newer version than the one our operation was based on, we don't guess — we run an explicit strategy:&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;_resolveConflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SyncOperation&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Lead&lt;/span&gt; &lt;span class="n"&gt;serverState&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="c1"&gt;// Field-level merge: keep the server's pipeline stage (authoritative,&lt;/span&gt;
  &lt;span class="c1"&gt;// it drives reporting), keep our locally-edited contact notes.&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serverState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;notes:&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'notes'&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;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="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;leadRepo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsertLocal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merged&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;_queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enqueueUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// push the merged result back&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For some fields last-write-wins is fine. For others (like the 5-stage pipeline that feeds management reporting) the server stays authoritative. The point is that the rule is &lt;strong&gt;a decision you document&lt;/strong&gt;, not an emergent behavior you discover in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this bought us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Agents stopped losing leads to "no internet." The pipeline kept moving online or off.&lt;/li&gt;
&lt;li&gt;The UI got &lt;em&gt;faster&lt;/em&gt;, because reads never blocked on the network.&lt;/li&gt;
&lt;li&gt;Sync failures became boring: they just retried.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons I'd pass on
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decide offline-first on day one.&lt;/strong&gt; Retrofitting it onto a network-coupled app is a rewrite, not a feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the server idempotent&lt;/strong&gt; before you trust replays. Operation IDs are your friend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on real cellular, not office WiFi.&lt;/strong&gt; The demo that works at your desk is not the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the conflict rules down.&lt;/strong&gt; Future-you will not remember why stage changes behave differently from notes.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;I'm Ahmed (Saqr), a senior Flutter engineer — 22+ production apps, 200K+ users. I write about building mobile apps that actually ship and scale.&lt;/p&gt;

&lt;p&gt;If this was useful, follow me here and on &lt;a href="https://github.com/saqrelfirgany" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, where I maintain an open-source &lt;strong&gt;Flutter Enterprise Template&lt;/strong&gt; used by 100+ developers.&lt;/p&gt;

&lt;p&gt;What's your approach to offline sync in Flutter? I'd love to hear it in the comments.&lt;/p&gt;

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