<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://yusufihsangorgel.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://yusufihsangorgel.github.io/" rel="alternate" type="text/html" /><updated>2026-07-08T20:39:45+00:00</updated><id>https://yusufihsangorgel.github.io/feed.xml</id><title type="html">Yusuf İhsan Görgel</title><subtitle>I build Flutter apps and the Go backends behind them, and I write about the decisions that go into them — mostly architecture, self-hosting, and the tradeoffs nobody puts in the tutorial.</subtitle><author><name>Yusuf İhsan Görgel</name></author><entry><title type="html">A Redis MCP Server Shows You Keys. When a Queue Breaks, You Need Jobs.</title><link href="https://yusufihsangorgel.github.io/2026/07/08/queue-inspector-mcp.html" rel="alternate" type="text/html" title="A Redis MCP Server Shows You Keys. When a Queue Breaks, You Need Jobs." /><published>2026-07-08T18:30:00+00:00</published><updated>2026-07-08T18:30:00+00:00</updated><id>https://yusufihsangorgel.github.io/2026/07/08/queue-inspector-mcp</id><content type="html" xml:base="https://yusufihsangorgel.github.io/2026/07/08/queue-inspector-mcp.html"><![CDATA[<p><img src="https://raw.githubusercontent.com/Yusufihsangorgel/queue-inspector-mcp/main/doc/cover.png" alt="queue-inspector-mcp" /></p>

<pre><code class="language-mermaid">flowchart LR
    A["AI agent"] --&gt;|"MCP · stdio"| M["queue-inspector-mcp"]
    M --&gt; B1["Asynq adapter"]
    M --&gt; B2["BullMQ adapter"]
    B1 --&gt;|ioredis| R[("Redis")]
    B2 --&gt;|ioredis| R
    M -.-&gt;|"--read-only"| Gprod-safe
</code></pre>

<p><img src="https://raw.githubusercontent.com/Yusufihsangorgel/queue-inspector-mcp/main/doc/cover.png" alt="queue-inspector-mcp — an MCP server for inspecting Redis job queues" /></p>

<p>A queue backs up at 2am. The retry count is climbing and something downstream is timing out. The questions in your head are all about jobs: how many tasks are stuck in retry, what error the top one failed with, how many attempts it has left, whether the dead ones can be requeued safely.</p>

<p>So you point an agent at Redis through an MCP server and ask. It hands you back keys.</p>

<p>That is the gap I kept hitting. A general-purpose Redis MCP server sees strings, hashes, and sorted sets. It does not know that an Asynq task is a protobuf blob living in the <code class="language-plaintext highlighter-rouge">msg</code> field of a hash, or that a BullMQ job’s state is decided entirely by which sorted set holds its id. The agent can read the raw bytes. It cannot answer the question you actually asked.</p>

<p><a href="https://github.com/Yusufihsangorgel/queue-inspector-mcp"><code class="language-plaintext highlighter-rouge">queue-inspector-mcp</code></a> encodes the knowledge that closes that gap. It speaks to two backends today — <a href="https://github.com/hibiken/asynq">Asynq</a> (Go) and <a href="https://github.com/taskforcesh/bullmq">BullMQ</a> (Node), both Redis-backed — and exposes six tools over MCP stdio: <code class="language-plaintext highlighter-rouge">list_queues</code>, <code class="language-plaintext highlighter-rouge">queue_stats</code>, <code class="language-plaintext highlighter-rouge">list_jobs</code>, <code class="language-plaintext highlighter-rouge">get_job</code>, <code class="language-plaintext highlighter-rouge">retry_job</code>, and <code class="language-plaintext highlighter-rouge">delete_job</code>. It runs with <code class="language-plaintext highlighter-rouge">npx queue-inspector-mcp</code> and a <code class="language-plaintext highlighter-rouge">REDIS_URL</code>. The rest of this post is about the three decisions that shaped it, and where it stops.</p>

<h2 id="what-a-state-aware-server-changes">What a state-aware server changes</h2>

<p>The unit of debugging changes. Instead of “there are 412 keys under <code class="language-plaintext highlighter-rouge">asynq:{default}:</code>,” <code class="language-plaintext highlighter-rouge">queue_stats</code> reports 380 pending, 4 active, 27 retry, 1 archived — the counts a human on-call would ask for first. <code class="language-plaintext highlighter-rouge">list_jobs</code> pages through one state and returns each job’s id, type, attempt count, and a truncated last error. <code class="language-plaintext highlighter-rouge">get_job</code> gives you the full payload, the retry ceiling, the last failure message, and the timestamps.</p>

<p>The point is not that a person couldn’t dig this out of raw Redis. It’s that an agent doing incident triage should reason about the queue in the same terms its owner does, so its next suggested action (“this dead job is a transient timeout, requeue it”) lands in the right vocabulary instead of in Redis internals.</p>

