<?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: flutter</title>
    <description>The latest articles tagged 'flutter' on DEV Community.</description>
    <link>https://dev.to/t/flutter</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tag/flutter"/>
    <language>en</language>
    <item>
      <title>Offline-First Flutter Architecture</title>
      <dc:creator>ROCI</dc:creator>
      <pubDate>Sun, 06 Sep 2026 13:03:25 +0000</pubDate>
      <link>https://dev.to/rocisapps/offline-first-flutter-architecture-558l</link>
      <guid>https://dev.to/rocisapps/offline-first-flutter-architecture-558l</guid>
      <description>&lt;p&gt;Deep dive into offline caching with Hive and SQLite.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>android</category>
    </item>
    <item>
      <title>What It Actually Takes to Run a Cross-Border Marketplace: Six Years of Shpper</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:16:06 +0000</pubDate>
      <link>https://dev.to/devshakib/what-it-actually-takes-to-run-a-cross-border-marketplace-six-years-of-shpper-1a8h</link>
      <guid>https://dev.to/devshakib/what-it-actually-takes-to-run-a-cross-border-marketplace-six-years-of-shpper-1a8h</guid>
      <description>&lt;p&gt;Shpper is a cross-border personal-shopping marketplace. A buyer wants something&lt;br&gt;
they cannot get where they live. A traveller is already flying that route with&lt;br&gt;
unused luggage space. The platform introduces them, holds the money until the&lt;br&gt;
item arrives, and turns an empty few kilos in someone's suitcase into a delivery&lt;br&gt;
network.&lt;/p&gt;

&lt;p&gt;I am the CTO. I own the platform end to end — the Flutter apps for both sides,&lt;br&gt;
the backend, payments and escrow, identity verification, and the release&lt;br&gt;
pipeline. We are on version 14.2.0, rated 4.64 on the App Store from 72 ratings.&lt;/p&gt;

&lt;p&gt;Fourteen major versions is enough distance to say something useful about what&lt;br&gt;
this kind of product actually demands. Almost none of the hard parts were the&lt;br&gt;
ones I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  You are not building one app
&lt;/h2&gt;

&lt;p&gt;The first structural fact about a two-sided marketplace is that "the app" is two&lt;br&gt;
products with opposed interests, and you ship both.&lt;/p&gt;

&lt;p&gt;The buyer wants their item cheaply, quickly, and with certainty it will arrive.&lt;br&gt;
The traveller wants to be paid well, carry as little risk as possible, and not be&lt;br&gt;
blamed for customs. Those are not complementary desires. Every feature has to be&lt;br&gt;
designed twice, from two points of view, and any change to the shared middle —&lt;br&gt;
the request, the offer, the trip — reaches into both.&lt;/p&gt;

&lt;p&gt;This has a consequence people underestimate: &lt;strong&gt;your release cadence is bounded by&lt;br&gt;
the slower side.&lt;/strong&gt; A change to how offers work is not shipped when the buyer app&lt;br&gt;
ships. It is shipped when both apps are live, on both platforms, and enough of&lt;br&gt;
both populations have updated. Mobile app review is not a build step you can&lt;br&gt;
optimise away; it is a scheduling constraint that shapes how you design changes,&lt;br&gt;
which is why so much of the logic has to be able to change without a release.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard problem is trust, and it is not a feature
&lt;/h2&gt;

&lt;p&gt;A marketplace's actual product is trust between strangers. Everything else is&lt;br&gt;
plumbing.&lt;/p&gt;

&lt;p&gt;Consider what the platform is asking. A buyer sends money for an item that does&lt;br&gt;
not exist yet, to be bought by someone they have never met, in a country they&lt;br&gt;
are not in, and carried across a border. A traveller agrees to spend their own&lt;br&gt;
money on someone else's goods, in exchange for a promise of repayment, and to&lt;br&gt;
carry those goods through customs under their own name.&lt;/p&gt;

&lt;p&gt;Neither side would do that for a stranger. They do it because the platform&lt;br&gt;
stands between them, and everything about the engineering follows from that&lt;br&gt;
position:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escrow is not a payments feature, it is the entire trust mechanism.&lt;/strong&gt; Money is&lt;br&gt;
held from the moment a deal is struck until the item is confirmed delivered. That&lt;br&gt;
single design decision is what makes the rest possible — the buyer risks nothing&lt;br&gt;
until they have the item, and the traveller knows the money genuinely exists&lt;br&gt;
before they spend their own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity verification is what makes escrow meaningful.&lt;/strong&gt; Held funds only&lt;br&gt;
protect you if there is a real, identified person on the other end when&lt;br&gt;
something goes wrong. Verification is not a compliance checkbox bolted on for&lt;br&gt;
regulators; it is the thing that makes the escrow promise enforceable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The state machine is the product.&lt;/strong&gt; A request becomes an offer, becomes an&lt;br&gt;
accepted deal, becomes a purchase, becomes a carried item, becomes a delivery,&lt;br&gt;
becomes a release of funds. Every transition can fail, and each failure needs a&lt;br&gt;
defined answer: what happens to the money, what each side sees, who is told what&lt;br&gt;
and when. The unhappy paths outnumber the happy one, and they are where the&lt;br&gt;
product either holds together or falls apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money makes everything irreversible
&lt;/h2&gt;

&lt;p&gt;Ordinary app bugs are annoying. Bugs that touch money are a different category,&lt;br&gt;
because you usually cannot fix them by shipping a patch.&lt;/p&gt;

&lt;p&gt;A double-charge is not resolved by correcting the code — the money has already&lt;br&gt;
moved, and now there is a human on the other end who is angry and correct to be.&lt;br&gt;
A payout that fires twice is gone. A state transition that releases escrow early&lt;br&gt;
cannot be un-released by a hotfix.&lt;/p&gt;

&lt;p&gt;This changes how you write things. Every money-moving operation has to be&lt;br&gt;
idempotent, because networks retry and users tap twice and both of those are&lt;br&gt;
normal. State transitions have to be atomic, because a partial write in the&lt;br&gt;
middle of a payment is worse than a failure. And ledgers have to be&lt;br&gt;
append-only — the record of what happened is not something you edit, because the&lt;br&gt;
moment it is editable it stops being evidence.&lt;/p&gt;

&lt;p&gt;None of this is exotic. All of it is the difference between a bug you fix and a&lt;br&gt;
bug you refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-border is a category of its own
&lt;/h2&gt;

&lt;p&gt;Everything above applies to any escrow marketplace. Crossing a border adds&lt;br&gt;
problems that are not software problems at all, and the engineering has to&lt;br&gt;
absorb them anyway.&lt;/p&gt;

&lt;p&gt;Prices move while a deal is open, because currencies move. Customs and duty are&lt;br&gt;
real costs that land on somebody, and the product has to be unambiguous about who&lt;br&gt;
before anyone commits, not after. Items that are perfectly ordinary in one&lt;br&gt;
country are restricted in another. Trips get cancelled, delayed, or rerouted, and&lt;br&gt;
a delivery network built from other people's travel plans inherits all the&lt;br&gt;
volatility of those plans.&lt;/p&gt;

&lt;p&gt;The engineering lesson is that a design which assumes stability will spend the&lt;br&gt;
rest of its life being patched. Timelines slip, prices change, routes vanish.&lt;br&gt;
Building for that from the start is much cheaper than discovering it across&lt;br&gt;
fourteen versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What being CTO of this actually means
&lt;/h2&gt;

&lt;p&gt;The title suggests architecture diagrams. The reality is that owning it end to&lt;br&gt;
end means the boundary between "engineering problem" and "business problem" does&lt;br&gt;
not exist.&lt;/p&gt;

&lt;p&gt;When a delivery goes wrong, the question is not only what the code did. It is&lt;br&gt;
what the policy should be, who absorbs the cost, what both users are told, and&lt;br&gt;
what changes so it happens less often. That is one conversation, not four, and&lt;br&gt;
being the person who understands both the state machine and the commercial&lt;br&gt;
consequence is most of the job.&lt;/p&gt;

&lt;p&gt;The other half is choosing what not to build. Every marketplace has an infinite&lt;br&gt;
backlog because both sides always want more, and the sides want different things.&lt;br&gt;
Fourteen versions in, the decisions I am most glad about are almost all&lt;br&gt;
subtractions.&lt;/p&gt;

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

&lt;p&gt;Flutter for both apps, so one codebase covers iOS and Android on each side —&lt;br&gt;
which matters enormously when "shipping a feature" already means shipping two&lt;br&gt;
products. Firebase for the backend. The choice that has aged best is not a&lt;br&gt;
technology but a principle: &lt;strong&gt;keep as much behaviour as possible changeable&lt;br&gt;
without an app release&lt;/strong&gt;, because you cannot hotfix your way out of a marketplace&lt;br&gt;
problem when app review sits between you and your users.&lt;/p&gt;




&lt;p&gt;The thing I would tell someone starting one of these: you are not building&lt;br&gt;
software that connects buyers and sellers. You are building the reason two&lt;br&gt;
strangers are willing to take a risk on each other, and every technical decision&lt;br&gt;
either strengthens that or quietly erodes it. Escrow, verification, and an&lt;br&gt;
honest state machine are not features on a roadmap. They are the product, and&lt;br&gt;
everything else is how it is delivered.&lt;/p&gt;

&lt;p&gt;Shpper is at &lt;a href="https://www.shpper.com" rel="noopener noreferrer"&gt;shpper.com&lt;/a&gt;, and there is a longer&lt;br&gt;
&lt;a href="https://devshakib.jumyn.com/work/shpper" rel="noopener noreferrer"&gt;case study&lt;/a&gt; on this site.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/what-it-takes-to-run-a-cross-border-marketplace" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>firebase</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your Flutter 404 Page Is Probably Crashing, and Your Server Is Probably Lying About It</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:16:05 +0000</pubDate>
      <link>https://dev.to/devshakib/your-flutter-404-page-is-probably-crashing-and-your-server-is-probably-lying-about-it-k43</link>
      <guid>https://dev.to/devshakib/your-flutter-404-page-is-probably-crashing-and-your-server-is-probably-lying-about-it-k43</guid>
      <description>&lt;p&gt;Someone sent me a screenshot of my own 404 page. Washed-out grey text on a light&lt;br&gt;
background, barely readable, nothing like the dark theme every other page uses.&lt;br&gt;
My first thought was a styling bug.&lt;/p&gt;

&lt;p&gt;It was not a styling bug. The page was crashing before it could paint, and the&lt;br&gt;
fallback I had built for exactly that case was doing its job. Underneath it was a&lt;br&gt;
second bug that had been hiding the first one for months.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug one: the status code was a lie
&lt;/h2&gt;

&lt;p&gt;The site is a Flutter web app with every route prerendered to a real HTML file.&lt;br&gt;
Firebase Hosting served those files, and anything unmatched hit a catch-all&lt;br&gt;
rewrite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"rewrites"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/404.html"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks right. It is not, and the reason is worth internalising: &lt;strong&gt;a Firebase&lt;br&gt;
rewrite always responds 200.&lt;/strong&gt; That is what a rewrite &lt;em&gt;is&lt;/em&gt; — serve this other&lt;br&gt;
content under the requested URL. So every nonexistent URL on the site returned&lt;br&gt;
&lt;code&gt;200 OK&lt;/code&gt; with a page that said "Page not found".&lt;/p&gt;

&lt;p&gt;Browsers do not care. Crawlers care a great deal. A 200 means "this is a real&lt;br&gt;
page, index it", so every typo, every dead inbound link, every scanner probing&lt;br&gt;
for &lt;code&gt;/wp-admin&lt;/code&gt; was eligible to be indexed as a real page. This is the soft 404,&lt;br&gt;
and it is invisible from a browser because the page &lt;em&gt;looks&lt;/em&gt; correct.&lt;/p&gt;

&lt;p&gt;The fix is to delete the catch-all rather than repoint it. With every real route&lt;br&gt;
prerendered as a file, Firebase serves those directly, and for anything with no&lt;br&gt;
matching file it falls through to its own handling — which serves &lt;code&gt;404.html&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;with an actual 404 status&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One caveat that will bite you if your app has client-only routes. My admin panel&lt;br&gt;
has no prerendered file, so removing the catch-all 404'd my own admin URL. It&lt;br&gt;
needs a rewrite scoped to that path alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"rewrites"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/your-client-only-path/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/index.html"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scoped rewrites for the routes that genuinely need them; no catch-all; real 404s&lt;br&gt;
for everything else.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug two: the page was crashing the whole time
&lt;/h2&gt;

&lt;p&gt;Fixing the status code is what made me actually look at the page, and that is&lt;br&gt;
when the grey rendering stopped looking like a theme problem.&lt;/p&gt;

&lt;p&gt;My prerendered HTML carries the page's text in the DOM, clipped to a single&lt;br&gt;
pixel, with a boot guard that reveals it if Flutter never paints. The idea is&lt;br&gt;
that a failed boot shows the content rather than a spinner turning forever. So&lt;br&gt;
the washed-out text was the guard firing correctly, telling me Flutter had&lt;br&gt;
died.&lt;/p&gt;

&lt;p&gt;The console had it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GoError: There is no GoRouterState above the current context.
This method should only be called under the sub tree of a RouteBase.builder.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the chain. &lt;code&gt;errorBuilder&lt;/code&gt; renders my &lt;code&gt;NotFoundPage&lt;/code&gt;. That page uses the&lt;br&gt;
same shell as every other page, and the shell contains the nav bar, and the nav&lt;br&gt;
bar asks which route is current so it can highlight the right link:&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GoRouterState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On every real route that is fine. On the 404 page it throws, because&lt;br&gt;
&lt;strong&gt;&lt;code&gt;errorBuilder&lt;/code&gt; renders outside any &lt;code&gt;RouteBase.builder&lt;/code&gt;&lt;/strong&gt; — there is no route&lt;br&gt;
subtree above it and therefore no &lt;code&gt;GoRouterState&lt;/code&gt; to inherit.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix that does not exist, and the one that also fails
&lt;/h2&gt;

&lt;p&gt;The instinct is &lt;code&gt;maybeOf&lt;/code&gt;. In go_router 17 &lt;strong&gt;there is no &lt;code&gt;GoRouterState.maybeOf&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;of()&lt;/code&gt; throws unconditionally; there is no nullable variant to fall back to.&lt;/p&gt;

&lt;p&gt;So the next instinct is to ask the router itself, which definitely sits above&lt;br&gt;
everything:&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GoRouter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// also throws&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried exactly this, and it fails too — differently, which is what makes it&lt;br&gt;
interesting. &lt;code&gt;GoRouter.state&lt;/code&gt; reads &lt;code&gt;matches.last&lt;/code&gt; on the current match list, and&lt;br&gt;
on an unmatched URL &lt;strong&gt;that list is empty&lt;/strong&gt;, so you get a &lt;code&gt;StateError: Bad state:&lt;br&gt;
No element&lt;/code&gt;. The accessor fails for precisely the reason you are on the 404 page&lt;br&gt;
at all.&lt;/p&gt;

&lt;p&gt;Both router-side answers are dead ends. But the browser knows the path regardless&lt;br&gt;
of what the router made of 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="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;current&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GoRouterState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&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;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&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 broad catch is deliberate, and I would defend it specifically here.&lt;br&gt;
Determining which nav item to highlight is decoration. This widget renders inside&lt;br&gt;
every page on the site. Failing at decoration must never be able to take down the&lt;br&gt;
page around it, and the two failure modes above are different exception types&lt;br&gt;
from different call sites — narrowing the catch buys nothing except a chance to&lt;br&gt;
miss the third one.&lt;/p&gt;

&lt;p&gt;Nothing highlights on a 404, which is correct: no nav item corresponds to a page&lt;br&gt;
that does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it survived so long
&lt;/h2&gt;

&lt;p&gt;Because the two bugs concealed each other. The 404 page had been broken for&lt;br&gt;
months and rendered identically whether it answered 200 or 404 — so nothing&lt;br&gt;
looked wrong from a browser, and nothing in the logs distinguished it. Crawlers&lt;br&gt;
saw 200 and were happy. I never visited my own 404 page, because why would I.&lt;/p&gt;

&lt;p&gt;The status-code fix did not cause the crash. It made someone look at the page,&lt;br&gt;
which is a different and more useful thing.&lt;/p&gt;

&lt;p&gt;Two habits fall out of this. &lt;strong&gt;Check your 404 page's status code, not just its&lt;br&gt;
appearance&lt;/strong&gt; — &lt;code&gt;curl -o /dev/null -w '%{http_code}' https://yoursite/nonsense&lt;/code&gt;&lt;br&gt;
takes two seconds and is the only way to see a soft 404. And &lt;strong&gt;actually load your&lt;br&gt;
error page in a browser after a routing change&lt;/strong&gt;, because it is the one page in&lt;br&gt;
your app that renders through a completely different code path from every other&lt;br&gt;
page, and therefore the one page your testing never touches.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/your-flutter-404-page-is-probably-crashing" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>seo</category>
      <category>dart</category>
      <category>mobile</category>
    </item>
    <item>
      <title>A Squarified Treemap by Hand, Because Charting Packages Cannot Drill Down</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sun, 06 Sep 2026 12:15:09 +0000</pubDate>
      <link>https://dev.to/devshakib/a-squarified-treemap-by-hand-because-charting-packages-cannot-drill-down-353g</link>
      <guid>https://dev.to/devshakib/a-squarified-treemap-by-hand-because-charting-packages-cannot-drill-down-353g</guid>
      <description>&lt;p&gt;Helm's storage tool shows your disk as a treemap: every folder a rectangle,&lt;br&gt;
every rectangle sized by how much space it takes. Click one and you are inside&lt;br&gt;
it, looking at its children, with a breadcrumb trail back out. It goes from a&lt;br&gt;
494 GB volume down to a single file.&lt;/p&gt;

&lt;p&gt;I drew it by hand. Not because the packages are bad, but because the thing that&lt;br&gt;
makes a treemap useful for disk space is the part they do not expose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the packages give you, and what they don't
&lt;/h2&gt;

&lt;p&gt;Search for a Flutter treemap and you will find several that work. Hand them a&lt;br&gt;
list of values, get back a coloured rectangle layout. For a dashboard showing&lt;br&gt;
revenue by region, that is the whole job.&lt;/p&gt;

