<?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: Yusuf İhsan Görgel</title>
    <description>The latest articles on DEV Community by Yusuf İhsan Görgel (@yusufihsangorgel).</description>
    <link>https://dev.to/yusufihsangorgel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3859744%2Fa9c730c8-0d59-4e68-af52-069001dbe5a7.jpeg</url>
      <title>DEV Community: Yusuf İhsan Görgel</title>
      <link>https://dev.to/yusufihsangorgel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yusufihsangorgel"/>
    <language>en</language>
    <item>
      <title>A Redis Task Queue in Dart: Weighted Polling, Dead Letters, and One Nasty Empty-Poll Bug</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Wed, 08 Jul 2026 12:39:12 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/a-redis-task-queue-in-dart-weighted-polling-dead-letters-and-one-nasty-empty-poll-bug-53ap</link>
      <guid>https://dev.to/yusufihsangorgel/a-redis-task-queue-in-dart-weighted-polling-dead-letters-and-one-nasty-empty-poll-bug-53ap</guid>
      <description>&lt;p&gt;I recently built a small Redis-backed task queue for server-side Dart, because the ecosystem didn't have a maintained one. In a &lt;a href="https://yusufihsangorgel.github.io/2026/07/08/asynq-for-dart.html" rel="noopener noreferrer"&gt;companion post&lt;/a&gt; I wrote about &lt;em&gt;why&lt;/em&gt; — the gap on pub.dev, what I ported from Go's Asynq, and what I deliberately left out of a first version.&lt;/p&gt;

&lt;p&gt;This post is the other half: how it actually works. It's about 360 lines of Dart, and the interesting parts are all in how three Redis commands get used. If you've ever wondered what's under a task queue, this is a small enough one to hold in your head.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    P["producer"] --&amp;gt;|LPUSH| Q["queue list&amp;lt;br/&amp;gt;rtq:pending"]
    Q --&amp;gt;|"weighted BRPOP"| W["worker"]
    W --&amp;gt;|success| D["done"]
    W --&amp;gt;|throw| Rt["retry"]
    Rt --&amp;gt;|"attempt &amp;lt; max"| Q
    Rt --&amp;gt;|exhausted| DL[("dead-letter&amp;lt;br/&amp;gt;rtq:dead")]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The data model: a Task the user sees, an Envelope Redis stores
&lt;/h2&gt;

&lt;p&gt;The user-facing unit is a &lt;code&gt;Task&lt;/code&gt; — a type string plus a JSON payload:&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;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Task&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;type&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;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                     &lt;span class="c1"&gt;// routes to a handler&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// whatever the handler needs&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The split is deliberate: the code that enqueues and the code that processes only agree on a string and a shape. They don't share a class hierarchy, and they can live in different processes.&lt;/p&gt;

&lt;p&gt;But the &lt;code&gt;Task&lt;/code&gt; isn't what gets stored. Redis stores an &lt;code&gt;Envelope&lt;/code&gt; — the task plus the metadata the queue needs to run and retry it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Envelope&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;queue&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;maxRetries&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;attempt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// incremented on each retry&lt;/span&gt;
  &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;encode&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;jsonEncode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* id, task, queue, max_retries, attempt */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;attempt&lt;/code&gt; is the only mutable field, and it's the whole retry state machine: bump it each failure, give up when it passes &lt;code&gt;maxRetries&lt;/code&gt;. Keeping run-state on the envelope (not in a separate Redis key) means a job is one self-contained string — nothing to clean up, nothing to get out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enqueue is one LPUSH
&lt;/h2&gt;

&lt;p&gt;Producing a task is deliberately trivial — a single write:&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;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&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;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'default'&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;maxRetries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_newId&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;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Envelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;task:&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;queue:&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;maxRetries:&lt;/span&gt; &lt;span class="n"&gt;maxRetries&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;_command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send_object&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;'LPUSH'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_keys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LPUSH&lt;/code&gt; pushes the encoded envelope onto the head of a Redis list. Because the worker pops from the &lt;em&gt;tail&lt;/em&gt; (more on that next), head-push + tail-pop gives you FIFO ordering for free — first in, first out, which is what you want for a job queue. Since it's a single command, you keep one client alive for the app's lifetime and reuse it; there's nothing to pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The worker: weighted polling with one BRPOP
&lt;/h2&gt;