<p>That framing forced three decisions I want to be honest about, because two of them are deliberately less convenient than the obvious alternative.</p>

<h2 id="decision-1-each-backend-keeps-its-own-state-names">Decision 1: each backend keeps its own state names</h2>

<p>The tempting move is to invent a unified vocabulary — pick five nice words like <code class="language-plaintext highlighter-rouge">queued / running / retrying / dead / done</code>, and map both libraries onto them. It reads well in a README and it is wrong the moment you rely on it.</p>

<p>Asynq and BullMQ model job lifecycles differently, and the differences matter during an incident. Asynq has a <code class="language-plaintext highlighter-rouge">scheduled</code> state and a separate <code class="language-plaintext highlighter-rouge">retry</code> state, and its terminal “dead” state is called <code class="language-plaintext highlighter-rouge">archived</code>. BullMQ has <code class="language-plaintext highlighter-rouge">delayed</code>, <code class="language-plaintext highlighter-rouge">prioritized</code>, <code class="language-plaintext highlighter-rouge">waiting-children</code> (for flows), and <code class="language-plaintext highlighter-rouge">paused</code>, and its terminal failure state is <code class="language-plaintext highlighter-rouge">failed</code>. A <code class="language-plaintext highlighter-rouge">waiting-children</code> job is blocked on a flow; there is no honest Asynq word for that. Flattening these into a shared five-word vocabulary throws away exactly the distinction you need to act correctly.</p>

<p>So the server does not invent a shared vocabulary. <code class="language-plaintext highlighter-rouge">queue_stats</code> reports each backend’s own state names, and each state maps to a specific Redis structure — Asynq’s <code class="language-plaintext highlighter-rouge">archived</code> is a sorted set, BullMQ’s <code class="language-plaintext highlighter-rouge">waiting</code> is a list. If you are staring at an Asynq queue, you see <code class="language-plaintext highlighter-rouge">archived</code>, because that is the word in the Asynq docs, the Asynq dashboard, and your coworker’s runbook. The tool refuses to paper over a real difference to look tidier.</p>

<h2 id="decision-2-retry-and-delete-run-the-librarys-own-script-not-mine">Decision 2: retry and delete run the library’s own script, not mine</h2>

<p><code class="language-plaintext highlighter-rouge">retry_job</code> and <code class="language-plaintext highlighter-rouge">delete_job</code> mutate a production database. This is the part I was least willing to improvise on.</p>

<p>The naive implementation of “requeue a dead job” is a couple of Redis commands: pull the id out of the archived set, push it onto pending, flip a state field. It works in the demo. It also races with a running worker, skips the bookkeeping the library does around retry counters and group aggregation, and drifts the moment either library changes its internals.</p>

<p>Instead, the mutating tools run each library’s own atomic Lua. Retry executes Asynq’s <code class="language-plaintext highlighter-rouge">Inspector.RunTask</code> script and BullMQ’s <code class="language-plaintext highlighter-rouge">Job.retry</code> (<code class="language-plaintext highlighter-rouge">reprocessJob</code>) script; delete executes Asynq’s <code class="language-plaintext highlighter-rouge">Inspector.DeleteTask</code> and BullMQ’s <code class="language-plaintext highlighter-rouge">Job.remove</code> (<code class="language-plaintext highlighter-rouge">removeJob</code>) script. These are vendored verbatim from the libraries — pinned to specific versions — and registered on the Redis client so the mutation is exactly the one the library would have performed.</p>

<p>The upside is that the semantics match the library, including the sharp edges. Retrying a BullMQ job applies only to <code class="language-plaintext highlighter-rouge">failed</code> or <code class="language-plaintext highlighter-rouge">completed</code> jobs and does not reset <code class="language-plaintext highlighter-rouge">attemptsMade</code>, because that is what <code class="language-plaintext highlighter-rouge">Job.retry()</code> does. Neither backend will retry or delete an <code class="language-plaintext highlighter-rouge">active</code> job — a job a worker is holding is refused, not force-moved. <code class="language-plaintext highlighter-rouge">delete_job</code> removes a single BullMQ job and does not cascade into a flow’s children. I did not design those rules; I inherited them on purpose, so the tool never does something to your queue that the library itself would not do.</p>

<h2 id="decision-3-read-only-is-the-production-posture">Decision 3: read-only is the production posture</h2>

<p><code class="language-plaintext highlighter-rouge">retry_job</code> and <code class="language-plaintext highlighter-rouge">delete_job</code> are useful, and I do not want an agent calling either one against a live queue by accident.</p>

<p>With <code class="language-plaintext highlighter-rouge">--read-only</code> (or <code class="language-plaintext highlighter-rouge">QUEUE_INSPECTOR_READ_ONLY=1</code>) the server never registers the two mutating tools. They are absent from <code class="language-plaintext highlighter-rouge">tools/list</code> entirely — not gated behind a confirmation prompt the model can talk itself past, just not present. The agent cannot call a tool it cannot see.</p>