&lt;p&gt;A disk browser needs four things beyond that, and each one reaches into the&lt;br&gt;
layout rather than sitting on top of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hit-testing that returns the node, not a coordinate.&lt;/strong&gt; A tap has to resolve
to &lt;em&gt;which folder&lt;/em&gt;, at whatever depth you are currently at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Navigation as a first-class state.&lt;/strong&gt; Diving into a rectangle re-lays out the
entire canvas from a new root, and the breadcrumb has to be able to climb back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Labels that adapt.&lt;/strong&gt; A rectangle 400 px wide gets a name and a size. One
20 px wide gets nothing, because a clipped half-word is worse than blank space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layout over a subtree, not a list.&lt;/strong&gt; The input is not fifty values, it is a
tree with a million nodes, and you lay out one level at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any one of those is a fork of the package. All four means you are writing it&lt;br&gt;
anyway, and you would rather own it than fight an abstraction built for a&lt;br&gt;
different problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Squarified, and why it is worth the trouble
&lt;/h2&gt;

&lt;p&gt;The naive treemap slices the rectangle repeatedly along one axis. It is easy and&lt;br&gt;
it produces slivers — long thin shapes that are impossible to read, impossible&lt;br&gt;
to tap, and misleading, because human eyes judge area badly when the aspect&lt;br&gt;
ratio is extreme.&lt;/p&gt;

&lt;p&gt;The squarified algorithm keeps rectangles as close to square as it can. The idea&lt;br&gt;
is simple enough to hold in your head: sort children largest first, then add&lt;br&gt;
them one at a time to the current row while doing so &lt;em&gt;improves&lt;/em&gt; the worst aspect&lt;br&gt;
ratio in that row. The moment adding one makes the worst ratio worse, close the&lt;br&gt;
row, and start a new one in the remaining space.&lt;/p&gt;

&lt;p&gt;That is the entire algorithm. Sort descending, accumulate greedily, close the&lt;br&gt;
row when the worst ratio stops improving, recurse into the space that is left.&lt;/p&gt;

&lt;p&gt;Two implementation notes that cost me time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sort descending or the greedy step is meaningless.&lt;/strong&gt; The whole method assumes&lt;br&gt;
you place the biggest item first. Feed it unsorted input and it still produces&lt;br&gt;
a layout — a bad one, with no error to tell you why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guard against zero.&lt;/strong&gt; Empty folders, and the remaining space after the last&lt;br&gt;
row, both produce zero-width or zero-height rectangles, which turn into&lt;br&gt;
divide-by-zero in the ratio computation and &lt;code&gt;NaN&lt;/code&gt; in the layout. &lt;code&gt;NaN&lt;/code&gt;&lt;br&gt;
propagates silently through a layout pass and you get an empty canvas with no&lt;br&gt;
exception, which is a genuinely unpleasant thing to debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it fast enough over a real disk
&lt;/h2&gt;

&lt;p&gt;Scanning a 347 GB volume walks several hundred thousand files. Two things keep&lt;br&gt;
that from freezing the UI, and neither is about the treemap itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scan off the main isolate.&lt;/strong&gt; Walking a filesystem is not CPU-heavy so much as&lt;br&gt;
relentless — hundreds of thousands of stat calls. Done on the main isolate it&lt;br&gt;
blocks the frame loop, and the app appears hung exactly while it is doing the&lt;br&gt;
work the user asked for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lay out one level at a time.&lt;/strong&gt; The tree has a million nodes; the screen shows&lt;br&gt;
a few dozen rectangles. There is no reason to lay out anything but the current&lt;br&gt;
level's children. Diving in is a fresh layout over a smaller subtree, which is&lt;br&gt;
why it stays instant no matter how deep you go — the work is bounded by what is&lt;br&gt;
visible, not by what exists.&lt;/p&gt;

&lt;p&gt;The second point is the one that also solves the label problem. Because you only&lt;br&gt;
lay out the visible level, you know each rectangle's real pixel size at paint&lt;br&gt;
time, so deciding whether a name fits is a measurement rather than a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that has nothing to do with drawing
&lt;/h2&gt;

&lt;p&gt;The hardest part of Helm's storage tool is not the treemap. It is making the&lt;br&gt;
numbers agree with the operating system.&lt;/p&gt;

&lt;p&gt;macOS reports &lt;strong&gt;purgeable&lt;/strong&gt; space — snapshots and caches the system will reclaim&lt;br&gt;
when it needs to. If you sum file sizes and ignore purgeable, your total&lt;br&gt;
disagrees with About This Mac, and when a user sees two numbers they believe the&lt;br&gt;
one from Apple. Correctly, too: yours is the one that is wrong.&lt;/p&gt;

&lt;p&gt;So the categories have to be disjoint buckets that reconcile to the volume's&lt;br&gt;
reported capacity, purgeable included as its own slice rather than quietly&lt;br&gt;
dropped or quietly counted as used. A beautiful visualisation of numbers the&lt;br&gt;
user does not trust is worth nothing, and trust here is a single comparison&lt;br&gt;
against a system dialog they already know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth writing?
&lt;/h2&gt;

&lt;p&gt;For a dashboard, no — use a package. For anything where the treemap is the&lt;br&gt;
interface rather than a picture of the data, you will end up writing it, because&lt;br&gt;
drill-down, hit-testing and adaptive labels all live below the API surface that&lt;br&gt;
a general charting package exposes.&lt;/p&gt;

&lt;p&gt;The reward is that it is genuinely the fastest way to answer "what is eating my&lt;br&gt;
disk". A list of the largest folders makes you read and compare. A treemap makes&lt;br&gt;
the answer the biggest thing on screen.&lt;/p&gt;

&lt;p&gt;Helm is free and MIT, and the treemap is in&lt;br&gt;
&lt;code&gt;lib/tools/storage/ui/widgets/treemap.dart&lt;/code&gt; if you want to read it rather than&lt;br&gt;
reimplement it: &lt;a href="https://github.com/devShakib015/helm" rel="noopener noreferrer"&gt;github.com/devShakib015/helm&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/a-squarified-treemap-by-hand-in-flutter" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>macos</category>
      <category>mobile</category>
    </item>
    <item>
      <title>المنصة اللي جاية بعد الموبايل مش خيال علمي</title>
      <dc:creator>Ahmed ElFirgany</dc:creator>
      <pubDate>Sun, 06 Sep 2026 09:54:12 +0000</pubDate>
      <link>https://dev.to/saqrelfirgany/lmns-lly-jy-bd-lmwbyl-msh-khyl-lmy-26lc</link>
      <guid>https://dev.to/saqrelfirgany/lmns-lly-jy-bd-lmwbyl-msh-khyl-lmy-26lc</guid>
      <description>&lt;p&gt;المنصة اللي جاية بعد الموبايل مش خيال علمي — بتتبنى قدام عينينا دلوقتي.&lt;/p&gt;

&lt;p&gt;جوجل هتطلق أول نظارة AI ليها في ٢٠٢٦، سامسونج طلعت Galaxy AI Glasses بالفعل، وRayNeo عرضت جيل جديد بالتعاون مع Dolby وBang &amp;amp; Olufsen. تلات شركات عالمية بتتسابق في نفس السنة بالظبط — ده مش تجربة، ده بداية سباق منصة حقيقية.&lt;/p&gt;

&lt;p&gt;قضيت ٥ سنين أبني للموبايل. ولسه معتبرش نفسي خلصت، بس المنصة اللي بنيت عليها شغلي عمره كله بقى ليها منافس واقف في الصف جنبها.&lt;/p&gt;

&lt;p&gt;السؤال مش هل النظارات هتاخد مكان الموبايل. السؤال: مين من المطورين هيبقى جاهز لما اللحظة دي تيجي؟&lt;/p&gt;

&lt;p&gt;إنت متابع المنصة الجاية دي، ولا لسه مركّز بس في اللي إنت فيه؟&lt;/p&gt;

&lt;h1&gt;
  
  
  الذكاء&lt;em&gt;الاصطناعي #نظارات&lt;/em&gt;ذكية #هندسة_الموبايل #Flutter
&lt;/h1&gt;

</description>
      <category>ai</category>
      <category>flutter</category>
      <category>mobile</category>
      <category>discuss</category>
    </item>
    <item>
      <title>David Stark: Top High-Paying Roles</title>
      <dc:creator>David Stark</dc:creator>
      <pubDate>Sun, 06 Sep 2026 02:24:46 +0000</pubDate>
      <link>https://dev.to/devpulse/david-stark-top-high-paying-roles-1dgn</link>
      <guid>https://dev.to/devpulse/david-stark-top-high-paying-roles-1dgn</guid>
      <description>&lt;h3&gt;
  
  
  👋 Hello Architects &amp;amp; Elite Engineers,
&lt;/h3&gt;

&lt;p&gt;The market is shifting. We are seeing a surge in &lt;strong&gt;MOBILE&lt;/strong&gt; roles this week.&lt;/p&gt;

&lt;p&gt;We don't do "Easy Apply". Our internal gatekeeper just processed 200+ verified remote jobs from our partner network. To get these jobs, you must pass the architecture audit. &lt;/p&gt;

&lt;p&gt;Here are the Top 5 roles worth your time today.** 👇&lt;/p&gt;




&lt;h3&gt;
  
  
  1. &lt;a href="https://www.jobsniper.pro?slug=staff-software-engineer-samsara-e110da9a8e46" rel="noopener noreferrer"&gt;Staff Software Engineer&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;🏢 Samsara&lt;/strong&gt; | 💰 &lt;strong&gt;Competitive&lt;/strong&gt; | 📍 Remote&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Could you walk us through your experience with this tech stack?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tech Stack:&lt;/strong&gt; &lt;p&gt; &lt;strong&gt;.samsara.com/ &lt;/strong&gt;&lt;/p&gt;
&lt;strong&gt; &lt;p&gt;&lt;strong&gt;About the role:&lt;/strong&gt;&lt;/p&gt; &lt;p&gt;Samsara (NYSE: IOT) sits a...&lt;/p&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;strong&gt;
&lt;li&gt;&lt;strong&gt;👉 &lt;a href="https://www.jobsniper.pro?slug=staff-software-engineer-samsara-e110da9a8e46" rel="noopener noreferrer"&gt;Apply &amp;amp; View Full Salary&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/strong&gt;
&lt;/ul&gt;
&lt;strong&gt;


&lt;h3&gt;
  
  
  2. &lt;a href="https://www.jobsniper.pro?slug=senior-quality-engineer-montevideo-lawnstarter-deaa1ef484fe" rel="noopener noreferrer"&gt;Senior Quality Engineer (Montevideo)&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;🏢 LawnStarter&lt;/strong&gt; | 💰 &lt;strong&gt;Competitive&lt;/strong&gt; | 📍 Remote&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Could you walk us through your experience with this tech stack?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tech Stack:&lt;/strong&gt; &lt;p&gt;&lt;strong&gt;This is a remote role for candidates located in Montevideo, Uruguay.&amp;nbsp;&lt;/strong&gt;&amp;lt;...&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;👉 &lt;a href="https://www.jobsniper.pro?slug=senior-quality-engineer-montevideo-lawnstarter-deaa1ef484fe" rel="noopener noreferrer"&gt;Apply &amp;amp; View Full Salary&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  3. &lt;a href="https://www.jobsniper.pro?slug=senior-quality-engineer-s-o-paulo-lawnstarter-05dff025d48c" rel="noopener noreferrer"&gt;Senior Quality Engineer (São Paulo)&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;🏢 LawnStarter&lt;/strong&gt; | 💰 &lt;strong&gt;Competitive&lt;/strong&gt; | 📍 Remote&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Could you walk us through your experience with this tech stack?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tech Stack:&lt;/strong&gt; &lt;p&gt;&lt;strong&gt;This is a remote role for candidates located in São Paulo, Brazil. &lt;/strong&gt;&amp;lt;/p...&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;👉 &lt;a href="https://www.jobsniper.pro?slug=senior-quality-engineer-s-o-paulo-lawnstarter-05dff025d48c" rel="noopener noreferrer"&gt;Apply &amp;amp; View Full Salary&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  4. &lt;a href="https://www.jobsniper.pro?slug=senior-quality-engineer-mexico-city-lawnstarter-8cc6257d32e0" rel="noopener noreferrer"&gt;Senior Quality Engineer (Mexico City)&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;🏢 LawnStarter&lt;/strong&gt; | 💰 &lt;strong&gt;Competitive&lt;/strong&gt; | 📍 Remote&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Could you walk us through your experience with this tech stack?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tech Stack:&lt;/strong&gt; &lt;p&gt;&lt;strong&gt;This is a remote role for candidates located in Mexico City, Mexico.&lt;/strong&gt;&lt;/p&gt; &amp;lt;p...&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;👉 &lt;a href="https://www.jobsniper.pro?slug=senior-quality-engineer-mexico-city-lawnstarter-8cc6257d32e0" rel="noopener noreferrer"&gt;Apply &amp;amp; View Full Salary&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  5. &lt;a href="https://www.jobsniper.pro?slug=software-engineer-sticker-mule-537847f22033" rel="noopener noreferrer"&gt;Software engineer&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;🏢 Sticker Mule&lt;/strong&gt; | 💰 &lt;strong&gt;Competitive&lt;/strong&gt; | 📍 Remote&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Could you walk us through your experience with this tech stack?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;


&lt;ul&gt;

&lt;li&gt;
&lt;strong&gt;Tech Stack:&lt;/strong&gt; &lt;p&gt; &lt;strong&gt;.stickermule.com &lt;/strong&gt;&lt;/p&gt;
&lt;strong&gt; &lt;p&gt;Sticker Mule is building the Internet's most lucrative commerce...&lt;/p&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;strong&gt;&lt;br&gt;
&lt;li&gt;&lt;strong&gt;👉 &lt;a href="https://www.jobsniper.pro?slug=software-engineer-sticker-mule-537847f22033" rel="noopener noreferrer"&gt;Apply &amp;amp; View Full Salary&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;br&gt;
&lt;/strong&gt;
&lt;/ul&gt;
&lt;strong&gt;




&lt;h3&gt;
  
  
  🚀 Want more?
&lt;/h3&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://www.jobsniper.pro/#view-board" rel="noopener noreferrer"&gt;View the full board of 50+ New Jobs here&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;🛑 Stop doing 7-round HR interviews.&lt;/strong&gt; We hold direct contracts with hiring CTOs for high-ticket roles. Accept the architecture challenge, get your Instant AI Score, and bypass HR completely. 👉 &lt;a href="https://www.jobsniper.pro" rel="noopener noreferrer"&gt;Take the CTO Challenge&lt;/a&gt;&lt;/p&gt;

&lt;/strong&gt;&lt;/strong&gt;

</description>
      <category>mobile</category>
      <category>ios</category>
      <category>android</category>
      <category>flutter</category>
    </item>
    <item>
      <title>Saving a Real File From Flutter Web, Instead of Downloading Another Copy</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sat, 05 Sep 2026 23:32:13 +0000</pubDate>
      <link>https://dev.to/devshakib/saving-a-real-file-from-flutter-web-instead-of-downloading-another-copy-5ah3</link>
      <guid>https://dev.to/devshakib/saving-a-real-file-from-flutter-web-instead-of-downloading-another-copy-5ah3</guid>
      <description>&lt;p&gt;Build an editor on Flutter Web. The user opens &lt;code&gt;budget.csv&lt;/code&gt;, edits it, hits&lt;br&gt;
save. They get &lt;code&gt;budget.csv&lt;/code&gt; in their Downloads folder.&lt;/p&gt;

&lt;p&gt;They edit again, save again: &lt;code&gt;budget (1).csv&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Again: &lt;code&gt;budget (2).csv&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Their real file — the one on their Desktop that they opened — has never been&lt;br&gt;
touched. By the end of an afternoon they have nine numbered copies and no idea&lt;br&gt;
which is current. This is not a Flutter problem; it is what "saving" has meant on&lt;br&gt;
the web for twenty years. The browser hands you a download, and a download is&lt;br&gt;
always a new file.&lt;/p&gt;
&lt;h2&gt;
  
  
  The API that changed this
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;File System Access API&lt;/strong&gt; gives a page a real handle to a real file, with&lt;br&gt;
the user's permission, granted through the browser's own picker. With a handle&lt;br&gt;
you can write back to the same file. Not a copy. That one.&lt;/p&gt;

&lt;p&gt;It is a genuine capability, gated properly: the user picks the file, the browser&lt;br&gt;
mediates every access, and the permission can be revoked. There is no way for a&lt;br&gt;
page to reach a file the user has not deliberately handed over.&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;file&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;FileSystemAccess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;openFile&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;file&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;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the same file, in place&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No download. No &lt;code&gt;(1)&lt;/code&gt;. The file on their Desktop now has their edits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the old way works the way it does
&lt;/h3&gt;

&lt;p&gt;The download-a-copy behaviour is not laziness on anyone's part. For most of the&lt;br&gt;
web's history, a page having write access to your filesystem would have been an&lt;br&gt;
enormous security hole, so the only sanctioned direction was &lt;em&gt;out&lt;/em&gt;: the page&lt;br&gt;
produces bytes, the browser saves them somewhere the page cannot see or reach.&lt;/p&gt;

&lt;p&gt;That is why &lt;code&gt;&amp;lt;a download&amp;gt;&lt;/code&gt; and the blob-URL trick that every Flutter Web file-save&lt;br&gt;
snippet uses cannot ever overwrite. They are not writing a file. They are handing&lt;br&gt;
the browser a payload and asking it to save one, and "save one" means a new one.&lt;/p&gt;

&lt;p&gt;Understanding that is what tells you the File System Access API is a genuinely&lt;br&gt;
different mechanism rather than a nicer wrapper over the same thing. The&lt;br&gt;
permission model changed; the API followed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Coming back after a reload
&lt;/h2&gt;

&lt;p&gt;The second half is what makes it feel like an application rather than a web page.&lt;/p&gt;

&lt;p&gt;Handles can be &lt;strong&gt;persisted&lt;/strong&gt;. Store one, and after a page reload — or the next&lt;br&gt;
morning — you can ask for that same file again. The browser will re-prompt for&lt;br&gt;
permission, which is correct and is the point, but the user is confirming access&lt;br&gt;
to a file they already chose rather than hunting through a picker to find it&lt;br&gt;
again.&lt;/p&gt;

&lt;p&gt;That single behaviour is the difference between "a web tool I paste things into"&lt;br&gt;
and "the thing I edit my file with".&lt;/p&gt;

&lt;p&gt;Handles are stored in IndexedDB — they are structured-cloneable objects, not&lt;br&gt;
strings — so they persist like any other browser data and survive until the user&lt;br&gt;
clears site data. Which means the recovery flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On startup, look for a stored handle.&lt;/li&gt;
&lt;li&gt;If there is one, ask whether permission is still granted.&lt;/li&gt;
&lt;li&gt;If it is, open silently and show the document.&lt;/li&gt;
&lt;li&gt;If it is not, show one button: &lt;em&gt;Reopen budget.csv&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step four is the honest version of "restore my session". You are not&lt;br&gt;
re-prompting for a file the user must find again; you are asking them to confirm&lt;br&gt;
a file you both already know about.&lt;/p&gt;
&lt;h3&gt;
  
  
  Directories, not just files