&lt;p&gt;This is the part with actual design in it. The worker drains &lt;em&gt;multiple&lt;/em&gt; queues, and it drains them by weight so a flood of low-priority work can't starve the important stuff.&lt;/p&gt;

&lt;p&gt;The trick is almost embarrassingly simple. You expand the weights into a repeated list of keys:&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;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_weightedOrder&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;order&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;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;[];&lt;/span&gt;
  &lt;span class="n"&gt;_queues&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&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;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;weight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;order&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;queue&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;order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// {'critical':6,'default':3,'low':1} -&amp;gt; [crit×6, default×3, low×1]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you hand that whole list to a single &lt;code&gt;BRPOP&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_keys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send_object&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;'BRPOP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;List&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;BRPOP key1 key2 ... timeout&lt;/code&gt; is a &lt;em&gt;blocking&lt;/em&gt; right-pop across many keys at once. It scans the keys left to right and returns from the first one that has an item. Because &lt;code&gt;critical&lt;/code&gt; appears six times near the front of the list, it gets first refusal six times over before &lt;code&gt;low&lt;/code&gt; gets a look — that's the weighting, and Redis does it atomically. No priority field, no sorting, no second data structure. The blocking part also means an idle worker sleeps in Redis instead of hot-looping, so an empty queue costs you nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: an empty poll doesn't return null
&lt;/h2&gt;

&lt;p&gt;Here's the one that cost me real time, and it's the reason I don't trust a queue I haven't run idle.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BRPOP&lt;/code&gt; with a timeout has two outcomes. A hit returns &lt;code&gt;[key, value]&lt;/code&gt;. A timeout — no job for a full second — returns &lt;em&gt;nothing&lt;/em&gt;. I assumed "nothing" meant &lt;code&gt;null&lt;/code&gt;. It doesn't:&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;// WRONG: this branch never runs&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;res&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Envelope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// boom, every idle second&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Redis client hands back &lt;code&gt;[null]&lt;/code&gt; on timeout — a one-element list whose single element is null, not a bare &lt;code&gt;null&lt;/code&gt;. So &lt;code&gt;res == null&lt;/code&gt; was never true, and every idle second the worker tried to read &lt;code&gt;res[1]&lt;/code&gt; off a list of length one and threw. An idle worker — the normal state — was crashing on a schedule.&lt;/p&gt;

&lt;p&gt;The fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;first&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Envelope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I only found it because a test spins up a real worker against a real Redis and lets it sit with an empty queue. Unit tests with a mocked client would have sailed right past it — the mock would have returned whatever I &lt;em&gt;thought&lt;/em&gt; BRPOP returns, which was the wrong thing. The boring states (idle, empty, timed-out) are where the real bugs live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry or dead-letter
&lt;/h2&gt;

&lt;p&gt;Processing routes by type and treats &lt;em&gt;any&lt;/em&gt; throw as a failure — including "no handler registered," so a wiring mistake surfaces loudly instead of silently dropping jobs:&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;_process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Envelope&lt;/span&gt; &lt;span class="n"&gt;env&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;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_handlers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&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;'no handler for "&lt;/span&gt;&lt;span class="si"&gt;${env.task.type}&lt;/span&gt;&lt;span class="s"&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;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;task&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_retryOrDeadLetter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the retry path is where a job's life ends one of two ways:&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;_retryOrDeadLetter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Envelope&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maxRetries&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;_command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send_object&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;'LPUSH'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_keys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;deadLetter&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// out of tries -&amp;gt; dead-letter list&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send_object&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;'LPUSH'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_keys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt; &lt;span class="c1"&gt;// back on its queue&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two honest caveats I put in the README rather than hide:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The re-enqueue is immediate&lt;/strong&gt; — no backoff. A production version delays it with a sorted set scored by a "run-after" timestamp. I left that out of &lt;code&gt;0.1.0&lt;/code&gt; to keep the core path readable; it's the first thing on the list to add.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dead-letter list, not silent death.&lt;/strong&gt; Once retries are exhausted the envelope goes to &lt;code&gt;&amp;lt;prefix&amp;gt;:dead&lt;/code&gt; so you can inspect what failed, instead of the job vanishing or looping forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  That's the whole thing
&lt;/h2&gt;