<p>This is the configuration I recommend for pointing an agent at a production database: inspection is safe and always on, mutation is an explicit, separate decision you make by leaving the flag off. Read is the default; write is the exception you opt into.</p>

<h2 id="what-it-doesnt-do">What it doesn’t do</h2>

<p>A launch post that only lists strengths is hiding something, so here is where it stops.</p>

<ul>
  <li><strong>Two backends, not many.</strong> Only Asynq and BullMQ are supported. Sidekiq, Celery, and RQ are not. The whole value here is library-specific knowledge, so each backend is real work, not a config entry.</li>
  <li><strong>No dashboard.</strong> This is an MCP server for programmatic use. If you want a web UI for your queues, Asynq and BullMQ both already have good ones — use those.</li>
  <li><strong>No streaming or watch.</strong> Every tool call is a point-in-time read. There is no subscription to queue events, so an agent polls rather than watches.</li>
  <li><strong>Some Asynq depth is not surfaced yet.</strong> Group aggregation (the <code class="language-plaintext highlighter-rouge">aggregating</code> state) is not exposed in this release.</li>
</ul>

<p>None of these are secret; they are all in the README, next to the features. A dependency that oversells itself is worse than one that tells you exactly where its edges are, and a queue tool is the last place you want a pleasant surprise.</p>

<h2 id="where-this-is-useful">Where this is useful</h2>

<p>If your stack runs Asynq or BullMQ and you already let an agent help with operational work, this gives it the vocabulary to reason about a misbehaving queue in the terms the queue actually uses — and, in read-only mode, to do that against production without being able to change anything.</p>

<p>The code is on <a href="https://github.com/Yusufihsangorgel/queue-inspector-mcp">GitHub</a> and <a href="https://www.npmjs.com/package/queue-inspector-mcp">npm</a>, MIT licensed. If you want the build mechanics instead of the reasoning — how the Asynq protobuf gets decoded off the wire, why BullMQ’s state has to be inferred from set membership, and how the tests run real Go and Node producers against a real Redis — I wrote that up separately.</p>]]></content><author><name>Yusuf İhsan Görgel</name></author><category term="mcp" /><category term="redis" /><category term="queue" /><category term="asynq" /><category term="bullmq" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Why I Taught My AI Pipeline to Stay Silent</title><link href="https://yusufihsangorgel.github.io/2026/07/08/why-i-taught-my-ai-pipeline-to-stay-silent.html" rel="alternate" type="text/html" title="Why I Taught My AI Pipeline to Stay Silent" /><published>2026-07-08T16:00:00+00:00</published><updated>2026-07-08T16:00:00+00:00</updated><id>https://yusufihsangorgel.github.io/2026/07/08/why-i-taught-my-ai-pipeline-to-stay-silent</id><content type="html" xml:base="https://yusufihsangorgel.github.io/2026/07/08/why-i-taught-my-ai-pipeline-to-stay-silent.html"><![CDATA[<p><img src="/assets/images/silence-cover.png" alt="silence is a valid output" /></p>

<p>There’s a version of “reliable AI” that everyone talks about, and a quieter one that actually ships. The loud one is about making the model right more often. The quiet one is about deciding what happens when it isn’t — and building the whole system around that answer instead of the accuracy number.</p>

<p>I ended up on the quiet side by accident, running a pipeline that reads a stream of financial news and forwards only the items that matter. And the thing that taught me the lesson wasn’t a paper or a framework. It was watching what a wrong answer actually costs.</p>

<h2 id="the-asymmetry-nobody-prices-in">The asymmetry nobody prices in</h2>

<p>When you build a classifier, the default mental model is a confusion matrix where a false positive and a false negative are two kinds of “oops.” Precision here, recall there, tune to taste.</p>

<p>But those two mistakes are almost never equal in the real world, and for a system whose entire job is to <em>decide what’s worth trusting</em>, they’re wildly unequal.</p>

<p>Miss a real story? Someone reads it somewhere else a few minutes later. The cost is a small delay.</p>

<p>Forward a wrong one? Someone acts on it — precisely because your system was supposed to have caught it. The whole point of a filter is that passing through <em>means something</em>. Every false positive quietly devalues every true one that came before it. You’re not just wrong once; you’re eroding the reason anyone trusts the pipeline at all.</p>

<p>Once I saw it that way, “high accuracy” stopped being the goal. The goal became: <strong>make a pass-through trustworthy, even if that means staying silent more often than a balanced model would.</strong> I was willing to trade recall. I was not willing to trade trust.</p>

<h2 id="silence-as-a-design-decision-not-a-failure">Silence as a design decision, not a failure</h2>

<p>This is where it gets a little philosophical, and I think that’s the part the current wave of “AI reliability” content mostly skips.</p>

<p>We’ve trained ourselves — and our models — to treat an answer as the deliverable. Ask a question, get a response. A model that says “I’m not sure” feels like a broken model. So we force binaries, we lower thresholds, we accept the confident-but-wrong because at least it <em>answered</em>.</p>