&lt;/h3&gt;

&lt;p&gt;The same API grants handles to whole directories. That unlocks a different class&lt;br&gt;
of application entirely — a project folder, a photo set, a folder of Markdown&lt;br&gt;
notes — where the app can enumerate, read and write within the folder the user&lt;br&gt;
chose, and nothing outside it.&lt;/p&gt;

&lt;p&gt;For anything resembling an editor, that is the difference between a single-file&lt;br&gt;
toy and something people keep their work in.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you must handle
&lt;/h2&gt;

&lt;p&gt;This is where an honest package earns its keep, because the API is not&lt;br&gt;
universally available and pretending otherwise produces a broken app on a third&lt;br&gt;
of browsers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support is real but partial.&lt;/strong&gt; Chrome, Edge and other Chromium browsers have&lt;br&gt;
it. Safari and Firefox largely do not. So the capability check is not optional&lt;br&gt;
housekeeping — it decides which UI you show:&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FileSystemAccess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isSupported&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "Save" — writes in place&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "Download a copy" — the old behaviour, honestly labelled&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Label the fallback accurately. A button that says &lt;em&gt;Save&lt;/em&gt; and silently produces&lt;br&gt;
&lt;code&gt;budget (3).csv&lt;/code&gt; is worse than a button that says &lt;em&gt;Download a copy&lt;/em&gt;, because the&lt;br&gt;
first one lies about what just happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permission is per-handle and revocable.&lt;/strong&gt; A write can fail because the user&lt;br&gt;
revoked access, or the file moved, or the browser dropped the grant. Handle it as&lt;br&gt;
a normal outcome rather than an exception path you never test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It needs a user gesture.&lt;/strong&gt; The picker cannot be opened from a timer or an&lt;br&gt;
async continuation far from a tap. Open it directly in the button handler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design for the fallback, do not bolt it on
&lt;/h3&gt;

&lt;p&gt;The temptation is to write the good path and add a fallback later. That produces&lt;br&gt;
an app whose Safari experience is an afterthought, and Safari is not a rounding&lt;br&gt;
error.&lt;/p&gt;

&lt;p&gt;A structure that works: define one interface with &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;save&lt;/code&gt; and &lt;code&gt;saveAs&lt;/code&gt;.&lt;br&gt;
Implement it twice — once over the File System Access API, once over the classic&lt;br&gt;
picker-and-download. Choose at startup. The rest of your app never branches, and&lt;br&gt;
the difference is confined to which implementation is behind the interface and&lt;br&gt;
what the save button says.&lt;/p&gt;

&lt;p&gt;The label matters more than it sounds. If the fallback button says &lt;strong&gt;Save&lt;/strong&gt; and&lt;br&gt;
produces &lt;code&gt;budget (3).csv&lt;/code&gt;, the app has lied about what it did. If it says&lt;br&gt;
&lt;strong&gt;Download a copy&lt;/strong&gt;, the user understands the platform limitation immediately and&lt;br&gt;
does not blame the app when their original file is unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more on Flutter Web than elsewhere
&lt;/h2&gt;

&lt;p&gt;Flutter Web is unusually good at the kind of app this unlocks — editors,&lt;br&gt;
spreadsheets, diagram tools, code playgrounds, anything with a document. Those&lt;br&gt;
are exactly the apps where "download another copy" is most obviously wrong.&lt;/p&gt;

&lt;p&gt;It is also the gap people cite when they say Flutter Web "isn't ready for real&lt;br&gt;
apps". Not being able to save a file properly is a fair thing to hold against a&lt;br&gt;
platform. It is no longer true, on the browsers that matter, and it is worth&lt;br&gt;
knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on security, since people ask
&lt;/h2&gt;

&lt;p&gt;Every time this API comes up someone asks whether a web page can now read their&lt;br&gt;
whole disk. It cannot, and the reasons are worth knowing so you can answer the&lt;br&gt;
question when a user asks you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every handle comes from a user gesture&lt;/strong&gt; through the browser's own picker.
A page cannot construct a handle to a path it names.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access is per-handle.&lt;/strong&gt; Being granted &lt;code&gt;budget.csv&lt;/code&gt; grants nothing about the
folder it sits in, or any other file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The browser blocks sensitive locations&lt;/strong&gt; — system directories, and in
Chromium's case a maintained blocklist that includes things like the user's
home root and library folders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permission is revocable&lt;/strong&gt; and is re-confirmed after a reload rather than
granted permanently in the background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It requires a secure context.&lt;/strong&gt; No HTTPS, no API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design is closer to "the user hands your app a file" than to "your app gets&lt;br&gt;
filesystem access", which is the right shape for a capability this significant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://devshakib.jumyn.com/packages/file_system_access" rel="noopener noreferrer"&gt;&lt;code&gt;file_system_access&lt;/code&gt;&lt;/a&gt; is on pub.dev — MIT, 160/160&lt;br&gt;
pub points. It wraps the picker, the in-place write, the persisted handle and the&lt;br&gt;
capability check.&lt;/p&gt;

&lt;p&gt;It returns &lt;code&gt;false&lt;/code&gt; from the capability check on browsers that cannot do this,&lt;br&gt;
rather than throwing or half-working — so your fallback is a decision you make at&lt;br&gt;
startup rather than a crash you discover from a Safari user three weeks after&lt;br&gt;
launch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/flutter-web-save-file-in-place" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
    <item>
      <title>Scrolling to an Index in a Flutter Lazy List, Without Building Everything Above It</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sat, 05 Sep 2026 23:22:11 +0000</pubDate>
      <link>https://dev.to/devshakib/scrolling-to-an-index-in-a-flutter-lazy-list-without-building-everything-above-it-7o9</link>
      <guid>https://dev.to/devshakib/scrolling-to-an-index-in-a-flutter-lazy-list-without-building-everything-above-it-7o9</guid>
      <description>&lt;p&gt;You have a list of a million rows and you want to jump to row 842,013.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ScrollController.jumpTo&lt;/code&gt; takes a &lt;em&gt;pixel&lt;/em&gt; offset, not an index. To convert one&lt;br&gt;
to the other you need the total height of the 842,012 rows above your target —&lt;br&gt;
and a lazy list has never built them, so it does not know how tall they are.&lt;br&gt;
That is not an oversight. It is the entire point of a lazy list.&lt;/p&gt;

&lt;p&gt;Flutter ships no answer. The two packages that did are both dead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scrollable_positioned_list&lt;/code&gt;&lt;/strong&gt; — archived by Google&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scroll_to_index&lt;/code&gt;&lt;/strong&gt; — last published in 2022&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Between them they still serve over a million downloads a month, which tells you&lt;br&gt;
how many apps are relying on an unmaintained solution to this.&lt;/p&gt;
&lt;h2&gt;
  
  
  How the archived approach worked
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scrollable_positioned_list&lt;/code&gt; built a &lt;strong&gt;second complete list&lt;/strong&gt;, anchored at the&lt;br&gt;
target index, and cross-faded from the old one to the new one.&lt;/p&gt;

&lt;p&gt;It works. It is also why jumping felt the way it did: for the duration of the&lt;br&gt;
transition there are two full sets of children alive in the tree, both being&lt;br&gt;
laid out, both painting. On a heavy row widget that is visible — a flash, a&lt;br&gt;
frame drop, sometimes a scrollbar that jumps twice.&lt;/p&gt;

&lt;p&gt;And it is why the package was hard to maintain. Two lists that must agree about&lt;br&gt;
scroll position, item extents and which one is currently authoritative is a lot&lt;br&gt;
of state to keep correct across Flutter versions.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the obvious workarounds do not hold
&lt;/h2&gt;

&lt;p&gt;Before the real answer, it is worth walking the approaches people try first,&lt;br&gt;
because each fails in a way that teaches something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed item extent.&lt;/strong&gt; If every row is exactly 72 pixels, index 842,013 is at&lt;br&gt;
offset 60,624,936 and you are done. &lt;code&gt;SliverFixedExtentList&lt;/code&gt; exists precisely for&lt;br&gt;
this and it is genuinely the fastest option. It also stops working the moment one&lt;br&gt;
row wraps to two lines, and "all my rows are identical forever" is a promise most&lt;br&gt;
products break within a month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Estimate, jump, correct.&lt;/strong&gt; Guess an average height, jump to the estimate, then&lt;br&gt;
measure where you landed and adjust. This visibly hunts — the list arrives near&lt;br&gt;
the target and then shuffles, sometimes several times. Worse, the correction&lt;br&gt;
happens &lt;em&gt;after&lt;/em&gt; paint, so the user sees the wrong content first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ensureVisible&lt;/code&gt; on a &lt;code&gt;GlobalKey&lt;/code&gt;.&lt;/strong&gt; This works beautifully and only for items&lt;br&gt;
that are already built. The element for index 842,013 does not exist, so there is&lt;br&gt;
no context to scroll to. It is the right tool for "scroll to this form field",&lt;br&gt;
and no tool at all for "jump into a list".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build everything.&lt;/strong&gt; A million &lt;code&gt;ListTile&lt;/code&gt;s is a million elements, a million&lt;br&gt;
render objects, and a frame budget measured in seconds. This is what&lt;br&gt;
&lt;code&gt;shrinkWrap: true&lt;/code&gt; quietly does to you in some nestings, which is why it has the&lt;br&gt;
reputation it has.&lt;/p&gt;

&lt;p&gt;Each of these is the sensible next idea after the previous one fails. The reason&lt;br&gt;
none of them work is that they are all trying to &lt;em&gt;compute&lt;/em&gt; an offset that the&lt;br&gt;
list fundamentally does not know.&lt;/p&gt;
&lt;h2&gt;
  
  
  The primitive Flutter already has
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Viewport&lt;/code&gt; can nominate a &lt;strong&gt;centre sliver&lt;/strong&gt; via its &lt;code&gt;center&lt;/code&gt; property. It is&lt;br&gt;
there for chat-style lists that grow upwards, and it does something unusual:&lt;br&gt;
content &lt;em&gt;before&lt;/em&gt; the centre sliver lays out at &lt;strong&gt;negative&lt;/strong&gt; scroll offset.&lt;/p&gt;

&lt;p&gt;That is the whole solution, once you see it.&lt;/p&gt;

&lt;p&gt;Split the list in two at the anchor index:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;everything &lt;strong&gt;before&lt;/strong&gt; the anchor goes in one sliver, laid out backwards from
zero into negative offsets&lt;/li&gt;
&lt;li&gt;the anchor and everything &lt;strong&gt;after&lt;/strong&gt; it go in a second sliver, marked as the
centre&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now scroll offset zero &lt;em&gt;is&lt;/em&gt; the anchor. Not "approximately the anchor once we&lt;br&gt;
have measured our way there" — it is the anchor, by definition, because that is&lt;br&gt;
what nominating a centre sliver means.&lt;/p&gt;

&lt;p&gt;Jumping to index 842,013 becomes: rebuild with the anchor set to 842,013, offset&lt;br&gt;
zero. There is nothing to measure, nothing to estimate, and nothing above it to&lt;br&gt;
build. It costs the same as jumping to index 3.&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;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AnchoredListController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;AnchoredList&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;controller:&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;itemCount:&lt;/span&gt; &lt;span class="mi"&gt;1000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;itemBuilder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&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;ListTile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Item &lt;/span&gt;&lt;span class="si"&gt;$index&lt;/span&gt;&lt;span class="s"&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;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jumpToIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;842013&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// same cost as jumping to item 3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One viewport, one set of children, no cross-fade.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why negative offsets are not a hack
&lt;/h3&gt;

&lt;p&gt;If nominating a centre sliver and laying content out at negative offsets sounds&lt;br&gt;
like an exotic trick, it is worth knowing it is what Flutter uses for its own&lt;br&gt;
reverse-scrolling lists. &lt;code&gt;CustomScrollView&lt;/code&gt; exposes &lt;code&gt;center&lt;/code&gt; as public API, and&lt;br&gt;
the framework's chat-style examples rely on exactly this behaviour.&lt;/p&gt;

&lt;p&gt;The insight this package contributes is not the primitive. It is noticing that&lt;br&gt;
"the offset origin can be placed anywhere" is the same problem as "jump to an&lt;br&gt;
arbitrary index" — and that once the origin &lt;em&gt;is&lt;/em&gt; the target, there is no&lt;br&gt;
arithmetic left to do.&lt;/p&gt;

&lt;p&gt;That is why the jump is O(1) rather than merely fast. It is not that the search&lt;br&gt;
got quicker; there is no search.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second thing this fixes
&lt;/h2&gt;

&lt;p&gt;There is a related problem the same structure solves for free.&lt;/p&gt;

&lt;p&gt;You are reading a chat, or a feed, or a log. New items arrive &lt;strong&gt;above&lt;/strong&gt; where you&lt;br&gt;
are looking. In an ordinary &lt;code&gt;ListView&lt;/code&gt; every insertion above your position pushes&lt;br&gt;
your content down, because your scroll offset is measured from the top of a list&lt;br&gt;
that just got taller.&lt;/p&gt;

&lt;p&gt;With a centre sliver, items above the anchor live at negative offsets. Inserting&lt;br&gt;
one extends the list &lt;em&gt;upwards&lt;/em&gt;, into more-negative territory. Your position is&lt;br&gt;
measured from the anchor, and the anchor has not moved — so your content does&lt;br&gt;
not move either.&lt;/p&gt;

&lt;p&gt;That is why the package is called &lt;code&gt;anchored_list&lt;/code&gt; rather than something about&lt;br&gt;
jumping. Holding your place while things arrive above you is the same mechanism&lt;br&gt;
as jumping instantly, seen from a different angle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this changes what you can build
&lt;/h2&gt;

&lt;p&gt;Two features become straightforward that are usually quietly dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep linking into a list.&lt;/strong&gt; A notification says "someone replied to your&lt;br&gt;
comment", and the comment is 4,000 items down. Ordinarily you either load a&lt;br&gt;
separate detail screen or you build a paginated approximation and hope. With an&lt;br&gt;
anchored list you open the list &lt;em&gt;at&lt;/em&gt; that comment, and scrolling up from it works&lt;br&gt;
normally because the items above are real list items, lazily built as the user&lt;br&gt;
reaches them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restoring scroll position properly.&lt;/strong&gt; Saving a pixel offset and restoring it&lt;br&gt;
is unreliable, because content above may have changed length between sessions.&lt;br&gt;
Saving an &lt;em&gt;index&lt;/em&gt; and restoring the anchor is exact — the user comes back to the&lt;br&gt;
item they were reading, not to a coordinate that used to contain it.&lt;/p&gt;

&lt;p&gt;Both of these usually get cut during estimation because "scroll to an arbitrary&lt;br&gt;
position in a long list" sounds like a week of work. It is a controller call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Honest limitations, because a list package that claims none is hiding some:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two slivers, not one.&lt;/strong&gt; If you were relying on a single-sliver structure for
something exotic, this changes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrollbar geometry is estimated.&lt;/strong&gt; A lazy list genuinely does not know its
own total height, so the thumb size is a best guess that improves as more is
measured. Every solution to this problem shares that limit, including the
archived ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The anchor is a rebuild&lt;/strong&gt;, not an animation. &lt;code&gt;jumpToIndex&lt;/code&gt; is instant by
design. If you want a visible scroll across a million rows, that is a
different feature and a much slower one — and one nobody actually wants, since
a five-second animated scroll past 800,000 rows is not a better experience than
arriving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Items above the anchor build as you scroll up&lt;/strong&gt;, exactly as items below build
as you scroll down. Jumping to index 842,013 and immediately flinging upward
will build items in that direction — which is correct, but means the work is
proportional to how far you scroll, not zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  On replacing an archived package
&lt;/h3&gt;

&lt;p&gt;If you are migrating from &lt;code&gt;scrollable_positioned_list&lt;/code&gt;, the mental model changes&lt;br&gt;
in one way worth flagging. That package thinks in terms of "scroll such that item&lt;br&gt;
N is at alignment 0.0–1.0 within the viewport". This one thinks in terms of "item&lt;br&gt;
N &lt;em&gt;is&lt;/em&gt; the origin". For the common case — put item N at the top — they express&lt;br&gt;
the same thing. For "put item N one third down the viewport", you are adjusting&lt;br&gt;
an offset from the anchor rather than passing an alignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://devshakib.jumyn.com/packages/anchored_list" rel="noopener noreferrer"&gt;&lt;code&gt;anchored_list&lt;/code&gt;&lt;/a&gt; is on pub.dev — MIT, no&lt;br&gt;
dependencies, all six platforms, 160/160 pub points.&lt;/p&gt;

&lt;p&gt;The repository has a demo that jumps around a million-row list while showing the&lt;br&gt;
live child count. That counter is the part worth watching: it stays flat as you&lt;br&gt;
jump from index 3 to 842,013 and back. A flat child count during a jump is the&lt;br&gt;
evidence that nothing above the anchor is being built — and it is the number that&lt;br&gt;
distinguishes this approach from every workaround above, all of which make it&lt;br&gt;
spike.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/flutter-scroll-to-index-in-a-lazy-list" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
    <item>
      <title>Talking to Native: FFI, Pigeon, and Knowing Which One You Need</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sat, 05 Sep 2026 23:12:10 +0000</pubDate>
      <link>https://dev.to/devshakib/talking-to-native-ffi-pigeon-and-knowing-which-one-you-need-37pj</link>
      <guid>https://dev.to/devshakib/talking-to-native-ffi-pigeon-and-knowing-which-one-you-need-37pj</guid>
      <description>&lt;p&gt;A &lt;code&gt;MethodChannel&lt;/code&gt; typo cost us three days and a hotfix release, and the compiler never said a word. That's the whole story of Flutter native interop in one sentence: the easy path is a stringly-typed message bus that fails silently in the field, and almost everyone reaches for it first.&lt;/p&gt;

&lt;p&gt;Every Flutter developer's first brush with native code goes the same way. You need something the framework doesn't give you — a battery level, a hardware sensor, a C library your backend team already trusts — and the first search result says &lt;code&gt;MethodChannel&lt;/code&gt;. You copy the snippet, wire up a stringly-typed channel name, and it works. Ship it.&lt;/p&gt;