&lt;p&gt;Three commands — &lt;code&gt;LPUSH&lt;/code&gt; to produce, &lt;code&gt;BRPOP&lt;/code&gt; to consume, &lt;code&gt;LPUSH&lt;/code&gt; again to retry or dead-letter — plus a repeated-key trick for weighting and an envelope that carries its own retry count. No magic, which is the point: a queue small enough to read end to end is a queue you can actually trust in front of your request path.&lt;/p&gt;

&lt;p&gt;The package is &lt;a href="https://pub.dev/packages/redis_task_queue" rel="noopener noreferrer"&gt;&lt;code&gt;redis_task_queue&lt;/code&gt;&lt;/a&gt; on pub.dev, source on &lt;a href="https://github.com/Yusufihsangorgel/redis_task_queue" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you want the decisions behind it — what I took from Asynq and what I refused to copy into a first version — that's in the &lt;a href="https://yusufihsangorgel.github.io/2026/07/08/asynq-for-dart.html" rel="noopener noreferrer"&gt;companion post&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dart</category>
      <category>redis</category>
      <category>backend</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Built an LLM Filter That Prefers Silence Over Slop — and the Eval Harness That Keeps It Honest</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Tue, 07 Jul 2026 23:18:52 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/i-built-an-llm-filter-that-prefers-silence-over-slop-and-the-eval-harness-that-keeps-it-honest-3pce</link>
      <guid>https://dev.to/yusufihsangorgel/i-built-an-llm-filter-that-prefers-silence-over-slop-and-the-eval-harness-that-keeps-it-honest-3pce</guid>
      <description>&lt;p&gt;Last month, curl &lt;a href="https://news.ycombinator.com/item?id=48537165" rel="noopener noreferrer"&gt;paused security reports&lt;/a&gt; for a stretch because the queue had filled with AI-generated slop — plausible-looking bug reports that were wrong. It's the same complaint the Stack Overflow survey put a number on: the top frustration with AI output isn't that it's useless, it's that it's &lt;em&gt;almost right&lt;/em&gt;. Confidently, expensively, almost right.&lt;/p&gt;

&lt;p&gt;I ran into a smaller version of this problem a while back, building a pipeline that pulls financial news from a stream, decides which items actually matter, and forwards only those. The failure mode that mattered wasn't missing a story. It was forwarding a wrong one. A false positive here isn't a minor annoyance — it's a signal someone might act on.&lt;/p&gt;

&lt;p&gt;So I built the whole thing around one goal: &lt;strong&gt;prefer silence over slop.&lt;/strong&gt; Here's how, and — more importantly — the eval harness that keeps it honest, because a filter you can't measure is just a vibe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why false positives cost more than false negatives
&lt;/h2&gt;

&lt;p&gt;Most classification tutorials optimize for balanced accuracy, or F1, treating a miss and a false alarm as roughly equal mistakes. For a lot of problems that's fine. For this one it's exactly wrong.&lt;/p&gt;

&lt;p&gt;If the pipeline stays quiet on a real story, the cost is: someone reads it somewhere else, a few minutes later. Annoying, recoverable.&lt;/p&gt;

&lt;p&gt;If the pipeline forwards a wrong story — a misread number, a rumor stated as fact, a stale headline — the cost is: someone trusts it &lt;em&gt;because the pipeline is supposed to have filtered it.&lt;/em&gt; The whole value of a filter is that passing through means something. One confident wrong answer devalues every correct one before it.&lt;/p&gt;

&lt;p&gt;So the design target isn't "high accuracy." It's &lt;strong&gt;a false-positive rate low enough that a pass-through is trustworthy, accepting that we'll stay silent on borderline cases.&lt;/strong&gt; Recall is the thing I'm willing to trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gating pipeline
&lt;/h2&gt;