<p>Last month, curl paused security reports for a stretch because the queue had filled with exactly this: AI-generated bug reports, confident, plausible, and wrong. It’s the same failure the Stack Overflow survey named — the top frustration with AI isn’t uselessness, it’s being <em>almost right</em>. The industry is drowning in answers nobody asked to be trusted.</p>

<p>So I inverted the default. In my pipeline, “I’m not sure” doesn’t mean broken. It means <strong>stay silent</strong>, and silence is a valid, often <em>correct</em>, output. A model that abstains isn’t failing to do its job; on a borderline case, abstaining <em>is</em> the job. The system’s default state is quiet, and speaking up is what has to be earned — by clearing every gate, not just the last one.</p>

<p>That reframing changed more than the code. It changed what I considered a good day for the system. A good day isn’t “we forwarded a lot.” It’s “everything we forwarded was worth forwarding, and the things we weren’t sure about, we let pass by.”</p>

<h2 id="the-uncomfortable-part-you-cant-feel-this-working">The uncomfortable part: you can’t feel this working</h2>

<p>Here’s the catch, and it’s why most people don’t build this way. A system tuned to stay silent is, by design, quiet. And quiet-because-it’s-careful looks identical to quiet-because-it’s-broken. You cannot tell them apart by watching.</p>

<p>That’s the real reason the discipline matters — not as engineering hygiene, but as the only way to <em>know</em> your caution is caution and not just failure wearing its clothes. I keep a labeled set of the hard cases — the almost-right, the plausible-but-stale — and every change gets measured against it, with the one number I actually care about held as a hard line. (I wrote up the mechanics of that harness <a href="https://dev.to/yusufihsangorgel/i-built-an-llm-filter-that-prefers-silence-over-slop-and-the-eval-harness-that-keeps-it-honest-3pce">separately</a>, for anyone who wants the how rather than the why.)</p>

<p>But the mechanics aren’t the lesson. The lesson is the mindset shift that came first: deciding, before writing a line, that a wrong “yes” costs more than a missed “yes,” and letting that single decision reshape everything downstream — the thresholds, the gates, the definition of success, even what “the model did well today” means.</p>

<p>Everyone’s racing to make AI answer more. I got more mileage out of teaching mine when to shut up.</p>]]></content><author><name>Yusuf İhsan Görgel</name></author><category term="ai" /><category term="llm" /><category term="reliability" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">In Praise of the Boring Library</title><link href="https://yusufihsangorgel.github.io/2026/07/08/in-praise-of-the-boring-library.html" rel="alternate" type="text/html" title="In Praise of the Boring Library" /><published>2026-07-08T15:20:00+00:00</published><updated>2026-07-08T15:20:00+00:00</updated><id>https://yusufihsangorgel.github.io/2026/07/08/in-praise-of-the-boring-library</id><content type="html" xml:base="https://yusufihsangorgel.github.io/2026/07/08/in-praise-of-the-boring-library.html"><![CDATA[<p><img src="https://raw.githubusercontent.com/Yusufihsangorgel/redis_task_queue/main/doc/cover.png" alt="redis_task_queue" /></p>

<p><em>Server-side Dart was missing an obvious piece. Building it taught me more about restraint than about Redis.</em></p>

<p>There’s a specific small disappointment every engineer knows: you reach for the obvious library, the one that surely exists, and it isn’t there.</p>

<p>It happened to me recently in server-side Dart. I wanted to move some slow work — sending an email, calling a third-party API that fails one time in fifty — out of the request path and onto a queue, so a failure could be retried instead of becoming a 500 the user sees. In Go this is a solved, boring problem: you reach for Asynq, and you move on with your day. I went looking for the Dart equivalent, and the shelf was empty.</p>

<p>Not <em>literally</em> empty. There were a few packages. But they had two likes, a few dozen downloads a month, a last commit from years ago. The kind of package you can tell was written once for one project and quietly abandoned. Nothing you’d put in front of production traffic.</p>

<p>That gap is interesting, and not because it’s a complaint.</p>

<h2 id="the-missing-obvious-piece">The missing obvious piece</h2>

<p>A young ecosystem announces its maturity in a strange way: by which boring things are still missing.</p>

<p>Nobody is impressed by a task queue. It’s plumbing. In Go or Ruby or Python it’s so settled that reaching for it is muscle memory — you’d no sooner write your own than write your own HTTP client. That very boringness is what makes its absence loud. When the exciting things exist but the boring ones don’t, the ecosystem is still young. Server-side Dart has real web frameworks now, thousands of stars between them, people shipping actual backends. But the unglamorous connective tissue — the queue, the scheduler, the thing that retries — is thin.</p>

<p>Which means the most valuable open-source contribution you can make there isn’t clever. It’s boring. It’s the piece everyone will need and nobody wants to write, because there’s no novelty in it, only usefulness.</p>