&lt;p&gt;Then it grows. Six months later that one channel has fourteen methods, each one a &lt;code&gt;switch&lt;/code&gt; on a string, each argument a &lt;code&gt;Map&amp;lt;String, dynamic&amp;gt;&lt;/code&gt; you &lt;code&gt;as&lt;/code&gt;-cast and pray over. At Shpper we had exactly this: a device channel that had quietly become the single largest source of crash-free-rate regressions in one of our apps. The regression that cost us the three days was a renamed method on the Kotlin side that nobody renamed on the Dart side — green build, green tests, &lt;code&gt;MissingPluginException&lt;/code&gt; on real hardware two days after release. Not one of those crashes was catchable by the compiler, because we'd built the boundary out of strings. The lesson I keep relearning: &lt;code&gt;MethodChannel&lt;/code&gt; is the default answer and it is usually the wrong one.&lt;/p&gt;

&lt;p&gt;This post is the decision framework I wish I'd had earlier: the three ways Flutter talks to native code, what each one actually costs, and how to pick before you write a line of glue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three Flutter native interop paths, and what they actually cost
&lt;/h2&gt;

&lt;p&gt;Flutter gives you three real ways to reach native code. They are not interchangeable, and picking the wrong one is where the pain comes from.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dart:ffi&lt;/code&gt;&lt;/strong&gt; — call C (and Rust, and anything with a C ABI) directly, in-process, synchronously. No serialization, no message passing, no platform thread. This is the fastest path and the one people reach for last.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pigeon&lt;/strong&gt; — a code generator that turns a schema of Dart abstract classes into type-safe, generated method channels for you. Same transport as raw channels underneath, but the compiler now checks both sides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw &lt;code&gt;MethodChannel&lt;/code&gt; / &lt;code&gt;EventChannel&lt;/code&gt;&lt;/strong&gt; — the hand-written message bus between Dart and the platform (Kotlin/Java, Swift/Obj-C). Async, dynamically typed, and manual on both ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the mental model I use. FFI is for &lt;em&gt;code&lt;/em&gt; — you have a native function and you want to call it. Pigeon is for &lt;em&gt;platform APIs&lt;/em&gt; — you need to talk to Android or iOS SDKs and want a typed contract. Raw channels are for the awkward middle: event streams, plugin ecosystems, and things Pigeon can't express yet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;FFI&lt;/th&gt;
&lt;th&gt;Pigeon&lt;/th&gt;
&lt;th&gt;Raw MethodChannel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Talks to&lt;/td&gt;
&lt;td&gt;C / Rust / C ABI&lt;/td&gt;
&lt;td&gt;Kotlin / Swift SDKs&lt;/td&gt;
&lt;td&gt;Kotlin / Swift SDKs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Call style&lt;/td&gt;
&lt;td&gt;Synchronous&lt;/td&gt;
&lt;td&gt;Async (Future)&lt;/td&gt;
&lt;td&gt;Async (Future)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type safety&lt;/td&gt;
&lt;td&gt;Compile-time (C types)&lt;/td&gt;
&lt;td&gt;Compile-time (generated)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serialization&lt;/td&gt;
&lt;td&gt;None (raw memory)&lt;/td&gt;
&lt;td&gt;Standard codec&lt;/td&gt;
&lt;td&gt;Standard codec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on&lt;/td&gt;
&lt;td&gt;Calling thread&lt;/td&gt;
&lt;td&gt;Platform thread&lt;/td&gt;
&lt;td&gt;Platform thread&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Hot paths, existing C libs&lt;/td&gt;
&lt;td&gt;New platform integrations&lt;/td&gt;
&lt;td&gt;Streams, edge cases&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you take one thing away: the interesting decision is between FFI and Pigeon. Raw channels are the fallback, not the starting point. Everything below is really about earning the confidence to &lt;em&gt;not&lt;/em&gt; hand-write a channel by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where raw MethodChannel bites you in production
&lt;/h2&gt;

&lt;p&gt;The problem with raw channels isn't that they don't work. They work fine on the happy path, which is exactly why they're dangerous — the cost is deferred to the moment you least want it. A &lt;code&gt;MethodChannel&lt;/code&gt; is a &lt;code&gt;BasicMessageChannel&lt;/code&gt; with a method-call codec bolted on, and that's all the safety you get: a string name and a bag of dynamically-typed arguments.&lt;/p&gt;

&lt;p&gt;Look at a typical hand-rolled channel:&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;_channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MethodChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'com.shpper/device'&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;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getBatteryLevel&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;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;_channel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invokeMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'getBatteryLevel'&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="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// hope it's really an int&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 Kotlin side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMethodCallHandler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"getBatteryLevel"&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;batteryLevel&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="c1"&gt;// typo "getBateryLevel" here? compiles fine, fails at runtime&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notImplemented&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 failure modes are baked in and none of them are caught by a compiler:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stringly-typed dispatch.&lt;/strong&gt; The method name is a string on both sides. Rename it on one side and you get a silent &lt;code&gt;MissingPluginException&lt;/code&gt; in the field. Grep is your only "refactoring tool," and grep doesn't know the difference between a channel name and a comment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Untyped arguments.&lt;/strong&gt; Everything crosses the boundary as &lt;code&gt;Object?&lt;/code&gt;. You cast on the Dart side and cast again in Kotlin. Change an argument's shape — an &lt;code&gt;int&lt;/code&gt; that becomes a &lt;code&gt;long&lt;/code&gt;, a field that becomes nullable — and nothing warns you until a specific device tries it. Worse, the standard codec silently promotes small integers, so a value that's fine in the emulator can &lt;code&gt;ClassCastException&lt;/code&gt; on a payload that happens to exceed 32 bits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always async, even when it shouldn't be.&lt;/strong&gt; Every call is a &lt;code&gt;Future&lt;/code&gt;, even reading a constant. That async hop forces &lt;code&gt;await&lt;/code&gt; into call sites that are conceptually synchronous, which spreads through your architecture and makes otherwise-pure functions infectious.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Raw channels are the assembly language of Flutter interop. Sometimes you need assembly. You just shouldn't write your whole app in it — and you definitely shouldn't reach for it first for a plain typed request/response API.&lt;/p&gt;

&lt;h2&gt;
  
  
  dart:ffi for synchronous C and Rust interop
&lt;/h2&gt;

&lt;p&gt;When you actually have native &lt;em&gt;code&lt;/em&gt; — an image codec, a crypto primitive, a Rust core you share across platforms — FFI is a different universe. There's no message bus. Dart calls the C function directly and gets the result back on the same thread, synchronously, with zero serialization.&lt;/p&gt;

&lt;p&gt;On a project last year we needed to hash and verify a few thousand small records on-device during a sync. Doing it over a method channel meant a round trip per record, and the platform-thread hop killed us — the per-call overhead dominated the actual work. Moving the hot loop to a tiny C function behind FFI took the whole operation from "spinner the user notices" to "done before the frame ends." The win wasn't a faster hash; it was deleting the boundary entirely.&lt;/p&gt;

&lt;p&gt;The mechanics: you declare the native signature and the Dart signature, then bind them.&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="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:ffi'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:ffi/ffi.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// C: uint32_t crc32(const uint8_t* data, int len);&lt;/span&gt;
&lt;span class="kd"&gt;typedef&lt;/span&gt; &lt;span class="n"&gt;_Crc32C&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Uint32&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Pointer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Uint8&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;Int32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;typedef&lt;/span&gt; &lt;span class="n"&gt;_Crc32Dart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Pointer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Uint8&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&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;_lib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DynamicLibrary&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'libhash.so'&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;_crc32&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lookupFunction&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_Crc32C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_Crc32Dart&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;'crc32'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;crc32&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;bytes&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;ptr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;malloc&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Uint8&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asTypedList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&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;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAll&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="n"&gt;bytes&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;return&lt;/span&gt; &lt;span class="n"&gt;_crc32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&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="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;malloc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// you own this memory now&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;Note the two typedefs: one uses native FFI types (&lt;code&gt;Uint32&lt;/code&gt;, &lt;code&gt;Int32&lt;/code&gt;, &lt;code&gt;Pointer&lt;/code&gt;) to describe the C ABI, the other uses plain Dart types (&lt;code&gt;int&lt;/code&gt;) for the call site. &lt;code&gt;lookupFunction&lt;/code&gt; marries them. Get a width wrong — &lt;code&gt;Int32&lt;/code&gt; where the header says &lt;code&gt;int64_t&lt;/code&gt; — and you'll read garbage or corrupt the stack, so this is exactly the place to let &lt;code&gt;ffigen&lt;/code&gt; transcribe headers instead of doing it by hand.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;try/finally&lt;/code&gt; is the whole game with FFI. The moment you cross into native memory, Dart's garbage collector stops helping you. Every &lt;code&gt;malloc&lt;/code&gt; needs a &lt;code&gt;free&lt;/code&gt;, and if you throw in between, you leak. My rules after getting this wrong more than once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Allocate and free in the same function&lt;/strong&gt; whenever you can. If a pointer has to outlive the call, wrap it in a class with a &lt;code&gt;dispose()&lt;/code&gt; and treat it like a file handle — and consider a &lt;code&gt;NativeFinalizer&lt;/code&gt; as a backstop so a forgotten &lt;code&gt;dispose()&lt;/code&gt; degrades to a late free instead of a permanent leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;package:ffi&lt;/code&gt;'s &lt;code&gt;malloc&lt;/code&gt;/&lt;code&gt;calloc&lt;/code&gt;&lt;/strong&gt;, not raw &lt;code&gt;Pointer&lt;/code&gt; gymnastics — they give you &lt;code&gt;.free()&lt;/code&gt; and helpers like &lt;code&gt;.toNativeUtf8()&lt;/code&gt; for the ever-annoying string marshalling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy at the boundary.&lt;/strong&gt; &lt;code&gt;asTypedList&lt;/code&gt; gives you a &lt;em&gt;view&lt;/em&gt; into native memory; if that memory gets freed, the view is a dangling pointer and reading it is undefined behaviour. Copy into a Dart &lt;code&gt;List&lt;/code&gt; before you free if the data escapes the function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocking calls block the isolate.&lt;/strong&gt; FFI is synchronous, so a slow native call freezes your UI. For anything long-running, use &lt;code&gt;Isolate.run&lt;/code&gt; or an async FFI callback via &lt;code&gt;NativeCallable.listener&lt;/code&gt; so native code can call back into Dart from another thread.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things make modern FFI far less painful. &lt;strong&gt;&lt;code&gt;ffigen&lt;/code&gt;&lt;/strong&gt; reads a C header and generates all the typedefs and bindings for you, so you're not hand-transcribing signatures and getting integer widths wrong. And &lt;strong&gt;&lt;code&gt;flutter_rust_bridge&lt;/code&gt;&lt;/strong&gt; does the same for Rust, generating the FFI glue and handling the memory dance so a Rust core feels like a normal async Dart API — including turning long-running Rust work into proper Dart &lt;code&gt;Future&lt;/code&gt;s and &lt;code&gt;Stream&lt;/code&gt;s. If you have a real algorithmic core to share across platforms, that combination is the strongest option Flutter has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pigeon for type-safe platform channels
&lt;/h2&gt;

&lt;p&gt;FFI is great when the thing you're calling is C. But most native work isn't C — it's "please open the iOS share sheet" or "read this value from the Android KeyStore." That means talking to platform SDKs in Swift and Kotlin, and for that, the right tool is Pigeon.&lt;/p&gt;

&lt;p&gt;Pigeon isn't a runtime; there's nothing to add to your app's dependency footprint at ship time. It's a code generator you run at build time. You write a schema in Dart — just abstract classes and data classes — and Pigeon emits the channel plumbing for Dart, Kotlin/Java, and Swift/Obj-C. The wire is still a method channel underneath. The difference is the compiler now sees both ends of 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="c1"&gt;// pigeons/device_api.dart — this file is the schema, not shipped code&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:pigeon/pigeon.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeviceInfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;batteryLevel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;isCharging&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@HostApi&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeviceApi&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;DeviceInfo&lt;/span&gt; &lt;span class="n"&gt;getDeviceInfo&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nd"&gt;@async&lt;/span&gt;
  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;authenticate&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;reason&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;Run &lt;code&gt;dart run pigeon --input pigeons/device_api.dart&lt;/code&gt; and you get a generated Dart class you call like any typed API, plus a Kotlin &lt;code&gt;interface&lt;/code&gt; and a Swift &lt;code&gt;protocol&lt;/code&gt; you &lt;em&gt;implement&lt;/em&gt;. Now the compiler is your integration test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DevicePlugin&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DeviceApi&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getDeviceInfo&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;DeviceInfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;DeviceInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;batteryLevel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;currentBattery&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="n"&gt;isCharging&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;charging&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="c1"&gt;// forget authenticate()? Kotlin won't compile. That's the point.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rename-safe by construction.&lt;/strong&gt; Change a method or a field and both platforms fail to compile until you fix them. No more field-day &lt;code&gt;MissingPluginException&lt;/code&gt; from a one-character typo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real data classes&lt;/strong&gt; instead of &lt;code&gt;Map&amp;lt;String, dynamic&amp;gt;&lt;/code&gt;. The DTO is defined once and generated everywhere, so there's a single source of truth for the shape crossing the boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HostApi&lt;/code&gt; and &lt;code&gt;FlutterApi&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;@HostApi()&lt;/code&gt; lets Dart call &lt;em&gt;into&lt;/em&gt; native; &lt;code&gt;@FlutterApi()&lt;/code&gt; lets native call &lt;em&gt;back into&lt;/em&gt; Dart. Both are typed, so bidirectional flows (a native SDK pushing a callback into your Dart layer) stop being a stringly-typed guessing game.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical workflow note: check the generated files into version control and regenerate them in CI, then fail the build if the output changed. That turns "someone edited the schema but forgot to regenerate" into a red build instead of a runtime surprise — the same discipline I apply to any generated code.&lt;/p&gt;

&lt;p&gt;My default now for any new platform integration is Pigeon first, and I only drop to a raw channel when I hit something Pigeon genuinely can't model. The generated code is boring, which on a boundary this error-prone is the highest praise I can give.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threading: where the native boundary really lives
&lt;/h2&gt;

&lt;p&gt;The part that trips people up isn't syntax — it's which thread the code runs on. Get this wrong and you'll ship an app that janks or, worse, deadlocks intermittently on some devices and never in the office.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Method/Pigeon channel handlers run on the platform's main thread&lt;/strong&gt; (the Android main thread, the iOS main queue) by default. Do heavy work there and you block the native UI thread. On Android that's an ANR waiting to happen; on iOS it's a watchdog kill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dart async is not a thread.&lt;/strong&gt; &lt;code&gt;await&lt;/code&gt; yields on the single UI isolate. It keeps the UI responsive for I/O-bound work, but a tight CPU loop in Dart still blocks the frame — concurrency and parallelism are not the same thing here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FFI calls run on the calling thread.&lt;/strong&gt; Call it from the UI isolate and a slow function freezes your app; there's no free hop to a background thread like channels give you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the real rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In a native channel handler, if the work is slow, &lt;strong&gt;dispatch to a background thread&lt;/strong&gt; (a Kotlin coroutine on &lt;code&gt;Dispatchers.IO&lt;/code&gt;, a &lt;code&gt;DispatchQueue.global()&lt;/code&gt;) and post the result back to the channel. Don't do the work inline on the platform thread.&lt;/li&gt;
&lt;li&gt;For heavy Dart-side or FFI work, &lt;strong&gt;move it off the UI isolate.&lt;/strong&gt; &lt;code&gt;Isolate.run(() =&amp;gt; crc32(bytes))&lt;/code&gt; runs the synchronous FFI call on a separate isolate so the main one keeps rendering. Remember that data crossing isolate boundaries is copied unless it's transferable (like &lt;code&gt;TransferableTypedData&lt;/code&gt;), so measure before you assume the isolate hop is free.&lt;/li&gt;
&lt;li&gt;For continuous native events (location, sensors, download progress), use an &lt;strong&gt;&lt;code&gt;EventChannel&lt;/code&gt;&lt;/strong&gt; — a typed stream — rather than polling with repeated method calls. Polling turns a push problem into an N-round-trips-per-second problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The mistake I see most: someone benchmarks a single channel call, sees it's "fast," and assumes it's free. Then they call it 60 times a frame in a scroll listener. The per-call cost is small; the platform-thread contention and codec churn at that frequency are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passing big payloads without killing the codec
&lt;/h2&gt;

&lt;p&gt;Every method channel serializes its arguments through the &lt;strong&gt;standard message codec&lt;/strong&gt;. For small structured data that's fine. For big payloads it is a trap, and it's a trap you fall into precisely when performance matters most.&lt;/p&gt;

&lt;p&gt;Two things go wrong at scale. First, a large &lt;code&gt;Map&lt;/code&gt; or &lt;code&gt;List&lt;/code&gt; is walked and encoded field by field — slow and allocation-heavy, with garbage-collection pressure on both sides. Second, that encode/decode happens on threads you care about, so a fat payload stalls the UI while it's being marshalled.&lt;/p&gt;

&lt;p&gt;The fix is to stop sending structure and start sending bytes. The standard codec has a fast path for &lt;code&gt;Uint8List&lt;/code&gt; — it's passed as a raw byte buffer, not walked element by element.&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;// Slow: a 100k-element list gets encoded item by item&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;points&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;channel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invokeMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'getPoints'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// List&amp;lt;dynamic&amp;gt;, ouch&lt;/span&gt;

&lt;span class="c1"&gt;// Fast: hand back a packed byte buffer, decode it in Dart&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;buffer&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;channel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invokeMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'getPointsBytes'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Uint8List&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;floats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asFloat32List&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the native side, pack your data into a &lt;code&gt;ByteArray&lt;/code&gt;/&lt;code&gt;Data&lt;/code&gt; and return that. You decode it in Dart with a typed-data view — no per-element codec cost, just a pointer reinterpretation. For a mesh of a few hundred thousand floats, this was the difference between a visible hitch and nothing at all. Mind endianness and struct alignment when you do this: agree on a layout (little-endian, tightly packed) and, if in doubt, use a &lt;code&gt;ByteData&lt;/code&gt; with explicit &lt;code&gt;getFloat32(offset, Endian.little)&lt;/code&gt; reads rather than a raw view.&lt;/p&gt;

&lt;p&gt;And if you're already passing large buffers back and forth constantly, that's a signal you might want FFI instead. FFI shares memory by pointer — zero copy, zero serialization. The codec's byte fast-path is the right answer &lt;em&gt;inside&lt;/em&gt; the channel world; FFI is the right answer when the channel world itself is the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging native code so consumers don't need a toolchain
&lt;/h2&gt;