&lt;p&gt;The filter isn't one model call. It's a chain of cheap-to-expensive gates, and the cheap ones exist to keep the expensive one from ever being the single point of judgment.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Canonicalize.&lt;/strong&gt; Normalize the text, strip boilerplate, extract entities. Nothing clever — this just makes later steps deterministic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard rules first.&lt;/strong&gt; Regex and lookup gates for the things that don't need a model: known-noise sources, duplicate hashes, items missing a required entity. If a hard rule can reject it, no model runs. Cheap, deterministic, and it shrinks the surface the model has to be right about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model classifies — but it can abstain.&lt;/strong&gt; This is the core move. The prompt doesn't force a binary. It's allowed to return &lt;em&gt;uncertain&lt;/em&gt;, and uncertain is treated as &lt;strong&gt;reject, not pass.&lt;/strong&gt; A model that isn't sure is, for this pipeline, a model saying "stay silent."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post-veto.&lt;/strong&gt; Even a confident pass gets checked against the extracted facts. If the model's justification references a number or entity that isn't actually in the source text, it's vetoed. This is the step that catches the "almost right" — the confident summary of a thing that was subtly not said.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The asymmetry is baked in at every gate: &lt;strong&gt;the default is silence, and passing requires clearing all of them.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part everyone skips: the eval harness
&lt;/h2&gt;

&lt;p&gt;Here's the thing about a filter tuned for low false positives — you cannot tell if it's working by looking at it. It's quiet by design. Quiet because it's good and quiet because it's broken look identical from the outside.&lt;/p&gt;

&lt;p&gt;So the filter isn't the real artifact. The &lt;strong&gt;eval harness&lt;/strong&gt; is.&lt;/p&gt;

&lt;p&gt;It's a golden dataset — hand-labeled items, each marked &lt;em&gt;should-pass&lt;/em&gt; or &lt;em&gt;should-reject&lt;/em&gt;, including the nasty ones: the almost-right, the plausible-but-stale, the technically-true-but-misleading. Every change to a prompt, a rule, or a threshold runs against it and reports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;False-positive rate&lt;/strong&gt; — the number I actually care about, gated hard. A change that lowers FP-rate but tanks recall is a conversation. A change that &lt;em&gt;raises&lt;/em&gt; FP-rate fails the build, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recall&lt;/strong&gt; — tracked, not gated, so I can see the cost of my strictness and decide if it's too much.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-category breakdown&lt;/strong&gt; — because "it got worse" is useless; "it got worse on stale-headline cases" is a lead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the discipline the AI-slop stories are missing. The problem in those threads isn't that people used a model. It's that they shipped its output without a regression suite that would have caught the slop before a human had to. &lt;strong&gt;An LLM step without an eval harness is an untested function you deploy to production and hope about.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Treating evals like tests changed how I make changes. A new prompt idea isn't "better" because it reads better. It's better if it moves the FP-rate down without moving recall down more than I'll accept — and I have a number for that, before it ships, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decide which error is expensive &lt;em&gt;before&lt;/em&gt; you tune anything. It changes every threshold downstream.&lt;/li&gt;
&lt;li&gt;Let the model abstain, and make abstain mean "no." A forced binary manufactures confidence you didn't earn.&lt;/li&gt;
&lt;li&gt;Build the golden dataset first, and put the ugly cases in it. The average case is easy; the harness earns its keep on the almost-right ones.&lt;/li&gt;
&lt;li&gt;Gate the metric you care about in CI. If a regression on it doesn't fail the build, it will ship.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. It's just testing, applied to the one part of the system we keep exempting from testing because it's a model. The model isn't special. It's a function with a bad day ahead of it, and the harness is how you find out before your users do.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>go</category>
    </item>
    <item>
      <title>Turning a Portfolio Effect Into a Zero-Dependency Flutter Package</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Mon, 06 Jul 2026 17:51:08 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/turning-a-portfolio-effect-into-a-zero-dependency-flutter-package-2k3o</link>
      <guid>https://dev.to/yusufihsangorgel/turning-a-portfolio-effect-into-a-zero-dependency-flutter-package-2k3o</guid>
      <description>&lt;p&gt;A while ago I wrote about the &lt;a href="https://dev.to/yusufihsangorgel/building-a-spatial-grid-particle-system-in-flutter-web-4oa5"&gt;spatial grid particle system&lt;/a&gt; in my Flutter Web portfolio. The trick was keeping the connecting-line effect close to O(n) instead of the O(n²) you get from checking every pair. After that post, a few people asked if they could use the widget in their own projects.&lt;/p&gt;