<p>So I wrote the boring thing. A small Redis-backed task queue: put work on a queue, return immediately, let a separate worker run it with retries, and if it keeps failing, set it aside in a dead-letter list instead of looping forever. That’s the whole idea. It is deeply unoriginal, and that is exactly the point.</p>

<h2 id="restraint-is-the-actual-skill">Restraint is the actual skill</h2>

<p>Here’s what surprised me. The hard part wasn’t the Redis. It was deciding what to <em>leave out</em>.</p>

<p>Asynq — the Go library I was homesick for — is years of production hardening. It has scheduled jobs, delayed retries with exponential backoff, unique-task deduplication, a web dashboard. Every one of those is genuinely useful. And every instinct I had said to match it, feature for feature, so my version would look serious.</p>

<p>That instinct is a trap. Copying a mature library’s surface into a first version doesn’t give you a mature library — it gives you a large, shallow one, full of features you haven’t earned the right to claim work. A first version that does one thing — enqueue, process, retry, dead-letter — and does it in code you can read top to bottom is more trustworthy than one that does eight things vaguely and hides where each one breaks.</p>

<p>So I cut. No scheduler. No backoff — the retries are immediate, and I wrote that limitation directly into the README instead of burying it. No dashboard. What’s left is small enough to hold in your head, and honest about exactly where it stops.</p>

<p>I’ve come to think that’s the real craft in a small library: not what you’re clever enough to add, but what you’re disciplined enough to leave out, and whether you can say plainly what it costs you. A dependency that oversells itself is worse than one that under-promises. The person evaluating it for real work doesn’t need to be impressed; they need to know precisely where the edge is.</p>

<h2 id="the-bugs-live-in-the-boring-places-too">The bugs live in the boring places, too</h2>

<p>Even the one real bug fit the theme. The worker crashed on an <em>idle</em> queue — the most boring state there is — because I’d guessed wrong about what an empty poll returns. Not the busy path, not the clever path. The nothing-is-happening path, which is where a queue spends most of its life. I only caught it because I tested the queue doing nothing, against a real Redis, and watched it fall over.</p>

<p>The unglamorous states are where the truth is. Idle, empty, timed-out, retried-one-too-many-times. That’s true of the code, and I suspect it’s true of the work in general.</p>

<h2 id="build-the-boring-thing">Build the boring thing</h2>

<p>I don’t think this package will make anyone famous, and it isn’t supposed to. It fills a hole that shouldn’t have been there, in code someone can actually read, with its limits stated out loud. If a handful of people building on Dart get their slow work off the request path because of it, that’s the entire return, and it’s enough.</p>

<p>The exciting libraries get written. They always do. It’s the boring, obvious, missing ones — and the restraint to keep them small and honest — that quietly tell you an ecosystem, and maybe an engineer, is growing up.</p>

<hr />

<p><em>The library is <a href="https://pub.dev/packages/redis_task_queue"><code class="language-plaintext highlighter-rouge">redis_task_queue</code></a> on pub.dev; source on <a href="https://github.com/Yusufihsangorgel/redis_task_queue">GitHub</a>. If you want the engineering rather than the reflection — how the weighted polling and dead-letter path actually work — I wrote that up <a href="https://dev.to/yusufihsangorgel/a-redis-task-queue-in-dart-weighted-polling-dead-letters-and-one-nasty-empty-poll-bug-53ap">here</a>.</em></p>]]></content><author><name>Yusuf İhsan Görgel</name></author><category term="open-source" /><category term="dart" /><category term="software-craft" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Server-Side Dart Doesn’t Have a Task Queue. So I Ported the Idea From Go.</title><link href="https://yusufihsangorgel.github.io/2026/07/08/asynq-for-dart.html" rel="alternate" type="text/html" title="Server-Side Dart Doesn’t Have a Task Queue. So I Ported the Idea From Go." /><published>2026-07-08T09:30:00+00:00</published><updated>2026-07-08T09:30:00+00:00</updated><id>https://yusufihsangorgel.github.io/2026/07/08/asynq-for-dart</id><content type="html" xml:base="https://yusufihsangorgel.github.io/2026/07/08/asynq-for-dart.html"><![CDATA[<p><img src="https://raw.githubusercontent.com/Yusufihsangorgel/redis_task_queue/main/doc/cover.png" alt="redis_task_queue — enqueue, worker, retries, dead-letter" /></p>

<p>Every backend I’ve run has the same small pile of work that shouldn’t be in the request path: send the welcome email, resize the upload, call the flaky third-party API that times out one time in fifty. If any of that runs inside the request, a slow dependency becomes a slow response, and a failing dependency becomes a 500 the user actually sees.</p>