&lt;p&gt;This is the part that turns a working prototype into a shippable plugin, and it's where a lot of FFI projects quietly die. Your C or Rust code has to be &lt;em&gt;built&lt;/em&gt; for every target — Android arm64/armeabi-v7a/x86_64, iOS device and simulator, and ideally macOS/Windows/Linux for desktop. If installing your package means "now go set up the NDK and a Rust cross-compiler," nobody will use it, including your future self on a fresh machine.&lt;/p&gt;

&lt;p&gt;Flutter's plugin system does have hooks for this. The clean options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prebuilt binaries.&lt;/strong&gt; Build your &lt;code&gt;.so&lt;/code&gt;/&lt;code&gt;.a&lt;/code&gt;/&lt;code&gt;.xcframework&lt;/code&gt; in CI and ship them inside the plugin (or, to keep the package small, download-on-first-build). This is the approach I lean toward — I already build binaries in CI and attach them to GitHub Releases for other parts of the stack, so the pattern is familiar and it keeps consumers toolchain-free. The trade-off is that you're responsible for reproducible, correctly-signed builds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native build hooks.&lt;/strong&gt; Point the Android Gradle/CMake and iOS CocoaPods/&lt;code&gt;podspec&lt;/code&gt; at your source so it compiles as part of the app build. Simplest to set up, but now every consumer needs the full native toolchain and eats the compile time on every clean build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;flutter_rust_bridge&lt;/code&gt; + &lt;code&gt;cargokit&lt;/code&gt;&lt;/strong&gt; automates the Rust build across all targets and is the least painful route if you're in Rust — it wires the cross-compilation into the standard Flutter build so &lt;code&gt;flutter build&lt;/code&gt; just produces the right artifacts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you pick, the goal is the same: &lt;code&gt;flutter pub add your_package&lt;/code&gt; should just work, with no README section titled "First, install these seven tools." The moment native code needs a manual setup step, adoption falls off a cliff.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Flutter native interop decision tree you can apply on Monday
&lt;/h2&gt;

&lt;p&gt;Strip away the nuance and the choice is short:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Are you calling native code you already have as C or Rust?&lt;/strong&gt; Use &lt;strong&gt;FFI&lt;/strong&gt;. Wrap &lt;code&gt;ffigen&lt;/code&gt; or &lt;code&gt;flutter_rust_bridge&lt;/code&gt; around it, mind the memory (every &lt;code&gt;malloc&lt;/code&gt; its &lt;code&gt;free&lt;/code&gt;), and push slow calls off the UI isolate. Synchronous, zero-copy, no codec.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are you integrating with a platform SDK (iOS/Android APIs)?&lt;/strong&gt; Use &lt;strong&gt;Pigeon&lt;/strong&gt;. Write the schema, generate typed bindings, implement the &lt;code&gt;HostApi&lt;/code&gt; on each side, and let the compiler catch your mistakes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you need a continuous stream of native events, or something Pigeon can't model yet?&lt;/strong&gt; Use a raw &lt;strong&gt;&lt;code&gt;EventChannel&lt;/code&gt;/&lt;code&gt;MethodChannel&lt;/code&gt;&lt;/strong&gt; — deliberately, knowing you own the type safety by hand and wrapping it thinly so the untyped surface doesn't leak through your app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are you moving big payloads?&lt;/strong&gt; Send &lt;code&gt;Uint8List&lt;/code&gt;, not maps. And if you're doing it constantly, reconsider whether the problem is actually an FFI problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what's &lt;em&gt;not&lt;/em&gt; on this list as a default: hand-writing a &lt;code&gt;MethodChannel&lt;/code&gt; for a new typed API. That's the option to talk yourself out of, not into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MethodChannel&lt;/code&gt; is the well-known answer and usually the wrong one&lt;/strong&gt; — it's stringly-typed, untyped, always async, and every failure it hides shows up in production instead of at compile time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for FFI when you're calling actual C or Rust code&lt;/strong&gt;: synchronous, no serialization, zero-copy — but you own the memory. Every &lt;code&gt;malloc&lt;/code&gt; needs its &lt;code&gt;free&lt;/code&gt;, views into native memory can dangle, and slow calls belong off the UI isolate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for Pigeon for platform SDK integrations&lt;/strong&gt;: you get generated, type-safe channels where the compiler checks both the Dart and native sides, and renames become compile errors instead of field crashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep raw channels for event streams and the genuine edge cases&lt;/strong&gt; Pigeon can't express — chosen on purpose, not by default, and wrapped thinly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the thread&lt;/strong&gt;: channel handlers run on the platform main thread, FFI runs on the calling isolate, and heavy work belongs on a background thread or a spawned isolate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass large data as &lt;code&gt;Uint8List&lt;/code&gt;&lt;/strong&gt; to hit the codec's fast path, and &lt;strong&gt;package your native binaries in CI&lt;/strong&gt; so consumers never need a toolchain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right tool depends entirely on whether you're crossing to &lt;em&gt;code&lt;/em&gt; or to a &lt;em&gt;platform API&lt;/em&gt; — answer that first, and the rest of the decision makes itself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/talking-to-native-ffi-pigeon-and-knowing-which-one-you-need" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
    <item>
      <title>Getting Work Off the Main Thread: Isolates Without the Hand-Waving</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sat, 05 Sep 2026 23:02:08 +0000</pubDate>
      <link>https://dev.to/devshakib/getting-work-off-the-main-thread-isolates-without-the-hand-waving-41nd</link>
      <guid>https://dev.to/devshakib/getting-work-off-the-main-thread-isolates-without-the-hand-waving-41nd</guid>
      <description>&lt;p&gt;A designer once handed me an 18 MB JSON export we had to parse, normalize, and diff against our local copy before we could render a preview. On a mid-range Android phone that parse froze the UI for about 900ms. That's not a spinner pause. That's a "did the app just die?" pause, the kind where the user taps three more times and now you have a rage-tap in your logs.&lt;/p&gt;

&lt;p&gt;The fix everyone reaches for is "throw it on an isolate," and that's usually correct. The problem is that almost every Dart isolate tutorial stops exactly where it gets interesting, right before message-passing costs bite you and you end up slower than when you started. This is the post I wish I'd had: not "isolates are like threads" hand-waving, but a working model of what an isolate is, what crossing between them actually costs, and how to decide whether spawning one helps or hurts. I've shipped this in production at Shpper and gotten it wrong enough times to have opinions worth the read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The single-thread myth: what actually blocks a Flutter frame
&lt;/h2&gt;

&lt;p&gt;Dart runs your code on a single thread of execution per isolate. That's the part people repeat. The part they skip: "single-threaded" doesn't mean "does one thing." The event loop juggles thousands of &lt;code&gt;async&lt;/code&gt; operations happily, because &lt;code&gt;await&lt;/code&gt; yields control back to the loop while I/O happens elsewhere (the OS, the network stack, the disk). Async is about &lt;em&gt;waiting&lt;/em&gt; efficiently.&lt;/p&gt;

&lt;p&gt;What &lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt; does nothing for is &lt;em&gt;work&lt;/em&gt;. A tight loop parsing 18 MB of JSON doesn't await anything. It just runs, synchronously, holding the thread. And the UI thread has a hard deadline: at 60fps you have roughly 16ms to produce a frame, at 120fps roughly 8ms. Anything synchronous that runs longer than that budget between two frames drops a frame. Run 900ms of parsing and you've dropped dozens of frames in a row. That's the jank.&lt;/p&gt;

&lt;p&gt;The mental split I use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I/O-bound work&lt;/strong&gt; (network calls, file reads, database queries): stay on the main isolate, use &lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt;. An isolate buys you nothing here and adds overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU-bound work&lt;/strong&gt; (JSON parsing, encryption, image manipulation, compression, big list transforms): this is what blocks frames. This is what isolates are for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can't point at a hot synchronous loop, you don't have an isolate problem. You have a "put an &lt;code&gt;await&lt;/code&gt; in the right place" problem, and reaching for an isolate will just add complexity and latency. A surprising number of "we need concurrency" tickets are actually a blocking &lt;code&gt;jsonDecode&lt;/code&gt; or a synchronous file read that should have been the async variant in the first place.&lt;/p&gt;

&lt;h3&gt;
  
  
  A quick way to tell them apart
&lt;/h3&gt;

&lt;p&gt;Ask one question: &lt;em&gt;does this code ever wait on something outside the CPU?&lt;/em&gt; If it waits on a socket, a disk, or a platform channel, it's I/O-bound and &lt;code&gt;await&lt;/code&gt; handles it. If it just churns through data in memory, it's CPU-bound and no amount of &lt;code&gt;async&lt;/code&gt; will unblock the frame, because there's nothing to yield on. &lt;code&gt;Future(() =&amp;gt; heavyWork())&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; help here either: it defers the work to a later microtask, but that work still runs on the same thread and still blocks the same UI. Deferring is not offloading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolate.run vs compute vs a long-lived spawned isolate
&lt;/h2&gt;

&lt;p&gt;There are three tools here and they're not interchangeable. Choosing the wrong one is the difference between a clean win and a regression.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;compute&lt;/code&gt;&lt;/strong&gt; is Flutter's original one-shot helper. You hand it a top-level (or static) function and one argument, it spins up an isolate, runs the function, sends the result back, and tears the isolate down.&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;parsed&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;compute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parseDesignExport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rawJsonString&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;&lt;code&gt;Isolate.run&lt;/code&gt;&lt;/strong&gt; (Dart 2.19 and later) is the modern, framework-agnostic version of the same idea. It's what I default to now. It takes a closure instead of a named function, which is a real ergonomic win because you can capture local variables directly instead of packing them into a single argument object.&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;parsed&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;Isolate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&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;parseDesignExport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawJsonString&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of these do the same thing conceptually: spawn, run once, return, die. The isolate startup cost is real but modest, on the order of a couple hundred microseconds to low milliseconds depending on platform. For a 900ms parse, that overhead is noise. For a 2ms task, that overhead is the whole point and you shouldn't be spawning at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A long-lived spawned isolate&lt;/strong&gt; (&lt;code&gt;Isolate.spawn&lt;/code&gt; plus your own &lt;code&gt;SendPort&lt;/code&gt;/&lt;code&gt;ReceivePort&lt;/code&gt; plumbing) is what you want when the same worker handles many messages over time. Spawning once and reusing it amortizes the startup cost and, more importantly, lets the worker keep warm state: a loaded ML model, an open database handle, a parser with a primed cache.&lt;/p&gt;

&lt;p&gt;The decision rule I use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One task, run once, then done: &lt;code&gt;Isolate.run&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Same kind of task, many times, and startup or warm state matters: a long-lived spawned isolate.&lt;/li&gt;
&lt;li&gt;Legacy Flutter code, or you just want the well-known name: &lt;code&gt;compute&lt;/code&gt; (it's fine, it's essentially &lt;code&gt;Isolate.run&lt;/code&gt; with a Flutter hat on).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One gotcha that trips people up on all three: the function or closure you hand to an isolate must be able to run in a fresh context. That means no capturing of things tied to the spawning isolate that can't cross the boundary, and any closure captures get copied by the same rules as messages (more on that next). If you see a &lt;code&gt;hard to serialize&lt;/code&gt; or &lt;code&gt;Illegal argument in isolate message&lt;/code&gt; error, you almost always captured something you shouldn't have, like a &lt;code&gt;BuildContext&lt;/code&gt;, a stream controller, or a plugin instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The copy tax: what SendPort actually serializes
&lt;/h2&gt;

&lt;p&gt;Here's the cost nobody puts on the label. Isolates don't share memory. When you send an object through a &lt;code&gt;SendPort&lt;/code&gt;, Dart doesn't hand the other isolate a reference. It &lt;em&gt;copies&lt;/em&gt; the entire object graph, deep, into the receiving isolate's heap. Send a 40 MB parsed tree back and you pay for allocating and copying 40 MB, plus the garbage you just created on both sides.&lt;/p&gt;

&lt;p&gt;I learned this the annoying way. I moved my 18 MB parse onto an isolate, the parse itself flew, and then I &lt;em&gt;still&lt;/em&gt; had jank. The parse produced a large nested &lt;code&gt;Map&lt;/code&gt;/&lt;code&gt;List&lt;/code&gt; structure, and copying &lt;em&gt;that&lt;/em&gt; back to the main isolate was itself a multi-hundred-millisecond synchronous operation on the receiving side. I'd moved the compute off the main thread but left the copy on it. The profiler showed a fat synchronous block right after the "await" returned, which is the deserialization landing on the UI thread.&lt;/p&gt;

&lt;p&gt;Two things fixed it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Return less.&lt;/strong&gt; The main isolate rarely needs the full parsed graph. I changed the worker to do the parse &lt;em&gt;and&lt;/em&gt; the diff &lt;em&gt;and&lt;/em&gt; the normalization, and return only the small delta the UI actually renders. Compute-heavy work belongs where the data already is, so push logic &lt;em&gt;into&lt;/em&gt; the worker rather than shuttling raw data out of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For byte buffers, use &lt;code&gt;TransferableTypedData&lt;/code&gt;.&lt;/strong&gt; This is the escape hatch. It hands ownership of a byte buffer to the other isolate with effectively zero copy, at the cost of the sending isolate no longer being able to touch it.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:isolate'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:typed_data'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// On the sending side:&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&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;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 20 MB&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;transferable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TransferableTypedData&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;bytes&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="n"&gt;sendPort&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transferable&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ownership moves; near-zero copy&lt;/span&gt;

&lt;span class="c1"&gt;// On the receiving side:&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TransferableTypedData&lt;/span&gt; &lt;span class="n"&gt;t&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;ByteBuffer&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt; &lt;span class="o"&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;materialize&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asUint8List&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// ...use data&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;TransferableTypedData&lt;/code&gt; only works for typed data (byte buffers), not arbitrary Dart objects. But a huge amount of heavy work — images, audio, compressed payloads, protobufs — is byte buffers at the boundary anyway. Design your isolate interface so what crosses the wire is bytes or a small summary, never a giant object graph. The rule I keep coming back to: &lt;strong&gt;cross the isolate boundary with as little as possible.&lt;/strong&gt; The message-passing model is the whole cost structure of Dart concurrency, and every megabyte you send is a megabyte you allocate, copy, and later collect.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worker-pool pattern for streaming and back-pressure
&lt;/h2&gt;

&lt;p&gt;Once you go long-lived, you need real plumbing. A single spawned isolate that you talk to over &lt;code&gt;SendPort&lt;/code&gt;/&lt;code&gt;ReceivePort&lt;/code&gt; 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;class&lt;/span&gt; &lt;span class="nc"&gt;Worker&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;SendPort&lt;/span&gt; &lt;span class="n"&gt;_commands&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;ReceivePort&lt;/span&gt; &lt;span class="n"&gt;_responses&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;_pending&lt;/span&gt; &lt;span class="o"&gt;=&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;,&lt;/span&gt; &lt;span class="n"&gt;Completer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;{};&lt;/span&gt;
  &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;_nextId&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;Worker&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="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_commands&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;_responses&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_responses&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;_onResponse&lt;/span&gt;&lt;span class="p"&gt;);&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;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;spawn&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;init&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ReceivePort&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;Isolate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendPort&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;commands&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;init&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;first&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;SendPort&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;responses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ReceivePort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;commands&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendPort&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;Worker&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="n"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;);&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;Object&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;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;job&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_nextId&lt;/span&gt;&lt;span class="o"&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Completer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Object&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;_pending&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;_commands&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job&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;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;future&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="n"&gt;_onResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;dynamic&lt;/span&gt; &lt;span class="n"&gt;msg&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="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;_pending&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;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;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&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;void&lt;/span&gt; &lt;span class="n"&gt;_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SendPort&lt;/span&gt; &lt;span class="n"&gt;initPort&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;commandPort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ReceivePort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;initPort&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;commandPort&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendPort&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;late&lt;/span&gt; &lt;span class="n"&gt;SendPort&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;commandPort&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;msg&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;msg&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;SendPort&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;responses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&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="kd"&gt;final&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&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="n"&gt;doWork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// your CPU-bound function&lt;/span&gt;
      &lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&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;The &lt;code&gt;id&lt;/code&gt; correlation is the important detail. Because responses come back over a single port, you tag each job so you can match results to the right &lt;code&gt;Completer&lt;/code&gt;. Without it you can't have more than one in-flight request, since you'd have no way to know which response belongs to which caller.&lt;/p&gt;

&lt;p&gt;Now the two things people forget:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Back-pressure.&lt;/strong&gt; A single worker processes jobs serially in its own event loop. If your producer fires jobs faster than the worker drains them, the pending queue grows without bound and you eat memory until you OOM. Cap it. I keep a semaphore on the producer side: don't dispatch job N+K until job N has come back. For genuinely parallel throughput, spawn a &lt;em&gt;pool&lt;/em&gt; of N workers (N roughly the number of physical cores) and round-robin or least-loaded dispatch across them. More than that and you're just context-switching for no gain, since the OS still only has so many cores. A pool of 3 to 4 workers is my usual starting point on mobile; measure before going wider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streaming.&lt;/strong&gt; If a job produces incremental output (say, decoding frames or lines of a CSV), have the worker send multiple &lt;code&gt;(id, chunk)&lt;/code&gt; messages and a final &lt;code&gt;(id, done)&lt;/code&gt; sentinel, and expose it on the main side as a &lt;code&gt;Stream&lt;/code&gt; instead of a &lt;code&gt;Future&lt;/code&gt;. But remember every chunk is a copy, so chunk coarsely. Sending 10,000 tiny messages will cost you more in message overhead than the work you saved offloading. Batch chunks into reasonably sized payloads and the streaming stays cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing memory the legal way: TransferableTypedData and FFI
&lt;/h2&gt;