&lt;p&gt;Short answer: they couldn't. The widget was wired into my app. That bothered me, so I extracted it into a package: &lt;a href="https://github.com/Yusufihsangorgel/constellation_particles" rel="noopener noreferrer"&gt;&lt;code&gt;constellation_particles&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is not about the algorithm again — the first post covers that. It is about the extraction itself: what it takes to turn a widget from your own app into something a stranger can install.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: It Looked Reusable, But It Wasn't
&lt;/h2&gt;

&lt;p&gt;On paper the widget was self-contained. One file, a &lt;code&gt;CustomPainter&lt;/code&gt;, a particle list. But the constructor told a different story. It reached into a GetX controller for the current scene, read an accent color and a speed from it, and listened with &lt;code&gt;ever()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;director&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;find&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SceneDirector&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;director&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;blendedConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;_configWorker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;director&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;blendedConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cfg&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;Drop this into an app without a &lt;code&gt;SceneDirector&lt;/code&gt; and it throws immediately. So the widget forced two things on every user: GetX, and my scene abstraction. Neither one has anything to do with drawing particles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Actually Needed
&lt;/h2&gt;

&lt;p&gt;I checked what the widget really reads from that config. Two values: a color and a speed multiplier. That's it. A full state-management dependency existed to deliver one &lt;code&gt;Color&lt;/code&gt; and one &lt;code&gt;double&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So they became constructor parameters, together with everything else you may want to tune:&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="nf"&gt;ConstellationParticles&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;particleCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;this&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;0xFF64FFDA&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;speed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&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;connectionDistance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;120.0&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;repulsionRadius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;200.0&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;repulsionForce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;50.0&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;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&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;ever()&lt;/code&gt; listener became &lt;code&gt;didUpdateWidget&lt;/code&gt;. If the color or the count changes, the widget reacts — the normal Flutter way. No plugin, no controller, nothing to register at app start. The dependency list is just Flutter.&lt;/p&gt;

&lt;p&gt;I think this part is underrated when people publish packages: a package that brings a state-management library with it is not a small package, no matter how few lines it has. The goal was that using it costs you nothing extra.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Kept
&lt;/h2&gt;

&lt;p&gt;The extraction was mostly deleting coupling. But a few parts were worth keeping, and they are easy to lose in a rewrite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle-aware&lt;/strong&gt; — the ticker pauses when the app is hidden or backgrounded. No frames burned in a tab nobody is looking at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility&lt;/strong&gt; — particle count drops to half when the platform asks for high contrast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repaint gating&lt;/strong&gt; — &lt;code&gt;shouldRepaint&lt;/code&gt; checks a generation counter, so the painter repaints only when the simulation actually moved. Paints and the glow gradient are cached, not rebuilt every frame.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing here is clever. But it is the difference between a demo and something you can leave running behind real content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Stack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;children:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Positioned&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;ConstellationParticles&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="n"&gt;yourContent&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;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/Yusufihsangorgel/constellation_particles" rel="noopener noreferrer"&gt;https://github.com/Yusufihsangorgel/constellation_particles&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pub.dev:&lt;/strong&gt; &lt;a href="https://pub.dev/packages/constellation_particles" rel="noopener noreferrer"&gt;https://pub.dev/packages/constellation_particles&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build something with it, I would like to see it. If you find a bug, the issue tracker is open. Would love feedback, especially on the API surface — is this parameter set enough, or already too much?&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building a Spatial Grid Particle System in Flutter Web</title>
      <dc:creator>Yusuf İhsan Görgel</dc:creator>
      <pubDate>Fri, 03 Apr 2026 16:09:50 +0000</pubDate>
      <link>https://dev.to/yusufihsangorgel/building-a-spatial-grid-particle-system-in-flutter-web-4oa5</link>
      <guid>https://dev.to/yusufihsangorgel/building-a-spatial-grid-particle-system-in-flutter-web-4oa5</guid>
      <description>&lt;p&gt;I recently built my developer portfolio entirely in Flutter Web. Instead of using a template, I wanted something with real engineering depth — cinematic backgrounds, interactive particles, and proper architecture.&lt;/p&gt;

&lt;p&gt;The most interesting technical challenge was the &lt;strong&gt;particle system&lt;/strong&gt;. Here's how I solved the performance problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: O(n²) Neighbor Lookups
&lt;/h2&gt;