<p>In Go, the answer is boring and settled. You reach for <a href="https://github.com/hibiken/asynq">Asynq</a> — a Redis-backed task queue — drop the work on a queue, return immediately, and let a separate worker process it with retries. I’ve leaned on that pattern in Go for a while; I even <a href="https://github.com/gofiber/recipes/pull/4997">wrote it up as a Fiber recipe</a> recently.</p>

<p>Then I went looking for the same thing in server-side Dart, and it wasn’t there.</p>

<h2 id="the-gap-is-real">The gap is real</h2>

<p>Dart on the server is growing up. Serverpod and Dart Frog are both past a few thousand stars and people are shipping real backends on them. But “get this slow thing out of the request and retry it if it fails” — the most ordinary backend need there is — doesn’t have a maintained answer.</p>

<p>I looked. The queue packages on pub.dev are either abandoned or never got traction: a couple of likes, a few dozen downloads a month, last touched years ago. Nothing you’d put in front of production. In Go or Ruby you’d never write this yourself; in Dart, there’s a hole where the obvious library should be.</p>

<p>So I filled it, deliberately small: <a href="https://pub.dev/packages/redis_task_queue"><code class="language-plaintext highlighter-rouge">redis_task_queue</code></a>. A producer drops a task on Redis and returns; a worker picks it up, runs it, and retries on failure until it either succeeds or lands in a dead-letter list.</p>

<div class="language-dart highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// in your request path</span>
<span class="k">await</span> <span class="n">client</span><span class="o">.</span><span class="na">enqueue</span><span class="p">(</span>
  <span class="n">Task</span><span class="p">(</span><span class="s">'email:welcome'</span><span class="p">,</span> <span class="p">{</span><span class="s">'user_id'</span><span class="o">:</span> <span class="s">'42'</span><span class="p">}),</span>
  <span class="nl">queue:</span> <span class="s">'default'</span><span class="p">,</span>
  <span class="nl">maxRetries:</span> <span class="mi">5</span><span class="p">,</span>
<span class="p">);</span>

<span class="c1">// in a separate worker process</span>
<span class="n">worker</span><span class="o">.</span><span class="na">handle</span><span class="p">(</span><span class="s">'email:welcome'</span><span class="p">,</span> <span class="p">(</span><span class="n">task</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
  <span class="k">await</span> <span class="n">sendWelcomeEmail</span><span class="p">(</span><span class="n">task</span><span class="o">.</span><span class="na">payload</span><span class="p">[</span><span class="s">'user_id'</span><span class="p">]</span> <span class="k">as</span> <span class="kt">String</span><span class="p">);</span>
<span class="p">});</span>
<span class="k">await</span> <span class="n">worker</span><span class="o">.</span><span class="na">run</span><span class="p">();</span>
</code></pre></div></div>

<p>That’s the whole surface. The rest of this post is about the decisions behind it — because with a library this small, the decisions <em>are</em> the work.</p>

<h2 id="what-i-kept-from-asynq">What I kept from Asynq</h2>

<p>Three things from the Go model earned their place, because I’d felt the absence of each one in production.</p>

<p><strong>The type/payload split.</strong> A task is a string type (<code class="language-plaintext highlighter-rouge">email:welcome</code>) plus a JSON payload. The side that enqueues and the side that processes only have to agree on a string and a shape — not share code, not import each other. That decoupling is why you can move a handler to a different process, or a different deploy, without touching the caller. It’s the single most useful idea in Asynq and it costs almost nothing to copy.</p>

<p><strong>Weighted queues.</strong> Not just multiple queues — <em>weighted</em> ones. With <code class="language-plaintext highlighter-rouge">{'critical': 6, 'default': 3, 'low': 1}</code> the worker polls <code class="language-plaintext highlighter-rouge">critical</code> about six times as often as <code class="language-plaintext highlighter-rouge">low</code>. The reason this matters isn’t throughput, it’s starvation: the first time a batch job dumps ten thousand low-priority tasks onto a shared worker and your password-reset emails sit behind them for an hour, you learn that “just use a queue” isn’t enough. Priority has to be in the design, not bolted on.</p>

<p><strong>A dead-letter list, not an infinite retry.</strong> A task that keeps failing has to stop somewhere. After <code class="language-plaintext highlighter-rouge">maxRetries</code>, the envelope moves to a <code class="language-plaintext highlighter-rouge">dead</code> list instead of looping forever. A loop that never ends is worse than a failure you can see — the dead-letter list is how you find out <em>what</em> failed and <em>why</em> the next morning, instead of discovering it as a pegged CPU.</p>

<h2 id="what-i-deliberately-left-out">What I deliberately left out</h2>

<p>Here’s where I part ways with the instinct to match Asynq feature-for-feature. Asynq is years of production hardening. Copying its surface would mean copying complexity I can’t yet stand behind, into a first version. So <code class="language-plaintext highlighter-rouge">0.1.0</code> leaves real things out, on purpose, and says so:</p>