&lt;p&gt;"Isolates don't share memory" is true for Dart objects. It is not the whole truth. There are legitimate ways to share a region of memory, and for the heaviest workloads they're the only thing that performs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TransferableTypedData&lt;/code&gt;&lt;/strong&gt; (above) is &lt;em&gt;move&lt;/em&gt;, not share: one isolate has the buffer at a time. Great for pipelines, useless when two isolates need the same data simultaneously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FFI-allocated native memory&lt;/strong&gt; is genuinely shared. If you &lt;code&gt;malloc&lt;/code&gt; a buffer through &lt;code&gt;dart:ffi&lt;/code&gt;, that pointer is just an address. Pass the address (an &lt;code&gt;int&lt;/code&gt;) across the port and both isolates can read and write the same bytes. No copy, true sharing.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:ffi'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:ffi/ffi.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Allocate a shared buffer in native memory:&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;Pointer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Uint8&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;malloc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allocate&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Uint8&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Send just the address across:&lt;/span&gt;
&lt;span class="n"&gt;sendPort&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// In the other isolate, reconstruct the pointer:&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;Pointer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Uint8&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Pointer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addressInt&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;view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asTypedList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// reads/writes the same bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch is that you've now opted out of Dart's memory safety. No GC over that region, manual &lt;code&gt;malloc&lt;/code&gt;/&lt;code&gt;free&lt;/code&gt;, and if two isolates write the same bytes concurrently you have a genuine data race with no lock to save you. This is C-level power with C-level footguns. I reach for it only when profiling proves the copy is the bottleneck and the buffer is large and long-lived: image pipelines, audio processing, an mmap'd file. For everything else, copy semantics are a &lt;em&gt;feature&lt;/em&gt;, not a limitation. They make isolates deadlock-free and race-free by construction, which is most of why they're pleasant to use compared to threads-and-locks concurrency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolates on Flutter web: where the story changes
&lt;/h2&gt;

&lt;p&gt;If you ship Flutter web, unlearn some of the above. On the web, Dart isolates map onto Web Workers, and Web Workers are genuinely separate contexts with hard limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Isolate.spawn&lt;/code&gt; and &lt;code&gt;Isolate.run&lt;/code&gt; are not supported on web the way they are on native.&lt;/strong&gt; Historically &lt;code&gt;compute&lt;/code&gt; on web just ran your function on the main thread and returned a future, giving you exactly zero parallelism. Tooling continues to evolve, but you cannot assume the native behavior holds. Test it, don't trust it.&lt;/li&gt;
&lt;li&gt;Web Workers can't touch the DOM, and passing data uses &lt;code&gt;postMessage&lt;/code&gt; with structured clone, which is its own copy tax with its own rules about what is and isn't cloneable.&lt;/li&gt;
&lt;li&gt;The reliable web escape hatch for real off-main-thread work is often &lt;em&gt;not&lt;/em&gt; a Dart isolate at all. It's a hand-written JS Web Worker (or a WASM module) that you talk to over JS interop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical consequence: don't design a single concurrency layer and assume it behaves identically across native and web. I gate the heavy path behind a capability check and fall back to chunked, yielding work on web — process a batch, &lt;code&gt;await Future.delayed(Duration.zero)&lt;/code&gt; to let a frame render, then process the next batch:&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;processInChunks&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="n"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;items&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;const&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&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="n"&gt;items&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="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;j&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;j&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;items&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;j&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="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&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;Future&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delayed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zero&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// yield so a frame can render&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;It's cooperative multitasking instead of real parallelism, but it keeps the UI breathing, which is the actual goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancellation, timeouts, and cleaning up a dead worker
&lt;/h2&gt;

&lt;p&gt;Isolates have no built-in &lt;code&gt;cancel()&lt;/code&gt;. You can't reach into a running isolate and stop a synchronous loop mid-flight, because it never yields to check for a cancel signal. Three tactics I use, in order of bluntness:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cooperative cancellation.&lt;/strong&gt; If the work is a loop over chunks, have the worker check a &lt;code&gt;cancelled&lt;/code&gt; flag (set via a message) between chunks. Only works if the work is chunkable and the worker actually returns to its event loop periodically to receive that message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kill the isolate.&lt;/strong&gt; For genuinely uninterruptible work, &lt;code&gt;Isolate.kill(priority: Isolate.immediate)&lt;/code&gt; is the hammer. It terminates the isolate, full stop. Any FFI-allocated memory it owned leaks unless you freed it first, and any in-flight &lt;code&gt;Completer&lt;/code&gt; on the main side hangs forever unless you complete it with an error yourself. So kill &lt;em&gt;and&lt;/em&gt; clean up:
&lt;/li&gt;
&lt;/ol&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;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Worker&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_isolate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;kill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;priority:&lt;/span&gt; &lt;span class="n"&gt;Isolate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;immediate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_responses&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&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;c&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_pending&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;completeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StateError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'worker cancelled'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;_pending&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clear&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;ol&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts&lt;/strong&gt; belong on the main side, not inside the worker. Wrap the future: &lt;code&gt;future.timeout(const Duration(seconds: 5))&lt;/code&gt;. When it fires, treat the worker as dead, kill it, and respawn a fresh one. Don't try to reuse a worker you just timed out on; you don't know what state it's in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The recurring lesson: an isolate is a resource, like a socket or a file handle. If you spawn it, you own closing it. I once shipped a bug where a screen spawned a worker per navigation and never killed the old ones, and after twenty navigations the app was carrying twenty warm isolates and their heaps. It didn't crash. It just got mysteriously slower and hungrier over time, which is worse, because nobody files a bug for "slightly worse the longer you use it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the break-even point before you reach for an isolate
&lt;/h2&gt;

&lt;p&gt;Isolates are not free, so treat "should this be on an isolate?" as a measurement, not a vibe. My checklist:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Time the synchronous work on the main isolate first.&lt;/strong&gt; Put a &lt;code&gt;Stopwatch&lt;/code&gt; around the exact call. If it's under a frame budget (roughly 8 to 16ms) and doesn't run in a tight loop, leave it on the main thread. You will not notice it and you'll skip all the plumbing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Estimate the copy cost of the input and the output.&lt;/strong&gt; Roughly: how many megabytes cross the boundary, in and out? Copying runs on the order of low-GB/s, so a 40 MB round trip is tens of milliseconds of pure copy that lands &lt;em&gt;synchronously&lt;/em&gt; on whichever isolate is receiving. If the copy cost approaches the compute cost, an isolate might not help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute the break-even.&lt;/strong&gt; An isolate is worth it when &lt;code&gt;compute_time &amp;gt; startup + copy_in + copy_out + result_handling&lt;/code&gt;. For my 900ms parse returning a tiny diff, easy win. For a 5ms transform on a 30 MB list returning a 30 MB list, an isolate can be a net &lt;em&gt;loss&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile in profile mode on a real device,&lt;/strong&gt; never debug mode and never the simulator. Debug-mode Dart is unoptimized and will lie to you about both the compute and the copy. I've seen a "clearly needs an isolate" call in debug turn out to be a few milliseconds in profile, not worth offloading at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A concrete before/after from a real screen: an image-tiling feature was dropping frames on scroll. The naive fix, one &lt;code&gt;Isolate.run&lt;/code&gt; per tile, was actually &lt;em&gt;slower&lt;/em&gt;: startup plus a full-bitmap copy each way dwarfed the tiling work itself. The fix that stuck was a persistent worker pool of 3 isolates, tiles handed over as &lt;code&gt;TransferableTypedData&lt;/code&gt;, results streamed back as bytes. Frame time on the main thread dropped dramatically, and the workers stayed warm across scrolls so we paid startup exactly once instead of per tile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Async is for waiting; isolates are for working.&lt;/strong&gt; If there's no hot synchronous CPU loop, an isolate is the wrong tool — you probably want an &lt;code&gt;await&lt;/code&gt; in the right place instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The boundary is the cost.&lt;/strong&gt; &lt;code&gt;SendPort&lt;/code&gt; deep-copies object graphs into the receiving isolate's heap. Cross it with as little as possible: return a small summary, not a giant tree, and push logic into the worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the right shape.&lt;/strong&gt; &lt;code&gt;Isolate.run&lt;/code&gt; for one-shot work, &lt;code&gt;compute&lt;/code&gt; for legacy Flutter code, a long-lived spawned isolate (or a pool sized to your physical cores) when startup, warm state, or throughput matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For big byte buffers, skip the copy.&lt;/strong&gt; &lt;code&gt;TransferableTypedData&lt;/code&gt; to move ownership, FFI/native memory to genuinely share — with the memory-safety caveats that implies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You own the lifecycle.&lt;/strong&gt; No free cancel, no free cleanup. Add timeouts on the main side, kill and respawn on failure, and never leak a warm worker across navigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web is different.&lt;/strong&gt; Isolates become Web Workers with their own limits; test the heavy path there and keep a chunked-yielding fallback for when parallelism isn't available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the break-even.&lt;/strong&gt; Time the compute, estimate the copy both ways, and profile on a real device in profile mode. Sometimes the honest answer is "keep it on the main thread."&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/getting-work-off-the-main-thread-isolates-without-the-hand-waving" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
    <item>
      <title>A Free, Practical CI/CD Pipeline for Flutter Web on Firebase Hosting</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sat, 05 Sep 2026 22:52:06 +0000</pubDate>
      <link>https://dev.to/devshakib/a-free-practical-cicd-pipeline-for-flutter-web-on-firebase-hosting-4k1e</link>
      <guid>https://dev.to/devshakib/a-free-practical-cicd-pipeline-for-flutter-web-on-firebase-hosting-4k1e</guid>
      <description>&lt;p&gt;For about a year, my "deploy process" for my portfolio site was a three-step ritual: &lt;code&gt;flutter build web&lt;/code&gt;, cross my fingers, &lt;code&gt;firebase deploy&lt;/code&gt;. It mostly worked, which is the dangerous part. Then one evening I shipped a build from a hotel in Abu Dhabi over Wi-Fi that dropped mid-upload, went to dinner, and left a half-deployed, blank-white-screen site live for about four hours. Nobody died. But a recruiter I'd been talking to opened the link during those four hours, and I got to explain why my personal site — the one thing on the internet I fully control — was broken.&lt;/p&gt;

&lt;p&gt;That was the push. I moved the whole thing into GitHub Actions the next weekend. The setup below is what I've run since: build once, cache hard, get a live preview URL on every pull request, and ship to production automatically on merge. The headline is that it costs &lt;strong&gt;$0/month&lt;/strong&gt; — GitHub's free Actions minutes and Firebase's free Hosting quota both cover it with room to spare — but the real win isn't the money. It's that I stopped being the single point of failure in my own deploy.&lt;/p&gt;

&lt;p&gt;This is a complete, copy-pasteable &lt;strong&gt;CI/CD pipeline for Flutter web on Firebase Hosting&lt;/strong&gt;: a GitHub Actions workflow, service-account authentication, per-PR preview channels, and dependency caching that keeps runs fast and free. Steal it, adapt it, ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why automate deployment for a one-person site
&lt;/h2&gt;

&lt;p&gt;I'll get the pushback out of the way, because I made this argument to myself for months: "It's a portfolio. It's one command. Why build a pipeline?"&lt;/p&gt;

&lt;p&gt;Because the command isn't the problem. The &lt;em&gt;human running the command under bad conditions&lt;/em&gt; is the problem. Manual deploys fail in a very specific pattern — they fail exactly when you're distracted, tired, on bad Wi-Fi, or rushing to fix something before a meeting. That's precisely when you'll skip &lt;code&gt;flutter analyze&lt;/code&gt;, deploy the wrong branch, forget to rebuild after your last edit, or upload a half-finished bundle. CI doesn't get tired. It runs the same steps in the same order every single time, on a clean machine, or it refuses to deploy at all.&lt;/p&gt;

&lt;p&gt;The second reason is subtler and, honestly, it changed how I work: &lt;strong&gt;a preview URL per pull request turns "trust me, it works" into "click this and see."&lt;/strong&gt; Once every change has a real, shareable, production-identical link before it merges, you review differently. You catch the layout that breaks at 375px. You notice the font that didn't load because of a CSP header. You send the link to a friend and they spot the typo you'd read past ten times. That feedback loop is worth more than the automation itself.&lt;/p&gt;

&lt;p&gt;There's a third reason I underestimated: &lt;strong&gt;continuous deployment shrinks your batch size.&lt;/strong&gt; When shipping is a scary manual ceremony, you batch changes up and deploy rarely, which makes every deploy riskier because more moved at once. When deployment is a merge, you ship tiny changes constantly, and a broken deploy is trivially bisectable to one small PR. Small, frequent, reversible — that's the whole DevOps thesis, and it applies to a portfolio just as much as to a product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the pipeline: two triggers, two behaviors
&lt;/h2&gt;

&lt;p&gt;That's the entire mental model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pull request&lt;/strong&gt; → build the app, deploy it to a &lt;strong&gt;temporary preview channel&lt;/strong&gt;, and post the preview URL back into the PR. Production is never touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push to &lt;code&gt;main&lt;/code&gt;&lt;/strong&gt; → build the app and deploy to the &lt;strong&gt;live&lt;/strong&gt; channel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That split is the whole point. &lt;strong&gt;Firebase Hosting preview channels&lt;/strong&gt; are a genuinely underrated feature: they're full, isolated deployments on a URL like &lt;code&gt;your-site--pr-42-abc123.web.app&lt;/code&gt;, serving the exact bundle that would go live, with zero effect on your real domain. You get a staging environment &lt;em&gt;per pull request&lt;/em&gt; without running or paying for staging infrastructure. No separate project, no separate config, no cost.&lt;/p&gt;

&lt;p&gt;The mechanics matter here. A preview channel is a snapshot of your entire Hosting output served from an auto-generated subdomain. It respects the same &lt;code&gt;firebase.json&lt;/code&gt; rewrites, headers, and clean-URL settings as production, which is why it catches config bugs a &lt;code&gt;localhost&lt;/code&gt; build never will. If your SPA routing depends on a &lt;code&gt;**&lt;/code&gt; rewrite to &lt;code&gt;/index.html&lt;/code&gt;, the preview channel exercises that exact rewrite. That's the difference between "works on my machine" and "works the way it'll actually be served."&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the Firebase credentials right, once
&lt;/h2&gt;

&lt;p&gt;The old advice — still all over the internet — is to bake a &lt;code&gt;FIREBASE_TOKEN&lt;/code&gt; into your secrets via &lt;code&gt;firebase login:ci&lt;/code&gt;. Don't. That token is long-lived, tied to your entire Google account, and grants access to &lt;em&gt;everything&lt;/em&gt; you can touch across &lt;em&gt;all&lt;/em&gt; your projects. If it leaks from a CI log, you're not rotating one project's key, you're rotating your whole identity. It's the deploy-automation equivalent of using your root password as an API key.&lt;/p&gt;

&lt;p&gt;Use a &lt;strong&gt;service account&lt;/strong&gt; scoped to the single project instead. This is the principle of least privilege applied to CI: a machine identity that can deploy Hosting to one project and do nothing else. The Firebase CLI wires the whole thing up for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;firebase init hosting:github
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It walks you through connecting the repo, provisions a service account with the minimum Hosting permissions, drops a starter workflow into &lt;code&gt;.github/workflows/&lt;/code&gt;, and stores the JSON key as a repo secret (named something like &lt;code&gt;FIREBASE_SERVICE_ACCOUNT_YOUR_PROJECT_ID&lt;/code&gt;). The private key never lands in your repo — it lives only in encrypted GitHub Actions secrets, which are write-only from the UI and masked in logs.&lt;/p&gt;

&lt;p&gt;If you'd rather do it by hand, go to the Google Cloud console, create a service account, give it the &lt;strong&gt;Firebase Hosting Admin&lt;/strong&gt; role (that's the least privilege that can actually deploy), download the JSON key, and paste the full JSON into a GitHub Actions secret. Three rules I hold myself to regardless of method:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One service account per project.&lt;/strong&gt; Blast radius stays small if a key ever leaks. A single shared account across projects is a single key that compromises all of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never &lt;code&gt;echo&lt;/code&gt; the secret in a workflow step.&lt;/strong&gt; GitHub masks known secrets in logs, but it can't mask what you've reformatted, trimmed, or base64-decoded into a new string. I've watched people debug a "malformed JSON" error by printing the key to the log — which permanently burns that key into a public build log. Don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate on a leak, not on a schedule you'll ignore.&lt;/strong&gt; If a key is ever exposed, delete that service-account key in Cloud console and generate a new one. Because it's scoped to one project with one role, rotation is a two-minute job, not an identity crisis.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The GitHub Actions workflow
&lt;/h2&gt;