&lt;p&gt;A constellation particle effect draws connecting lines between nearby particles. The naive approach checks every pair:&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;// O(n²) — kills performance at 50+ particles&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;particles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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="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;j&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;particles&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;particles&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="n"&gt;particles&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;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;drawLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;particles&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="n"&gt;particles&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="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;With 100 particles, that's 4,950 distance checks per frame. At 60fps, it's choppy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Spatial Grid Hashing
&lt;/h2&gt;

&lt;p&gt;Divide the viewport into cells. Each particle registers in its cell. To find neighbors, only check the 9 surrounding cells:&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_SpatialGrid&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;cellSize&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;Map&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="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_Particle&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_cells&lt;/span&gt; &lt;span class="o"&gt;=&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;_key&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;x&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;y&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;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;cellSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;floor&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;cy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;cellSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;floor&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;cx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cy&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;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_Particle&lt;/span&gt; &lt;span class="n"&gt;p&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;_cells&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&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;_Particle&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;neighbors&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;x&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;y&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;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;cellSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;floor&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;cy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;cellSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;floor&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_Particle&lt;/span&gt;&lt;span class="p"&gt;&amp;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;dx&lt;/span&gt; &lt;span class="o"&gt;=&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;dx&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&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;dx&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;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;dy&lt;/span&gt; &lt;span class="o"&gt;=&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;dy&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&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;dy&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_cells&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dy&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;cell&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now each particle only checks ~9 cells × ~5 particles = ~45 checks instead of 100. &lt;strong&gt;O(n) instead of O(n²).&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full System
&lt;/h2&gt;

&lt;p&gt;The particle system has more going on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cursor repulsion&lt;/strong&gt; — particles within 200px of the mouse get pushed away with spring physics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection lines&lt;/strong&gt; — drawn between particles within 120px, with opacity based on distance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scene-aware&lt;/strong&gt; — particle density and speed change based on which section you're scrolling through (controlled by a &lt;code&gt;SceneDirector&lt;/code&gt; state machine)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle-aware&lt;/strong&gt; — animations pause when the browser tab is hidden&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RepaintBoundary&lt;/strong&gt; — isolated from the rest of the widget tree to avoid unnecessary repaints&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Cinematic Background Stack
&lt;/h2&gt;

&lt;p&gt;The portfolio layers 7 visual elements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Dark base color&lt;/li&gt;
&lt;li&gt;Animated mesh gradient (Lissajous-curve blob movement with mouse parallax)&lt;/li&gt;
&lt;li&gt;Film grain overlay (pre-rasterized 256×256 texture via &lt;code&gt;toImageSync&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Constellation particles (spatial grid system)&lt;/li&gt;
&lt;li&gt;Vignette overlay (per-scene intensity)&lt;/li&gt;
&lt;li&gt;Scrollable content&lt;/li&gt;
&lt;li&gt;Fixed UI (sidebars, scroll dots, cursor)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each scene (Hero, About, Experience, Projects, Contact) maps to a movie color palette — Blade Runner 2049, Dune, The Matrix, Spider-Verse, and Interstellar. A &lt;code&gt;SceneDirector&lt;/code&gt; controller crossfades between them with 200px blend zones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;The project uses Clean Architecture with Dart 3.x patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;abstract interface class&lt;/code&gt; for domain contracts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;final class&lt;/code&gt; on all concrete classes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;switch&lt;/code&gt; expressions and &lt;code&gt;case when&lt;/code&gt; pattern guards&lt;/li&gt;
&lt;li&gt;GetX for reactive state (scroll, scene, language, sound controllers)&lt;/li&gt;
&lt;li&gt;185 automated tests&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://developeryusuf.com" rel="noopener noreferrer"&gt;developeryusuf.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/Yusufihsangorgel/Flutter-Web-Portfolio" rel="noopener noreferrer"&gt;Yusufihsangorgel/Flutter-Web-Portfolio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's fully forkable — change your JSON data, photo, and meta tags. All content is data-driven.&lt;/p&gt;

&lt;p&gt;Would love feedback, especially on the particle system performance and the scroll-driven scene architecture.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Flutter 3.41, Dart 3.7, zero external UI dependencies.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