<ul>
  <li><strong>Retries are immediate, not backed off.</strong> A production queue delays re-enqueues with an exponential backoff — usually via a Redis sorted set scored by “run after this timestamp.” I know how to build that; I left it out of the first cut so the core retry path stays readable, and I wrote the limitation directly into the README rather than hiding it. It’s the first thing I’ll add.</li>
  <li><strong>No scheduler, no cron, no unique-task dedup, no web dashboard.</strong> Asynq has all of these. Each is a real feature and each is its own project’s worth of edge cases. A first version that does the <code class="language-plaintext highlighter-rouge">enqueue → process → retry → dead-letter</code> core <em>clearly</em> is more useful — and more trustworthy — than one that does eight things vaguely.</li>
</ul>

<p>I’d rather ship a small thing that’s honest about its edges than a big thing that hides them. Someone evaluating a queue for real work needs to know exactly where it stops, and a dependency that oversells itself is worse than one that under-promises.</p>

<h2 id="the-part-that-actually-bit-me">The part that actually bit me</h2>

<p>The one genuinely non-obvious bug was in the worker’s poll loop, and it’s the kind of thing you only find by running it.</p>

<p>The worker does a blocking pop across the weighted queue keys — <code class="language-plaintext highlighter-rouge">BRPOP key1 key2 ... 1</code> — which either returns <code class="language-plaintext highlighter-rouge">[key, value]</code> when a job is waiting or blocks for up to a second and then times out. I assumed a timeout came back as <code class="language-plaintext highlighter-rouge">null</code>. It doesn’t. The Redis client hands back <code class="language-plaintext highlighter-rouge">[null]</code> — a one-element list whose only element is null. My first guard checked <code class="language-plaintext highlighter-rouge">if (res == null)</code>, which was never true, so on every idle second the worker tried to decode <code class="language-plaintext highlighter-rouge">res[1]</code> off a list that only had a null at index 0, and threw.</p>

<p>An idle worker — the completely normal state — was crashing. The fix is one line (<code class="language-plaintext highlighter-rouge">if (res.isEmpty || res.first == null) continue;</code>), but I’d never have guessed the shape from the docs. I only caught it because I wrote a test that runs a real worker against a real Redis and lets it sit idle. The lesson I keep relearning: the bugs that matter live in the boring states — idle, empty, timed-out — not the happy path you naturally reach for first.</p>

<h2 id="when-to-actually-use-this">When to actually use this</h2>

<p>I want to be straight about the ceiling, because that’s the useful part.</p>

<p>If you’re on a JVM or Go stack, use the mature thing — Sidekiq, Asynq — not this. If you need scheduled/delayed jobs <em>today</em>, <code class="language-plaintext highlighter-rouge">0.1.0</code> isn’t there yet. But if you’re building a server-side Dart app and you want to get slow, retryable work out of the request path without pulling in a second language or a heavyweight broker, this is a small, readable, tested option that does exactly that and nothing it can’t back up.</p>

<p>The code is on <a href="https://github.com/Yusufihsangorgel/redis_task_queue">GitHub</a> and <a href="https://pub.dev/packages/redis_task_queue">pub.dev</a>. If you want the mechanics rather than the reasoning — how the weighted polling and the dead-letter path actually work, line by line — I wrote that up <a href="https://dev.to/yusufihsangorgel/a-redis-task-queue-in-dart-weighted-polling-dead-letters-and-one-nasty-empty-poll-bug-53ap">separately</a>.</p>

<p>The broader point isn’t the library. It’s that a growing ecosystem gets its missing pieces filled one small, honest package at a time — and that the interesting decisions in a small library aren’t what you put in, but what you’re disciplined enough to leave out.</p>]]></content><author><name>Yusuf İhsan Görgel</name></author><category term="dart" /><category term="redis" /><category term="backend" /><category term="server-side-dart" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">I Run 17 Products From One Go Binary. Here’s the Case For and Against.</title><link href="https://yusufihsangorgel.github.io/2026/07/07/one-go-binary-for-and-against.html" rel="alternate" type="text/html" title="I Run 17 Products From One Go Binary. Here’s the Case For and Against." /><published>2026-07-07T05:30:00+00:00</published><updated>2026-07-07T05:30:00+00:00</updated><id>https://yusufihsangorgel.github.io/2026/07/07/one-go-binary-for-and-against</id><content type="html" xml:base="https://yusufihsangorgel.github.io/2026/07/07/one-go-binary-for-and-against.html"><![CDATA[<p>A few years ago, every app I built had its own backend. Its own database. Its own deploy pipeline. That felt right — it’s what every tutorial tells you to do.</p>

<p>Then I had a dozen of them, and I was one person.</p>

<p>Every new app multiplied the number of things that could break at 3am. A dozen backends meant a dozen deploy pipelines, a dozen databases to back up, a dozen sets of logs to grep through when something went wrong. The architecture that felt “clean” for one app was quietly drowning me at twelve.</p>