&lt;p&gt;Here's the core of my &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt;. One job does the build; the deploy step branches on the trigger.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Web&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="c1"&gt;# Cancel an in-flight run if you push again to the same PR/branch.&lt;/span&gt;
&lt;span class="c1"&gt;# No point building commit N when N+1 already exists.&lt;/span&gt;
&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-${{ github.ref }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build_and_deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;subosito/flutter-action@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stable&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# caches the Flutter SDK across runs&lt;/span&gt;

      &lt;span class="c1"&gt;# Cache pub packages so we don't re-download the world every run.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&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;~/.pub-cache&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}&lt;/span&gt;
          &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pub-${{ runner.os }}-&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter pub get&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter analyze&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter test&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flutter build web --release&lt;/span&gt;

      &lt;span class="c1"&gt;# PRs get an ephemeral preview channel.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy preview&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event_name == 'pull_request'&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FirebaseExtended/action-hosting-deploy@v0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;repoToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;firebaseServiceAccount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.FIREBASE_SERVICE_ACCOUNT }}&lt;/span&gt;
          &lt;span class="na"&gt;projectId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-project-id&lt;/span&gt;
          &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;7d&lt;/span&gt;

      &lt;span class="c1"&gt;# main goes straight to production.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy live&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main' &amp;amp;&amp;amp; github.event_name == 'push'&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FirebaseExtended/action-hosting-deploy@v0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;repoToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;firebaseServiceAccount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.FIREBASE_SERVICE_ACCOUNT }}&lt;/span&gt;
          &lt;span class="na"&gt;projectId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-project-id&lt;/span&gt;
          &lt;span class="na"&gt;channelId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;live&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things I'm deliberate about here, learned the boring way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;flutter analyze&lt;/code&gt; and &lt;code&gt;flutter test&lt;/code&gt; run before the build, and a failure stops everything.&lt;/strong&gt; A deploy pipeline that skips static analysis is just an automated way to ship bugs faster. This is your quality gate — treat it like one. Steps run sequentially and a non-zero exit halts the job, so a failed analyze means the deploy step is never reached. That ordering is the safety property, not an accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;channelId: live&lt;/code&gt; is the magic word for production.&lt;/strong&gt; Omit it on PRs and the action auto-generates a throwaway channel named after the PR, then comments the URL on the pull request for you. That comment is the whole feature; you don't build it, you just get it. It uses the built-in &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; to post, which is why &lt;code&gt;repoToken&lt;/code&gt; is wired in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;expires: 7d&lt;/code&gt; garbage-collects preview channels.&lt;/strong&gt; Firebase caps active channels per site, and stale previews pile up until a deploy silently fails with a quota error weeks later. Let them self-destruct. Seven days is long enough that an open PR keeps a live link and short enough that abandoned branches clean themselves up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;concurrency&lt;/code&gt; cancels superseded runs.&lt;/strong&gt; Push three commits to a PR in quick succession and you don't want three builds racing to comment three different URLs. Build the latest, kill the rest. This also stops two &lt;code&gt;main&lt;/code&gt; pushes from deploying out of order.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; vs. the service account
&lt;/h3&gt;

&lt;p&gt;People conflate these two secrets, so to be explicit: &lt;code&gt;secrets.GITHUB_TOKEN&lt;/code&gt; is auto-injected by Actions and only grants permissions &lt;em&gt;within this repo&lt;/em&gt; (commenting on the PR). &lt;code&gt;secrets.FIREBASE_SERVICE_ACCOUNT&lt;/code&gt; is the one &lt;em&gt;you&lt;/em&gt; created and it grants deploy access to Firebase. Different scopes, different owners, both needed. The Firebase one should never appear in any &lt;code&gt;run:&lt;/code&gt; step — it's consumed only by the deploy action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pinning your Flutter version for reproducible builds
&lt;/h2&gt;

&lt;p&gt;An early version of this pipeline passed CI and then served a broken site, because &lt;code&gt;flutter test&lt;/code&gt; was green but I'd never actually built web with the pinned SDK. Pin your Flutter version explicitly once your app grows past a toy. &lt;code&gt;channel: stable&lt;/code&gt; is fine for a portfolio, but for anything a team touches, set a concrete version so a stable-channel bump doesn't quietly change your build under you:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;subosito/flutter-action@v2&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;flutter-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.24.3'&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reproducible builds mean the version that's green in CI is the exact version that ships. "It builds on my machine" and "it builds in CI" should never be allowed to diverge — and the way you guarantee that is by removing "whatever &lt;code&gt;stable&lt;/code&gt; happens to be today" from the equation. Bump the pinned version deliberately, in its own PR, so you can see the diff a new SDK causes in isolation rather than discovering it tangled into an unrelated change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching Flutter web CI: where the time actually goes
&lt;/h2&gt;

&lt;p&gt;On a cold Flutter web CI run, most of the wall-clock time isn't compiling your code — it's downloading the SDK and resolving pub packages. My uncached runs sat around six to seven minutes; cached, they're closer to two and a half. Two caches do almost all of that work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Flutter SDK.&lt;/strong&gt; &lt;code&gt;subosito/flutter-action@v2&lt;/code&gt; with &lt;code&gt;cache: true&lt;/code&gt; stores the SDK keyed on channel and version, so later runs skip the multi-hundred-megabyte download entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pub cache&lt;/strong&gt; (&lt;code&gt;~/.pub-cache&lt;/code&gt;), keyed on a hash of &lt;code&gt;pubspec.lock&lt;/code&gt;. As long as dependencies don't change, restores are near-instant. The &lt;code&gt;restore-keys&lt;/code&gt; fallback matters more than it looks: on a partial miss (same OS, changed lock), it reuses most packages instead of starting from zero, so adding one dependency doesn't cost you a full re-resolve.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Why key on &lt;code&gt;pubspec.lock&lt;/code&gt;, not &lt;code&gt;pubspec.yaml&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Exactness. &lt;code&gt;pubspec.yaml&lt;/code&gt; has version &lt;em&gt;ranges&lt;/em&gt;; the lockfile has the &lt;em&gt;resolved&lt;/em&gt; versions. You want the cache to invalidate precisely when the actual installed set changes, not when you tweak a caret constraint that resolves to the same thing. Key on the manifest and you'd needlessly rebuild the cache on cosmetic edits; key on the lockfile and the cache tracks reality. (This also means you should commit &lt;code&gt;pubspec.lock&lt;/code&gt; for an app — which you should be doing anyway for reproducibility.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache the inputs, rebuild the output
&lt;/h3&gt;

&lt;p&gt;I deliberately &lt;strong&gt;do not&lt;/strong&gt; cache the &lt;code&gt;build/&lt;/code&gt; directory, and this is a hill I'll defend. Flutter web builds aren't reliably incremental across fresh CI runners, and a stale build cache is a far nastier bug than a clean rebuild — it's the kind that ships old JavaScript alongside new HTML and takes you an hour of "but I fixed that" to diagnose. &lt;strong&gt;Cache the inputs, rebuild the output.&lt;/strong&gt; Determinism beats a saved minute every time. The caches above are safe precisely because they're inputs keyed on their own fingerprints; a cached build artifact has no such honest key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the pipeline genuinely free
&lt;/h2&gt;

&lt;p&gt;This is the part people get nervous about, so let me be concrete about exactly where the free lines sit and why a site like this never crosses them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions.&lt;/strong&gt; Public repos get effectively unlimited minutes. Private repos get a monthly allowance (2,000 minutes at the time I'm writing this). A cached Flutter web build is roughly three minutes, so even on a private repo you'd need to push north of 600 times a month to run dry. If you somehow do, flip the repo public or self-host a runner on a spare machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firebase Hosting.&lt;/strong&gt; The free Spark quota covers a storage allowance and a monthly data-transfer allowance that a portfolio, docs site, or small app sits comfortably under. Preview channels count against the &lt;em&gt;same&lt;/em&gt; quota, which is exactly why &lt;code&gt;expires&lt;/code&gt; matters — abandoned previews quietly eat your storage budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The service account.&lt;/strong&gt; Costs nothing. Deployments are just authenticated API calls; there's no per-deploy charge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One myth worth killing: &lt;strong&gt;the Blaze plan is not "the paid plan."&lt;/strong&gt; I run Blaze so I have headroom to add a Cloud Function or bump limits later, and my hosting bill is still exactly $0. Blaze means "you &lt;em&gt;can&lt;/em&gt; pay if you exceed the free tier," not "you pay." Static hosting almost never exceeds it. The free tier isn't a trial that expires — it's a permanent floor, and Blaze just removes the ceiling above it.&lt;/p&gt;

&lt;p&gt;If you want a hard guarantee, set a &lt;strong&gt;billing budget alert&lt;/strong&gt; at $1 in Google Cloud. You'll get an email long before anything real happens, and you can sleep. I've had that alert armed for years and it has never fired. For total peace of mind you can pair it with a low daily quota cap on any service you're nervous about — but for pure static Hosting, the budget alert alone is plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases and gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;A few things that bit me or that I've watched bite others:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PRs from forks can't see your secrets.&lt;/strong&gt; GitHub deliberately withholds repo secrets from &lt;code&gt;pull_request&lt;/code&gt; runs triggered by forks, so the preview deploy step will fail with an empty credential. For a solo repo where PRs come from your own branches, this never happens. If you accept outside contributions, gate the deploy step behind &lt;code&gt;if: github.event.pull_request.head.repo.full_name == github.repository&lt;/code&gt; so external PRs still run analyze/test but skip the deploy that can't authenticate anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;base href&lt;/code&gt; and hosting path.&lt;/strong&gt; If you serve from a subpath rather than the domain root, &lt;code&gt;flutter build web --base-href /subpath/&lt;/code&gt; has to match your Hosting rewrite, or you get a blank screen and 404s on your own assets. Preview channels will surface this before production does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A green &lt;code&gt;flutter test&lt;/code&gt; doesn't prove the web build compiles.&lt;/strong&gt; Tests run on the Dart VM; web is a separate compilation target (dart2js/Wasm). Keeping &lt;code&gt;flutter build web&lt;/code&gt; in the pipeline as a required step is what actually guarantees a deployable artifact — don't rely on tests as a proxy for "it builds."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploys are atomic, but propagation isn't instant.&lt;/strong&gt; Firebase swaps the release atomically, so you won't serve a half-uploaded bundle the way my hotel-Wi-Fi disaster did — that alone fixes the original problem. Just don't be surprised if a CDN edge serves the previous version for a few seconds after the workflow goes green.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd add next, and what I'd skip
&lt;/h2&gt;

&lt;p&gt;I keep this pipeline deliberately small, but two extensions have earned their place on larger projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A separate &lt;code&gt;analyze&lt;/code&gt;/&lt;code&gt;test&lt;/code&gt; job that runs on every PR independent of deploy.&lt;/strong&gt; For a solo site, folding them into one job is fine. For a team, split them so a red test is a clear, fast signal that isn't tangled up with hosting credentials or deploy permissions. It also lets you require the test job as a branch-protection check without granting the check access to deploy secrets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lighthouse CI on the preview URL.&lt;/strong&gt; Since every PR already produces a live, production-identical URL, you can point Lighthouse at it and fail the build on a performance or accessibility regression. Catching a bundle that ballooned 400KB &lt;em&gt;before&lt;/em&gt; merge is a lot cheaper than noticing it in production analytics months later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And what I'd skip: matrix builds across Flutter channels, elaborate multi-environment promotion flows, anything with the word "orchestration" in it. This is a web app going to one place. The pipeline should be boring, legible, and fit on one screen. Every clever thing you add is a thing that breaks at 11pm when you just wanted to fix a typo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automate to remove yourself as the point of failure&lt;/strong&gt;, not to save keystrokes. Manual deploys fail exactly when you're distracted; CI runs the same clean steps every time or refuses to ship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two triggers, two behaviors:&lt;/strong&gt; pull requests deploy to an ephemeral preview channel; pushes to &lt;code&gt;main&lt;/code&gt; deploy to the &lt;code&gt;live&lt;/code&gt; channel. That split is the entire design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a per-project service account, never &lt;code&gt;firebase login:ci&lt;/code&gt; tokens.&lt;/strong&gt; Least privilege, small blast radius, and never echo the secret into a log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache the Flutter SDK and &lt;code&gt;~/.pub-cache&lt;/code&gt; keyed on &lt;code&gt;pubspec.lock&lt;/code&gt;&lt;/strong&gt; — that's where the minutes go. Cache inputs, always rebuild &lt;code&gt;build/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin your Flutter version&lt;/strong&gt; for reproducible builds so a &lt;code&gt;stable&lt;/code&gt; bump can't silently change what ships.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;expires&lt;/code&gt; on preview channels&lt;/strong&gt; so stale previews don't quietly exhaust your Hosting quota.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blaze is not "the paid plan."&lt;/strong&gt; Static hosting stays $0; a $1 budget alert is your safety net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire up the PR-preview half first.&lt;/strong&gt; The live URL per change is the piece that actually changes how you work.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The whole setup is one YAML file, one service-account secret, and two caches. Once it's in place, my workflow collapses to: open a PR, click the preview URL the bot posts, glance at it on my phone, merge when it looks right, and watch production update itself. No local builds, no manual &lt;code&gt;firebase deploy&lt;/code&gt;, no shipping a blank white screen from a hotel lobby ever again.&lt;/p&gt;

&lt;p&gt;If you're deploying a Flutter web app by hand today, don't try to build all of this at once. Wire up the &lt;strong&gt;PR-preview half first&lt;/strong&gt; — even before you automate production. Seeing a real, live URL for every change you make is the single piece that actually changes how you work. The automated production deploy is just the reward you give yourself once you trust the preview.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/a-free-practical-cicd-pipeline-for-flutter-web-on-firebase-hosting" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>flutter</category>
      <category>firebase</category>
      <category>programming</category>
    </item>
    <item>
      <title>Dropping Below the Widget Layer: Writing a RenderObject From Scratch</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Sat, 05 Sep 2026 22:32:02 +0000</pubDate>
      <link>https://dev.to/devshakib/dropping-below-the-widget-layer-writing-a-renderobject-from-scratch-1o18</link>
      <guid>https://dev.to/devshakib/dropping-below-the-widget-layer-writing-a-renderobject-from-scratch-1o18</guid>
      <description>&lt;p&gt;A designer on my team once handed me a "simple" gallery: variable-height cards packed like a Pinterest board, tappable, with a staggered fade-in as they landed. I burned two days forcing it out of &lt;code&gt;Wrap&lt;/code&gt;, then &lt;code&gt;GridView&lt;/code&gt;, then a &lt;code&gt;CustomScrollView&lt;/code&gt; with a delegate I bent into a pretzel. Every version was wrong on the last row, janky on scroll, or quietly O(n²). Then I stopped fighting the widget tree and wrote about 120 lines of &lt;code&gt;RenderBox&lt;/code&gt;. It laid out correctly on the first try, painted in one pass, and I have not touched it since.&lt;/p&gt;

&lt;p&gt;That is the pattern I want to talk about. Widgets and &lt;code&gt;CustomPaint&lt;/code&gt; cover the easy 90% of Flutter's rendering story, and most of us live there for entire careers without a scratch. But the real leverage — the stuff the built-ins genuinely cannot express — lives one layer down, in the render tree. This post is about &lt;strong&gt;when to write a custom RenderObject in Flutter&lt;/strong&gt;, and exactly how to do it, without breaking the framework's layout and painting contracts on the way down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three trees: widget, element, and render
&lt;/h2&gt;

&lt;p&gt;Flutter runs three trees in parallel, and understanding the split is the whole game. If you have ever wondered what actually happens between calling &lt;code&gt;build()&lt;/code&gt; and seeing pixels, this is it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;widget tree&lt;/strong&gt; is your configuration. Widgets are immutable, cheap, thrown away and rebuilt constantly. &lt;code&gt;Padding&lt;/code&gt;, &lt;code&gt;Row&lt;/code&gt;, your &lt;code&gt;StatelessWidget&lt;/code&gt; — all just descriptions, closer to a blueprint than a building.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;element tree&lt;/strong&gt; is the bookkeeping layer. &lt;code&gt;Element&lt;/code&gt; instances are long-lived, hold state, decide what to reuse across rebuilds, and connect widgets to their render objects. You rarely touch these directly, but &lt;code&gt;BuildContext&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; an element under the hood.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;render tree&lt;/strong&gt; is where the actual work happens: measuring, positioning, painting, and hit-testing. This is &lt;code&gt;RenderObject&lt;/code&gt; and its subclasses, chiefly &lt;code&gt;RenderBox&lt;/code&gt; (the 2D box protocol) and &lt;code&gt;RenderSliver&lt;/code&gt; (the scrollable viewport protocol).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most developers never leave the widget tree because they don't have to. &lt;code&gt;Row&lt;/code&gt; is a widget wrapping a &lt;code&gt;RenderFlex&lt;/code&gt;. &lt;code&gt;Padding&lt;/code&gt; wraps a &lt;code&gt;RenderPadding&lt;/code&gt;. &lt;code&gt;Opacity&lt;/code&gt; wraps a &lt;code&gt;RenderOpacity&lt;/code&gt;. Every layout primitive you use is a thin widget over a render object someone at Google already wrote. You compose those primitives and the framework does the rest — that is composition doing its job.&lt;/p&gt;

&lt;p&gt;The reason to go lower is that composition has a ceiling. When your layout depends on measuring children against each other, when you need real hit-testing on non-rectangular shapes, when intrinsic sizes matter, or when you are redoing the same expensive layout math every frame because the widget layer left you no cheaper path — that is the render tree calling. Not before. I have watched engineers reach for a custom render object the way some people reach for a rewrite: as a way to feel productive while avoiding the boring composition that would have shipped yesterday. Resist that. A custom &lt;code&gt;RenderObject&lt;/code&gt; is a scalpel, not a hammer.&lt;/p&gt;

&lt;h2&gt;
  
  
  When CustomPaint stops being enough
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CustomPaint&lt;/code&gt; is the escape hatch everyone reaches for first, and for good reason. Custom drawing, a &lt;code&gt;CustomPainter&lt;/code&gt;, done. I use it constantly for charts, progress rings, signature pads, and decorative flourishes. If your problem is "draw pixels inside a box whose size is already decided," &lt;code&gt;CustomPaint&lt;/code&gt; is the correct tool and you should not write a &lt;code&gt;RenderObject&lt;/code&gt;. Don't over-engineer a solved problem.&lt;/p&gt;

&lt;p&gt;It stops being enough the moment any of these show up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Layout that depends on content.&lt;/strong&gt; A &lt;code&gt;CustomPainter&lt;/code&gt; is handed a &lt;code&gt;Size&lt;/code&gt; and paints inside it. It cannot say "I want to be exactly as tall as the tallest thing I contain." If your widget's size is a function of what's inside it, you need &lt;code&gt;performLayout&lt;/code&gt;, which painters don't have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real children.&lt;/strong&gt; &lt;code&gt;CustomPaint&lt;/code&gt; can take a single child, but that is the ceiling. If you need to lay out and paint an arbitrary list of child widgets — position them, size them, let them handle their own gestures — a painter can't hold them. Painters draw; they don't parent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hit-testing beyond the bounding box.&lt;/strong&gt; A &lt;code&gt;GestureDetector&lt;/code&gt; around a &lt;code&gt;CustomPaint&lt;/code&gt; gives you a rectangle. If you drew a hexagonal button or a pie chart and taps in the dead corners shouldn't count, the painter has no say in hit-testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intrinsic sizes.&lt;/strong&gt; Ask a &lt;code&gt;CustomPaint&lt;/code&gt; "how wide do you want to be given unlimited height?" and it shrugs. &lt;code&gt;RenderBox&lt;/code&gt; exposes &lt;code&gt;computeMinIntrinsicWidth&lt;/code&gt; and its siblings, which &lt;code&gt;IntrinsicHeight&lt;/code&gt;, &lt;code&gt;Table&lt;/code&gt;, and text baselines actually query.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When two or more of those are true at once, stop stacking widgets. Write the render object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a RenderBox: the constraints contract
&lt;/h2&gt;

&lt;p&gt;The heart of Flutter layout is one sentence, and it's worth tattooing somewhere: &lt;strong&gt;constraints go down, sizes go up, and the parent sets position.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A parent hands each child a &lt;code&gt;BoxConstraints&lt;/code&gt; — min/max width and min/max height. The child must pick a size that satisfies those constraints and report it back. The parent then decides &lt;em&gt;where&lt;/em&gt; to place the child. A child never picks its own position, and it never sees its siblings. That decoupling is exactly what makes Flutter layout single-pass and fast: each box is visited once, top-down for constraints and bottom-up for sizes.&lt;/p&gt;

&lt;p&gt;A minimal &lt;code&gt;RenderBox&lt;/code&gt; implements a handful of methods. The load-bearing one is &lt;code&gt;performLayout&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;class&lt;/span&gt; &lt;span class="nc"&gt;RenderSquare&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;RenderBox&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;performLayout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Read the constraints the parent gave us, pick a size, report it.&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;constrainWidth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;paint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PaintingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt; &lt;span class="n"&gt;offset&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;paint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Paint&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xFF2962FF&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;canvas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;drawRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;paint&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 rules trip up everyone the first time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You &lt;strong&gt;must&lt;/strong&gt; set &lt;code&gt;size&lt;/code&gt; inside &lt;code&gt;performLayout&lt;/code&gt;, and it &lt;strong&gt;must&lt;/strong&gt; satisfy &lt;code&gt;constraints&lt;/code&gt;. Return a size outside the given min/max and the framework asserts in debug and misbehaves in release. &lt;code&gt;constraints.constrain(desiredSize)&lt;/code&gt; clamps for you, so lean on it.&lt;/li&gt;
&lt;li&gt;You must not read &lt;code&gt;size&lt;/code&gt; from anywhere except during and after layout. Reading &lt;code&gt;size&lt;/code&gt; in your own &lt;code&gt;performLayout&lt;/code&gt; before you've assigned it is a classic own-goal, and the assert message that catches it (&lt;code&gt;RenderBox was not laid out&lt;/code&gt;) is one you will learn to recognize.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your box takes children, you don't subclass &lt;code&gt;RenderBox&lt;/code&gt; raw — you mix in &lt;code&gt;ContainerRenderObjectMixin&lt;/code&gt; and &lt;code&gt;RenderBoxContainerDefaultsMixin&lt;/code&gt;, and you attach a &lt;code&gt;ParentData&lt;/code&gt; object to each child to stash its offset. The parent data is where "the parent sets position" physically lives.&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;FlowParentData&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;ContainerBoxParentData&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RenderBox&lt;/span&gt;&lt;span class="p"&gt;&amp;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;ContainerBoxParentData&lt;/code&gt; already carries an &lt;code&gt;offset&lt;/code&gt; field. That offset is the child's position relative to the parent, written by the parent during layout and read back during paint and hit-test. This is the spine of everything that follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a real one: a custom masonry layout
&lt;/h2&gt;

&lt;p&gt;Let me build the thing that sent me down here in the first place — a &lt;strong&gt;masonry / column-flow layout&lt;/strong&gt;. The rule: N columns of fixed width, and each child drops into whichever column is currently shortest. The built-ins can't express this because &lt;code&gt;GridView&lt;/code&gt; assumes uniform cell heights and &lt;code&gt;Wrap&lt;/code&gt; flows in rows, not balanced columns.&lt;/p&gt;

&lt;p&gt;Here's the render object. It's the whole point of the post, so read it slowly.&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;RenderMasonry&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;RenderBox&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt;
        &lt;span class="n"&gt;ContainerRenderObjectMixin&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RenderBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FlowParentData&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
        &lt;span class="n"&gt;RenderBoxContainerDefaultsMixin&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RenderBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FlowParentData&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;RenderMasonry&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;int&lt;/span&gt; &lt;span class="n"&gt;columns&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;double&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gap&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;_columns&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;set&lt;/span&gt; &lt;span class="n"&gt;columns&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;value&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;_columns&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&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;_columns&lt;/span&gt; &lt;span class="o"&gt;=&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;markNeedsLayout&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;_gap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;set&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;value&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;_gap&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&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;_gap&lt;/span&gt; &lt;span class="o"&gt;=&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;markNeedsLayout&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;setupParentData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RenderBox&lt;/span&gt; &lt;span class="n"&gt;child&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;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parentData&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="n"&gt;FlowParentData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parentData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FlowParentData&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="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;performLayout&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="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;totalGap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_gap&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_columns&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="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;columnWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maxWidth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;totalGap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;_columns&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Track the running height of each column.&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;columnHeights&lt;/span&gt; &lt;span class="o"&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;double&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;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&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;childConstraints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BoxConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;minWidth:&lt;/span&gt; &lt;span class="n"&gt;columnWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;maxWidth:&lt;/span&gt; &lt;span class="n"&gt;columnWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;RenderBox&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firstChild&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&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;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;childConstraints&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;parentUsesSize:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// Find the shortest column.&lt;/span&gt;
      &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;target&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="kt"&gt;int&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;_columns&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;columnHeights&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;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;columnHeights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;target&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="p"&gt;}&lt;/span&gt;

      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;columnWidth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;_gap&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;double&lt;/span&gt; &lt;span class="n"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;columnHeights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parentData&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;FlowParentData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="n"&gt;columnHeights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;height&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;_gap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;childAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&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="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;tallest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;columnHeights&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&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;a&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;constrain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tallest&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="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;paint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PaintingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;defaultPaint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;hitTestChildren&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BoxHitTestResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt; &lt;span class="n"&gt;position&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;defaultHitTestChildren&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;position:&lt;/span&gt; &lt;span class="n"&gt;position&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;A few things worth pointing at, because they're the difference between "works" and "works and doesn't fight the framework":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;child.layout(childConstraints, parentUsesSize: true)&lt;/code&gt;&lt;/strong&gt; is how the parent measures a child. &lt;code&gt;parentUsesSize: true&lt;/code&gt; tells the framework that my layout depends on the child's resulting size, so if that child relays out, I need to relay out too. Get this flag wrong and you get stale layouts that only fix themselves on the next unrelated rebuild — a genuinely nasty bug to chase, because it looks intermittent.&lt;/li&gt;
&lt;li&gt;I set &lt;strong&gt;tight width constraints&lt;/strong&gt; (&lt;code&gt;minWidth == maxWidth&lt;/code&gt;) so each card fills its column exactly, but I leave height unconstrained, so cards size themselves to their content. That asymmetry is the whole "masonry" effect in one line.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;defaultPaint&lt;/code&gt; and &lt;code&gt;defaultHitTestChildren&lt;/code&gt; come from the mixins and do the right thing: they walk children, apply each one's parent-data offset, and paint/hit-test in order. Don't reinvent them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The widget wrapper
&lt;/h3&gt;

&lt;p&gt;The widget is boring on purpose — it's a &lt;code&gt;MultiChildRenderObjectWidget&lt;/code&gt; that creates and updates the render object:&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;Masonry&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;MultiChildRenderObjectWidget&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;Masonry&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;key&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;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&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;gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&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;super&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;children&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;columns&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;double&lt;/span&gt; &lt;span class="n"&gt;gap&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;RenderMasonry&lt;/span&gt; &lt;span class="n"&gt;createRenderObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&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;RenderMasonry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;columns:&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;gap:&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nd"&gt;@override&lt;/span&gt;
  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;updateRenderObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RenderMasonry&lt;/span&gt; &lt;span class="n"&gt;ro&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ro&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gap&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;Note that the setters call &lt;code&gt;markNeedsLayout()&lt;/code&gt;. &lt;code&gt;updateRenderObject&lt;/code&gt; fires on every rebuild, but the equality guards inside the setters make sure we only actually relay out when a value truly changed. That's the render tree's version of a rebuild optimization, and it matters more here because layout is expensive. Skipping those guards is the most common performance regression I see in home-grown render objects — every parent rebuild silently marks the whole subtree dirty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Painting with the layer system and PaintingContext
&lt;/h2&gt;

&lt;p&gt;Naive painting means drawing straight onto &lt;code&gt;context.canvas&lt;/code&gt;. That's fine for opaque shapes. But the moment you need clipping, opacity, transforms, or repaint isolation, you should go through &lt;code&gt;PaintingContext&lt;/code&gt;, because that's what talks to Flutter's &lt;strong&gt;layer&lt;/strong&gt; system and, ultimately, the compositor thread.&lt;/p&gt;

&lt;p&gt;The distinction that matters in practice: &lt;code&gt;context.canvas&lt;/code&gt; draws into the &lt;em&gt;current&lt;/em&gt; layer, while methods like &lt;code&gt;context.pushClipRect&lt;/code&gt;, &lt;code&gt;context.pushOpacity&lt;/code&gt;, and &lt;code&gt;context.pushLayer&lt;/code&gt; create &lt;em&gt;new&lt;/em&gt; composited layers the GPU can handle cheaply. If you want a subtree to repaint independently of its parent, you push it into its own layer. This is exactly why &lt;code&gt;RepaintBoundary&lt;/code&gt; exists — it's a render object that forces a fresh layer so a repaint on one side doesn't smear across the whole screen and re-rasterize everything.&lt;/p&gt;

&lt;p&gt;If I wanted my masonry cards clipped to rounded corners without wrapping each one in a &lt;code&gt;ClipRRect&lt;/code&gt; widget, I'd do it in &lt;code&gt;paint&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="nd"&gt;@override&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;paint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PaintingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;RenderBox&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firstChild&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&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;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;childOffset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parentData&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;FlowParentData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pushClipRRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;needsCompositing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;childOffset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;Offset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zero&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;RRect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromRectAndRadius&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Offset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zero&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&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;Radius&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;circular&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&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;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;off&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;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;paintChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;childAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&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 things I learned the slow way here. First, &lt;code&gt;needsCompositing&lt;/code&gt; is a real signal — pass it through, don't hardcode &lt;code&gt;true&lt;/code&gt;, because forcing compositing everywhere allocates layers you didn't need and quietly costs you memory and frame time. Second, &lt;code&gt;context.paintChild&lt;/code&gt; is not optional sugar. It's how the framework knows whether a child needs its own layer and wires the tree together correctly. Calling &lt;code&gt;child.paint&lt;/code&gt; directly bypasses that machinery and will bite you when the child is itself a &lt;code&gt;RepaintBoundary&lt;/code&gt; or needs compositing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hit-testing, gestures, and routing taps to children
&lt;/h2&gt;

&lt;p&gt;A render object that lays out and paints but doesn't hit-test is a picture, not a widget. Taps have to find their way to your children, and Flutter walks the render tree in reverse paint order to route them.&lt;/p&gt;

&lt;p&gt;For the common case, the mixin default is correct, but it helps to see the shape of the contract you're implementing:&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;bool&lt;/span&gt; &lt;span class="nf"&gt;hitTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BoxHitTestResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt; &lt;span class="n"&gt;position&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;size&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&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;hitTestChildren&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;position:&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;hitTestSelf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;))&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;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BoxHitTestEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;position&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;true&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="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;The contract: return &lt;code&gt;true&lt;/code&gt; if the point hit you or a descendant, and if so, add yourself to the &lt;code&gt;result&lt;/code&gt;. &lt;code&gt;defaultHitTestChildren&lt;/code&gt; handles the child walk, translating the position by each child's parent-data offset — the same offset you wrote during layout, now read in reverse. This is why getting the offset right in &lt;code&gt;performLayout&lt;/code&gt; pays off three times: &lt;strong&gt;layout, paint, and hit-test all lean on it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For non-rectangular shapes, override &lt;code&gt;hitTestSelf&lt;/code&gt; and do the geometry yourself. A pie-chart segment, for instance, would test the angle and radius of &lt;code&gt;position&lt;/code&gt; against the wedge before accepting the tap — that is the whole reason you dropped below &lt;code&gt;GestureDetector&lt;/code&gt;'s rectangle in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Semantics: don't ship a render object screen readers can't see
&lt;/h2&gt;

&lt;p&gt;Don't skip &lt;strong&gt;semantics&lt;/strong&gt;. A custom render object is invisible to screen readers unless you describe it. For a container that just positions children — like the masonry above — the children carry their own semantics and you get accessibility for free. But if you draw interactive things yourself, implement &lt;code&gt;describeSemanticsConfiguration&lt;/code&gt; and, where the visual order differs from child order, override &lt;code&gt;visitChildrenForSemantics&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On one client project we shipped a custom chart that was completely opaque to accessibility tooling until we added semantics — a real bug, not a nice-to-have, and in some markets a legal compliance requirement. Treat semantics as part of "done," not a stretch goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two framework invariants you must not break
&lt;/h2&gt;

&lt;p&gt;The framework enforces a strict separation of phases, and it does not forgive violations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never lay out during paint, and never paint during layout.&lt;/strong&gt; The phases are separate for a reason — the compositor and the layout pipeline run on different assumptions. If you find yourself calling &lt;code&gt;child.layout&lt;/code&gt; inside &lt;code&gt;paint&lt;/code&gt;, you've made a mistake the framework can't recover from cleanly, and you'll usually see it as an assertion or a corrupted frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;markNeedsLayout&lt;/code&gt; vs &lt;code&gt;markNeedsPaint&lt;/code&gt;.&lt;/strong&gt; If only appearance changed (a color, a shadow), call &lt;code&gt;markNeedsPaint&lt;/code&gt; — cheap, paint-only. If size or position could change, call &lt;code&gt;markNeedsLayout&lt;/code&gt;, which also implies a repaint. Calling &lt;code&gt;markNeedsLayout&lt;/code&gt; for a color change works but relayouts the world for no reason. Calling &lt;code&gt;markNeedsPaint&lt;/code&gt; when geometry actually changed gives you a stale, broken layout. Pick correctly every time; this single decision is most of what separates a smooth render object from a janky one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  RenderBox vs. Sliver: know which door you need
&lt;/h2&gt;

&lt;p&gt;Here's the trap I fell into on that first gallery: I reached for a custom &lt;code&gt;RenderBox&lt;/code&gt; when I actually needed a custom &lt;strong&gt;sliver&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The distinction is about scrolling. A &lt;code&gt;RenderBox&lt;/code&gt; works in the 2D box protocol — it's laid out once against &lt;code&gt;BoxConstraints&lt;/code&gt; and it's fully realized in memory. A &lt;code&gt;RenderSliver&lt;/code&gt; works in the &lt;em&gt;viewport&lt;/em&gt; protocol: it's laid out against &lt;code&gt;SliverConstraints&lt;/code&gt; that tell it how much has scrolled past, how much viewport is left, and in which direction. Slivers can lay out &lt;strong&gt;lazily&lt;/strong&gt; — only building the children currently near the visible region — which is why an infinite &lt;code&gt;ListView&lt;/code&gt; doesn't build a million widgets and blow your memory budget.&lt;/p&gt;

&lt;p&gt;Rule of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your custom layout lives &lt;strong&gt;inside&lt;/strong&gt; a scroll view and could contain thousands of items, you probably want a custom sliver (&lt;code&gt;RenderSliverMultiBoxAdaptor&lt;/code&gt; territory, driven by a &lt;code&gt;SliverChildBuilderDelegate&lt;/code&gt;) so off-screen children aren't built.&lt;/li&gt;
&lt;li&gt;If your layout is a &lt;strong&gt;bounded, fully-visible&lt;/strong&gt; region — a card, a widget, a fixed gallery of a few dozen items — a &lt;code&gt;RenderBox&lt;/code&gt; is right and far simpler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My masonry above is a &lt;code&gt;RenderBox&lt;/code&gt;. That's fine for a screen's worth of cards. If it needed to scroll through ten thousand images without building them all, I'd have to rewrite it as a sliver adaptor that only lays out the visible window — and lazy masonry is genuinely one of the harder objects to write, because column heights depend on children you haven't measured yet. The honest advice: don't reach for it until a &lt;code&gt;RenderBox&lt;/code&gt; version has actually shown you a performance problem. Premature slivers are their own tar pit.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for shipping a RenderObject you won't regret
&lt;/h2&gt;

&lt;p&gt;Before you consider it done, walk this list:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every setter guards for equality and calls the correct &lt;code&gt;markNeedsLayout&lt;/code&gt; or &lt;code&gt;markNeedsPaint&lt;/code&gt;.&lt;/strong&gt; Layout for geometry, paint for appearance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;performLayout&lt;/code&gt; sets &lt;code&gt;size&lt;/code&gt;, and the size satisfies &lt;code&gt;constraints&lt;/code&gt;.&lt;/strong&gt; Run in debug; the asserts are your friend here, not your enemy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Children are laid out with the right &lt;code&gt;parentUsesSize&lt;/code&gt; flag.&lt;/strong&gt; &lt;code&gt;true&lt;/code&gt; if your size or child positions depend on their sizes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paint uses &lt;code&gt;PaintingContext&lt;/code&gt; helpers and &lt;code&gt;paintChild&lt;/code&gt;&lt;/strong&gt;, passes &lt;code&gt;needsCompositing&lt;/code&gt; honestly, and never touches &lt;code&gt;child.paint&lt;/code&gt; directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hit-testing routes to children&lt;/strong&gt; via the parent-data offsets, and non-rectangular shapes override &lt;code&gt;hitTestSelf&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantics are described&lt;/strong&gt; for anything interactive you draw yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intrinsics are implemented&lt;/strong&gt; (&lt;code&gt;computeMinIntrinsicWidth&lt;/code&gt; and friends) if a parent like &lt;code&gt;IntrinsicHeight&lt;/code&gt; or &lt;code&gt;Table&lt;/code&gt; might ever wrap you; otherwise they throw at runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You wrote a golden test.&lt;/strong&gt; Render objects are close to pure functions of constraints and children — they're unusually easy to pin down with &lt;code&gt;matchesGoldenFile&lt;/code&gt;, and layout regressions are otherwise invisible until a designer notices in review.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stay in widgets&lt;/strong&gt; for anything you can compose, and use &lt;code&gt;CustomPaint&lt;/code&gt; for pure drawing inside a size that is already decided.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop to &lt;code&gt;RenderBox&lt;/code&gt;&lt;/strong&gt; only when layout depends on content, when you need to parent and position arbitrary children, when hit-testing must be non-rectangular, or when intrinsic sizes matter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect the contract:&lt;/strong&gt; constraints go down, sizes come up, the parent sets position. Set &lt;code&gt;size&lt;/code&gt;, satisfy &lt;code&gt;constraints&lt;/code&gt;, and never lay out in paint or paint in layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the right dirty flag:&lt;/strong&gt; &lt;code&gt;markNeedsLayout&lt;/code&gt; for geometry, &lt;code&gt;markNeedsPaint&lt;/code&gt; for appearance — and guard your setters so rebuilds don't relayout the world.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the framework's helpers&lt;/strong&gt; — &lt;code&gt;defaultPaint&lt;/code&gt;, &lt;code&gt;defaultHitTestChildren&lt;/code&gt;, &lt;code&gt;paintChild&lt;/code&gt;, &lt;code&gt;needsCompositing&lt;/code&gt; — instead of reinventing them badly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for a sliver only&lt;/strong&gt; when the layout scrolls and could hold thousands of lazily-built children; otherwise a &lt;code&gt;RenderBox&lt;/code&gt; is simpler and correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship it accessible and tested:&lt;/strong&gt; describe semantics for interactive drawing, and lock the layout down with a golden test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dropping below the widget layer is not a flex, it's a tool with a narrow, sharp use case. The masonry object that started all this is still running in production, unchanged, doing in 120 lines what three layers of widget hacks couldn't. That's the trade: a steeper wall to climb, and a much shorter one to maintain once you're over it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/dropping-below-the-widget-layer-writing-a-renderobject-from-scratch" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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