<p>So I did something that still makes some engineers wince when I describe it: I put everything behind <strong>one Go binary</strong>. Today that gateway serves 17 product modules — 546 routes in total — from a single process, backed by one Postgres instance. One person maintains it.</p>

<p>I’ve since pulled a minimal version of the pattern into an <a href="https://github.com/Yusufihsangorgel/go-multitenant-gateway">open-source reference</a> so I can point at the shape without the product code. This post isn’t about the code, though. It’s about whether the decision was right — honestly, on both sides.</p>

<h2 id="the-case-for-one-binary">The case for one binary</h2>

<p>The thing nobody tells you when you’re starting out: for a small team, compute is not the expensive part. Operational surface is.</p>

<p>The expensive part is the number of independent things that can fail, deploy, and page you. Every service you add is another deploy to babysit, another log stream, another place a config drift can hide. When you’re one person, that number is the whole ballgame.</p>

<p>One binary collapses it:</p>

<ul>
  <li><strong>One deploy.</strong> A single artifact. Ship it, and everything ships. Roll it back, and everything rolls back — instantly, to the previous image.</li>
  <li><strong>One place for cross-cutting concerns.</strong> Auth, rate limiting, tenant resolution, request tracing — written once, in one middleware chain, not re-implemented per service.</li>
  <li><strong>One mental model.</strong> When something breaks, there’s one process to reason about, not a distributed system to trace a request across.</li>
</ul>

<p>Adding a product became almost free. A new module is a directory and one line that registers its routes. No new service, no new database, no new pipeline. That “almost free” is the entire reason the thing scaled to 17 modules without scaling me.</p>

<h2 id="the-case-against--which-is-real-and-which-i-dont-hide">The case against — which is real, and which I don’t hide</h2>

<p>Here’s where I part ways with people who’d sell you this as a clean win. It isn’t. It has a bill, and the bill is real.</p>

<p><strong>A shared failure domain.</strong> One binary means one bad deploy takes everything down at once. There’s no bulkhead. I mitigate it — instant rollback, health checks gating the routes — but I don’t pretend it away. This is the single biggest cost of the choice, and if you have a product that needs its own uptime guarantee, this design is wrong for it.</p>

<p><strong>Modularity by discipline, not by boundary.</strong> In a microservice setup, the network forces separation — service A physically cannot reach into service B’s internals. In one process, nothing stops a lazy import from coupling two modules that should never know about each other. Staying modular is on me and code review, not on the architecture. That’s a weaker guarantee, and I feel it every time the codebase grows.</p>

<p><strong>One Postgres for everyone.</strong> I isolate tenants by schema, not by database — one shared cluster, a schema per tenant. That gives me one connection pool, one backup, one thing to tune. The cost is blast radius: a runaway query or a mistaken migration can reach neighbors. It’s the right trade at my scale. It would be the wrong trade the moment one tenant needs a real SLA of its own.</p>

<h2 id="what-id-actually-change">What I’d actually change</h2>

<p>If I were grading my own system, three things:</p>

<ol>
  <li><strong>The biggest module is doing too much.</strong> One of the 17 is far larger than the rest. It’s the first thing I’d break out into its own service — not because one-binary is wrong, but because that specific module has outgrown the shared failure domain.</li>
  <li><strong>I’d make the failure domain explicit.</strong> Per-module circuit breaking, so one product’s bad path can’t cascade inside the process. Right now the isolation is conceptual; it should be enforced.</li>
  <li><strong>Per-tenant databases for the few that need it.</strong> Keep schema-per-tenant as the default for the long tail, but graduate the handful that need real isolation.</li>
</ol>

<h2 id="the-actual-lesson">The actual lesson</h2>

<p>The point isn’t “one binary good, microservices bad.” The point is that architecture decisions are trades, and the right trade depends on a constraint most tutorials never state: how many people are running this thing?</p>

<p>At one person and many products, the operational surface of microservices would have buried me long before their isolation benefits paid off. So I traded isolation for operability, on purpose, with my eyes open about what it costs. When the constraint changes — when a product earns its own SLA, when there’s a team to run it — the trade changes with it, and the schema boundary is exactly where I’d cut.</p>

<p>That’s the part I think separates a decision from cargo-culting: not which pattern you pick, but whether you can say out loud what it costs you.</p>

<hr />

<p><em>The reference implementation — a single-binary multi-tenant gateway with tenant resolution, per-tenant rate limiting, and JWT auth — is on <a href="https://github.com/Yusufihsangorgel/go-multitenant-gateway">GitHub</a>. It’s the pattern, minus the product code.</em></p>]]></content><author><name>Yusuf İhsan Görgel</name></author><category term="go" /><category term="architecture" /><category term="backend" /><category term="self-hosting" /><summary type="html"><![CDATA[A few years ago, every app I built had its own backend. Its own database. Its own deploy pipeline. That felt right — it’s what every tutorial tells you to do.]]></summary></entry></feed>