<?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: Yury</title>
    <description>The latest articles on DEV Community by Yury (@dr_kvet).</description>
    <link>https://dev.to/dr_kvet</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3729836%2F2153e4d8-0281-466b-a3f8-c899c08b742c.png</url>
      <title>DEV Community: Yury</title>
      <link>https://dev.to/dr_kvet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dr_kvet"/>
    <language>en</language>
    <item>
      <title>The Other Half of the Dual-Write Problem: What Happens When a Job Finishes</title>
      <dc:creator>Yury</dc:creator>
      <pubDate>Tue, 19 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/dr_kvet/the-other-half-of-the-dual-write-problem-what-happens-when-a-job-finishes-135n</link>
      <guid>https://dev.to/dr_kvet/the-other-half-of-the-dual-write-problem-what-happens-when-a-job-finishes-135n</guid>
      <description>&lt;p&gt;In the &lt;a href="https://dev.to/dr_kvet/series/35256"&gt;first post of this series&lt;/a&gt; I talked about the &lt;strong&gt;dual-write problem on the producer side&lt;/strong&gt; — the moment your app inserts a user and enqueues a "send welcome email" job, and one of those writes goes to Postgres while the other goes to Redis or Temporal. Two systems, no shared transaction, two ways to be inconsistent.&lt;/p&gt;

&lt;p&gt;Queuert closes that gap by making &lt;code&gt;startChain&lt;/code&gt; a row in the same transaction as the user insert. If the commit fails, the job never existed.&lt;/p&gt;

&lt;p&gt;But the producer side is only half the story. The same dual-write hazard shows up again at the &lt;strong&gt;other end of the pipe&lt;/strong&gt; — inside the attempt handler, when the job is actually running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symmetric Problem
&lt;/h2&gt;

&lt;p&gt;Same setup as the first post — Temporal activity, since that's the example we already have shared vocabulary for. The shape is the same in BullMQ, Sidekiq, Resque, SQS, anything where the queue's "completed" state lives outside your DB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chargePaymentActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;paymentId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;paymentId&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="nx"&gt;paymentId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// ← Activity returns. The worker now reports completion to the&lt;/span&gt;
  &lt;span class="c1"&gt;//   Temporal cluster. Separate connection, separate transaction,&lt;/span&gt;
  &lt;span class="c1"&gt;//   separate system.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four steps: read the order, charge Stripe, write &lt;code&gt;payment_id&lt;/code&gt; back, return so Temporal records the activity as completed. Looks atomic. It isn't.&lt;/p&gt;

&lt;p&gt;The seam is the last line. The DB update is one transaction against Postgres. The "mark this activity completed" write is a &lt;em&gt;different&lt;/em&gt; transaction against the Temporal cluster's persistence layer, issued by the worker after the function returns. If the worker crashes between them — pod eviction, OOM, network blip, anything — Postgres has the &lt;code&gt;payment_id&lt;/code&gt;, Temporal still thinks the activity is in flight, and the workflow re-runs the activity on the next attempt. Including the Stripe charge.&lt;/p&gt;

&lt;p&gt;The fixes everyone reaches for are familiar: pass an idempotency key to Stripe, add a &lt;code&gt;WHERE payment_id IS NULL&lt;/code&gt; to the update, maybe wrap the handler in a "did I already do this work?" guard. All of that is real, all of it works, and all of it is &lt;em&gt;defensive plumbing that exists purely because the DB and the queue can't agree on whether the work happened&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is the same dual-write problem, mirrored. Producer side: &lt;em&gt;did the row and the job both get created?&lt;/em&gt; Consumer side: &lt;em&gt;did the result write and the ack both land?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Most Libraries Don't Fix This
&lt;/h2&gt;

&lt;p&gt;The honest answer is that most job libraries can't fix this, because the worker process and the library's completion write don't share a transaction with your handler. The queue's notion of "this job is done" and your DB's notion of "this work happened" live in different transactions, and the library's answer is to hand you idempotency as homework.&lt;/p&gt;

&lt;p&gt;Receipts, one library at a time — expand the one you actually use:&lt;/p&gt;

&lt;p&gt;
  BullMQ, Bee, Sidekiq, Resque, SQS, Cloud Tasks
  &lt;br&gt;
Anything where the queue lives in Redis, RabbitMQ, or a managed broker. The worker does business work against your DB, then sends a separate ACK over the wire to the broker. Two systems, no shared transaction. The standard advice — "just make your handlers idempotent" — is real advice, but it's the library handing you back the dual-write problem and calling it a feature. You're now responsible for inventing an idempotency key for every write your handler performs.&lt;br&gt;


&lt;/p&gt;

&lt;p&gt;
  Temporal
  &lt;br&gt;
Temporal owns the truth, but in a third cluster. The activity finishes, reports success to the Temporal server, and that's a separate write from whatever your activity did to your application DB. The workflow engine reconciles the world by re-running activities until they look done — which is powerful, but it pushes you toward idempotency-by-default for &lt;em&gt;every&lt;/em&gt; activity that touches state, and it's an entire service tier to operate.&lt;br&gt;


&lt;/p&gt;

&lt;p&gt;
  graphile-worker
  &lt;br&gt;
Postgres-native, which is the right substrate, but the task handler's &lt;code&gt;withPgClient&lt;/code&gt; pulls a fresh pooled connection and the job's completion runs in a separate transaction issued by the worker pool after the task returns. Two transactions, no way to merge them. Better than Redis, but the dual-write surface is still there.&lt;br&gt;


&lt;/p&gt;

&lt;p&gt;
  pg-boss
  &lt;br&gt;
Worth a careful look, because its README markets "exactly-once job delivery" and it's easy to assume that means the consumer side is solved. It doesn't. The phrase refers to &lt;code&gt;SKIP LOCKED&lt;/code&gt; on the &lt;em&gt;fetch&lt;/em&gt; path — two workers can't claim the same row. The standard &lt;code&gt;work()&lt;/code&gt; worker still runs your handler, returns, and then pg-boss calls &lt;code&gt;complete()&lt;/code&gt; against its own pool in a separate transaction. If your handler commits domain writes and the worker crashes before &lt;code&gt;complete()&lt;/code&gt; lands — or the job's lease expires (&lt;code&gt;expireInSeconds&lt;/code&gt;, default 15 minutes) — the job is re-fetched and the handler runs again. Domain writes commit twice. pg-boss v12.17 added a &lt;code&gt;{ db }&lt;/code&gt; option that's accepted on &lt;code&gt;send()&lt;/code&gt;, &lt;code&gt;complete()&lt;/code&gt;, &lt;code&gt;fetch()&lt;/code&gt;, and friends — so the &lt;em&gt;primitives&lt;/em&gt; for atomic completion exist. But the &lt;code&gt;work()&lt;/code&gt; worker loop doesn't surface them; to actually get a handler-tx-fused-with-completion-tx flow you have to opt out of &lt;code&gt;work()&lt;/code&gt;, write your own &lt;code&gt;fetch()&lt;/code&gt; + handler + &lt;code&gt;complete(jobId, output, { db: tx })&lt;/code&gt; loop, and re-implement the lease, retry, and backoff machinery yourself. Atomic consumer-side processing is possible, but it's a parallel API you build, not the shape of the supported worker.&lt;br&gt;


&lt;/p&gt;

&lt;h2&gt;
  
  
  Atomic Mode: When Dual-Write Never Happens
&lt;/h2&gt;

&lt;p&gt;Queuert handlers come in two shapes. The simpler one — and the one most jobs should use — is &lt;strong&gt;atomic mode&lt;/strong&gt;: the entire handler runs inside one transaction in your DB (Postgres or SQLite — Queuert supports both).&lt;/p&gt;

&lt;p&gt;Here's &lt;code&gt;reserve-inventory&lt;/code&gt;, which decrements stock, records the reservation, and queues the next step in the chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reserve-inventory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;attemptHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;complete&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;continueWith&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`
        SELECT stock FROM items WHERE id = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; FOR UPDATE
      `&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="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Out of stock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`UPDATE items SET stock = stock - 1 WHERE id = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`
        INSERT INTO reservations (order_id, item_id)
        VALUES (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)
      `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;continueWith&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;charge-payment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&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;One transaction wraps &lt;strong&gt;all&lt;/strong&gt; of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; that locks the item row.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;UPDATE&lt;/code&gt; that decrements stock.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;INSERT&lt;/code&gt; into reservations.&lt;/li&gt;
&lt;li&gt;The mark-as-completed write on the job row.&lt;/li&gt;
&lt;li&gt;The insert of the &lt;code&gt;charge-payment&lt;/code&gt; continuation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If anything throws, nothing commits — the savepoint rolls back, the job stays pending, and the engine reschedules it with backoff. If the worker process dies mid-transaction, the DB rolls everything back on connection close. The next attempt starts from a clean slate.&lt;/p&gt;

&lt;p&gt;There is no dual-write window because there is no second write. You never need an idempotency key for &lt;code&gt;reserve-inventory&lt;/code&gt;, because the engine cannot leave the job half-done — Postgres won't let it. If your handlers are DB-bound (state machines, counters, ledger updates, workflow transitions), this is the mode you live in, and the consumer-side dual-write problem simply doesn't exist for your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Staged Mode: When You Have to Call Out
&lt;/h2&gt;

&lt;p&gt;Atomic mode works because nothing in the handler escapes the transaction. The moment you need to call an external system — Stripe, SES, an internal microservice — that stops being true. You can't hold a database transaction open across a Stripe API call (the lock contention alone would melt your DB), and you don't want network failures rolling back DB work that's already valid.&lt;/p&gt;

&lt;p&gt;That's what &lt;strong&gt;staged mode&lt;/strong&gt; is for: read in one transaction, do the external work outside any transaction, write the result in a second transaction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;charge-payment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;attemptHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;complete&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;staged&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`SELECT * FROM orders WHERE id = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// External call — outside any transaction, must be idempotent.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;paymentId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`order-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-attempt-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;continueWith&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`UPDATE orders SET payment_id = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;paymentId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; WHERE id = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;continueWith&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send-payment-receipt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paymentId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Stripe call still has to be idempotent — that's physics, not a library problem. You can't take back a network request, and any handler that calls an external system has to assume it might re-run after a retry. The idempotency key is unavoidable.&lt;/p&gt;

&lt;p&gt;But everything that &lt;em&gt;records&lt;/em&gt; what happened — the &lt;code&gt;UPDATE&lt;/code&gt;, the job's transition to &lt;code&gt;completed&lt;/code&gt;, the &lt;code&gt;send-payment-receipt&lt;/code&gt; continuation — lands in one DB commit. There is no second system to ack. The job's "done" &lt;em&gt;is&lt;/em&gt; a row update next to your &lt;code&gt;UPDATE orders&lt;/code&gt;. Either both commit or neither does.&lt;/p&gt;

&lt;p&gt;If the worker dies after Stripe returns but before &lt;code&gt;complete&lt;/code&gt; finishes, the next attempt re-runs Stripe with the same idempotency key (Stripe returns the original charge), reads the order, sees &lt;code&gt;payment_id&lt;/code&gt; is still null, and writes it. One source of truth, no reconciliation, no "did the ack make it back?" branch.&lt;/p&gt;

&lt;p&gt;The thing staged mode buys you is this: even though the external call breaks the single-transaction property, the &lt;em&gt;recording&lt;/em&gt; of its result stays atomic with everything downstream of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shape of the Guarantee
&lt;/h2&gt;

&lt;p&gt;Put the two posts together and the picture is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Producer side:&lt;/strong&gt; "I created a user and a job." → One commit. Atomic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumer side, atomic mode:&lt;/strong&gt; "My job is DB-bound." → One commit. Reads, writes, completion, next chain step — all atomic. No idempotency keys needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumer side, staged mode:&lt;/strong&gt; "My job calls an external system." → External call must be idempotent (always true, in every library). Recording the result is one commit, atomic with marking the job done and queuing the next step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No Temporal cluster keeping a third ledger in sync. No outbox table to babysit. No "what if the ack didn't make it back?" branch in your retry logic. The job table lives next to your business tables in the same DB — Postgres or SQLite — and the database handles atomicity for both. That's the one thing relational databases are unambiguously good at.&lt;/p&gt;

&lt;p&gt;The dual-write problem was never really about queues. It was about having two systems that needed to agree and no way to make them. Removing the second system — or, in pg-boss's case, opting into a wiring pattern that papers it over — is the only real fix. Queuert just makes it the default shape of the API rather than a feature you have to remember to turn on.&lt;/p&gt;

&lt;p&gt;There's one more tier of side effect that doesn't fit either mode — the "fire after commit, but I'm fine if it gets dropped" kind. That's what transaction hooks are for, and they have an important non-guarantee worth understanding before you reach for them. That's the next post.&lt;/p&gt;

</description>
      <category>node</category>
      <category>opensource</category>
      <category>postgres</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>TypeScript-first job chains: end-to-end inference for background jobs</title>
      <dc:creator>Yury</dc:creator>
      <pubDate>Mon, 04 May 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/dr_kvet/typescript-first-job-chains-end-to-end-inference-for-background-jobs-9dh</link>
      <guid>https://dev.to/dr_kvet/typescript-first-job-chains-end-to-end-inference-for-background-jobs-9dh</guid>
      <description>&lt;h2&gt;
  
  
  Most job queues forget your types at the queue boundary
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Promise&lt;/code&gt; chain in TypeScript is a thing of beauty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetchOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;//        ^^^^^^ Order[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step's input is typed by the previous step's output. Try to call &lt;code&gt;fetchOrders(user.something_that_doesnt_exist)&lt;/code&gt; and the compiler stops you. Misuse the &lt;code&gt;summary&lt;/code&gt; variable at the end and the compiler stops you.&lt;/p&gt;

&lt;p&gt;Now the typical &lt;em&gt;background-job&lt;/em&gt; version of the same thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// ... worker processes fetch-user, then enqueues fetch-orders ...&lt;/span&gt;
&lt;span class="c1"&gt;// ... worker processes fetch-orders, then enqueues summarize ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compiler sees &lt;code&gt;string&lt;/code&gt; (the type name) and &lt;code&gt;unknown&lt;/code&gt; (the payload). Whatever the &lt;code&gt;fetch-user&lt;/code&gt; handler returned, nothing checks that the input to &lt;code&gt;fetch-orders&lt;/code&gt; matches it. Renaming a field on the user payload silently breaks production.&lt;/p&gt;

&lt;p&gt;This isn't a TypeScript limitation. It's a design choice in most job libraries: jobs are loose key-value messages, scheduled by name. The type system has nothing to anchor to.&lt;/p&gt;

&lt;p&gt;I wanted job chains that worked more like &lt;code&gt;Promise.then()&lt;/code&gt; — but persisted, retryable, and able to live across worker restarts. I wrote a library called &lt;a href="https://github.com/kvet/queuert" rel="noopener noreferrer"&gt;Queuert&lt;/a&gt; that does this. Here's the type story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining job types
&lt;/h2&gt;

&lt;p&gt;You start by declaring the shape of every job type your application uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineJobTypes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queuert&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jobTypes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;defineJobTypes&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nl"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nl"&gt;continueWith&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nl"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nl"&gt;continueWith&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summarize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summarize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nl"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;totalRevenue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each entry has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;input&lt;/code&gt; — what the job receives.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;output&lt;/code&gt; — what the handler returns.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;continueWith&lt;/code&gt; — which job types this one can chain into next (optional; terminal jobs omit it).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;entry: true&lt;/code&gt; — marks the type as one that can start a chain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole declaration is a single TypeScript type literal. There's no codegen, no decorators, no separate schema language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type-checked chain creation
&lt;/h2&gt;

&lt;p&gt;Starting a chain checks the entry type's input against the literal you pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startChain&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;//         ^^^^^^ must match { userId: number }&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startChain&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summarize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;//         ^^^^^^^^^^^ Type '"summarize"' is not assignable&lt;/span&gt;
  &lt;span class="c1"&gt;//                     to type '"fetch-user"'&lt;/span&gt;
  &lt;span class="c1"&gt;// — only `entry: true` types are allowed at the start of a chain.&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can't start a chain at a non-entry type. You can't start it with the wrong input. Both errors are caught at compile time, before the job hits your queue table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type-checked continuations
&lt;/h2&gt;

&lt;p&gt;Inside a handler, you call &lt;code&gt;continueWith&lt;/code&gt; to chain into the next job. The library checks two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;typeName&lt;/code&gt; you pass must be one of the types declared in the current job's &lt;code&gt;continueWith&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;input&lt;/code&gt; must match the next type's declared input.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;attemptHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;complete&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchUserFromDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;continueWith&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;continueWith&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;//         ^^^^^^^^^^^^ must be in fetch-user's `continueWith` declaration&lt;/span&gt;
        &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="c1"&gt;//         ^^^^^^ must match fetch-orders' declared input&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;Try to chain to a job type the current type didn't declare? Compile error. Pass the wrong input shape? Compile error.&lt;/p&gt;

&lt;p&gt;This is the part that feels like Promise chains. The output of one job flows into the input of the next, type-checked at every step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Branching
&lt;/h2&gt;

&lt;p&gt;A chain can branch by listing multiple types in &lt;code&gt;continueWith&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trial-decision&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;continueWith&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;convert-to-paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expire-trial&lt;/span&gt;&lt;span class="dl"&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;In the handler, the compiler accepts either branch and forces you to pass the matching input for whichever you pick:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;continueWith&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nx"&gt;shouldConvert&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="nf"&gt;continueWith&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;convert-to-paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionId&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="nf"&gt;continueWith&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expire-trial&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionId&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;Each branch is type-checked independently against its target type's input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loops
&lt;/h2&gt;

&lt;p&gt;A job can chain back to itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;charge-billing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cycle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;finalCycle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;totalCharged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;continueWith&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;charge-billing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cancel-subscription&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is just a self-reference in the type system. The compiler resolves it cleanly. You can build retry loops, polling loops, recurring billing — anything where a job re-schedules itself based on its output and decides when to terminate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fan-in: typed blockers
&lt;/h2&gt;

&lt;p&gt;Sometimes a job needs to wait for several other chains to complete before running. In Queuert that's a &lt;code&gt;blockers&lt;/code&gt; declaration.&lt;/p&gt;

&lt;p&gt;Variable-count blockers (e.g. "wait for any number of &lt;code&gt;fetch-source&lt;/code&gt; chains"):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aggregate-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reportId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reportId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;totalSources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...{&lt;/span&gt; &lt;span class="nl"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch-source&lt;/span&gt;&lt;span class="dl"&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;Inside the handler, &lt;code&gt;job.blockers&lt;/code&gt; is a typed array — each entry is a completed &lt;code&gt;fetch-source&lt;/code&gt; job, with its &lt;code&gt;output&lt;/code&gt; already inferred:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aggregate-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;attemptHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;complete&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;const&lt;/span&gt; &lt;span class="nx"&gt;blocker&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blockers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blocker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;//                  ^^^^^^^^^^^^^^^      ^^^^^^^^^^^^^^^&lt;/span&gt;
      &lt;span class="c1"&gt;//              from fetch-source's declared output&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&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;Fixed-slot blockers (e.g. exactly one &lt;code&gt;validate-user&lt;/code&gt; and one &lt;code&gt;load-config&lt;/code&gt;) are also typed at the right index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;perform-action&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;blockers&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="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;validate-user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;load-config&lt;/span&gt;&lt;span class="dl"&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;Now &lt;code&gt;job.blockers[0].output&lt;/code&gt; is the &lt;code&gt;validate-user&lt;/code&gt; output and &lt;code&gt;job.blockers[1].output&lt;/code&gt; is the &lt;code&gt;load-config&lt;/code&gt; output, with each type inferred at the right index.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this prevents
&lt;/h2&gt;

&lt;p&gt;A non-exhaustive list of bugs the compiler refuses to let you ship:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Renaming a field on a job's output without updating downstream handlers.&lt;/li&gt;
&lt;li&gt;Chaining to a non-entry type as if it were an entry.&lt;/li&gt;
&lt;li&gt;Chaining to a type that wasn't declared in &lt;code&gt;continueWith&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Passing the wrong shape to a chain's input.&lt;/li&gt;
&lt;li&gt;Reading a field off a blocker's output that doesn't exist.&lt;/li&gt;
&lt;li&gt;Treating a fixed-slot blocker like an array of one type when it actually has heterogeneous slots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a typical Redis-backed queue, all of these become runtime errors — usually the kind that show up at 3am in your error tracker.&lt;/p&gt;

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

&lt;p&gt;Type-machinery this dense isn't free. Queuert publishes a &lt;a href="https://kvet.github.io/queuert/benchmarks/#type-complexity" rel="noopener noreferrer"&gt;type complexity benchmark&lt;/a&gt;: linear chains up to 100 types compile in ~1s, branched/blocker/loop graphs of similar size stay around the same, and merging 500 types from independent slices stays under 2.5s. There's a ceiling, but it's well above realistic application complexity.&lt;/p&gt;

&lt;p&gt;The library also internally caches navigation maps and uses tail-recursive type forms to keep instantiation counts down — a 0.5.0 rewrite cut blocker-heavy navigation by ~86%. If you ever hit a ceiling, it's a tractable problem.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/kvet/queuert" rel="noopener noreferrer"&gt;Queuert&lt;/a&gt; is MIT-licensed and pre-1.0. There's a companion post on the architectural side — why your database should be the source of truth for job state — that pairs with this one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/kvet/queuert" rel="noopener noreferrer"&gt;github.com/kvet/queuert&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://kvet.github.io/queuert/" rel="noopener noreferrer"&gt;kvet.github.io/queuert&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The dual-write problem (and a Postgres-native fix for Node.js background jobs)</title>
      <dc:creator>Yury</dc:creator>
      <pubDate>Fri, 01 May 2026 18:08:32 +0000</pubDate>
      <link>https://dev.to/dr_kvet/the-dual-write-problem-and-a-postgres-native-fix-for-nodejs-background-jobs-3an1</link>
      <guid>https://dev.to/dr_kvet/the-dual-write-problem-and-a-postgres-native-fix-for-nodejs-background-jobs-3an1</guid>
      <description>&lt;h2&gt;
  
  
  What Temporal taught me about state
&lt;/h2&gt;

&lt;p&gt;I've spent years building on Temporal — workflows-as-code, durable execution, the whole gospel. The thing Temporal got right, the thing that made it hard for me to go back to ordinary Redis-backed queues, is this: &lt;strong&gt;workflow state is durable on every step&lt;/strong&gt;. It lives in a transactional database. There's no in-memory state that disappears on a crash, no message that's "in flight" without being persisted, no step that completes without its result being safely written.&lt;/p&gt;

&lt;p&gt;But Temporal's durability story has a quiet caveat: it only protects state &lt;em&gt;inside&lt;/em&gt; a workflow. The moment you have an application database alongside the workflow service — and most projects do — you're back to two systems.&lt;/p&gt;

&lt;p&gt;Consider this signup handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@temporalio/client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sendWelcomeEmail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./workflows&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;temporal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alice@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;temporal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sendWelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;taskQueue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;main&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;workflowId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`welcome-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&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;Innocent enough. Insert a user, start a workflow, life moves on.&lt;/p&gt;

&lt;p&gt;Except sometimes Alice never gets the email. Other times Alice gets a welcome email pointing to a &lt;code&gt;userId&lt;/code&gt; that doesn't exist. Why?&lt;/p&gt;

&lt;h2&gt;
  
  
  The dual-write problem
&lt;/h2&gt;

&lt;p&gt;The application database and the workflow service are two different systems. The transaction in the snippet above is a &lt;strong&gt;Postgres&lt;/strong&gt; transaction. &lt;code&gt;temporal.workflow.start()&lt;/code&gt; writes to the &lt;strong&gt;Temporal cluster's&lt;/strong&gt; own database. Neither system knows about the other.&lt;/p&gt;

&lt;p&gt;So: &lt;code&gt;tx.users.create()&lt;/code&gt; succeeds. &lt;code&gt;workflow.start()&lt;/code&gt; succeeds. Then commit fails — constraint violation, network blip, anything. You now have a workflow running for a user that doesn't exist.&lt;/p&gt;

&lt;p&gt;Reverse it: &lt;code&gt;tx.users.create()&lt;/code&gt; succeeds. &lt;code&gt;workflow.start()&lt;/code&gt; fails. You now have a user with no welcome workflow, and no record of the missing one.&lt;/p&gt;

&lt;p&gt;Reverse it again: &lt;code&gt;workflow.start()&lt;/code&gt; succeeds in transit but the connection drops before you get the handle back. The workflow is running. You retry, and (if your &lt;code&gt;workflowId&lt;/code&gt; collides) you fail open or get two workflows.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;dual-write problem&lt;/strong&gt;, and it's not unique to BullMQ-on-Redis — it shows up any time job/workflow state lives in a different system from your business state. BullMQ is just the most obvious case because Redis isn't transactional with your Postgres. Temporal hides it better but doesn't eliminate it.&lt;/p&gt;

&lt;p&gt;Temporal's official answer is to make the workflow service the source of truth — keep workflow-relevant state inside Temporal, lean on Sagas for cross-service consistency, and treat your app DB as a read model fed by workflow events. That works, and it's the right answer for Temporal-native projects. But it's a real architectural commitment: a service tier with its own cluster, history/matching/frontend services, a Postgres or Cassandra of its own, and SDKs to keep in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard workaround: transactional outbox
&lt;/h2&gt;

&lt;p&gt;The textbook fix in the BullMQ-style world is the &lt;strong&gt;transactional outbox pattern&lt;/strong&gt;. Instead of writing directly to your queue, you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Insert a row into an &lt;code&gt;outbox&lt;/code&gt; table inside the same transaction as your business write.&lt;/li&gt;
&lt;li&gt;A separate process polls the outbox and forwards rows to your queue.&lt;/li&gt;
&lt;li&gt;Once acknowledged, the outbox row is deleted (or marked sent).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works. It also means you're now maintaining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An outbox table and its schema migrations.&lt;/li&gt;
&lt;li&gt;A poller process and its supervisor.&lt;/li&gt;
&lt;li&gt;An idempotency layer (your queue can receive the same outbox row twice if the poller crashes between forwarding and marking sent).&lt;/li&gt;
&lt;li&gt;Monitoring for outbox lag.&lt;/li&gt;
&lt;li&gt;A retry policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a small team shipping background jobs, that's a lot of infrastructure to maintain just to avoid orphaned data. And once you're maintaining it, you have to ask: what is Redis actually buying me here, that's worth this much glue code?&lt;/p&gt;

&lt;h2&gt;
  
  
  A smaller-shape question
&lt;/h2&gt;

&lt;p&gt;Temporal's answer to the dual-write problem is "run the whole workflow service." That's the right answer when you need it — workflows-as-code, replay semantics, polyglot SDKs, a managed control plane, history that survives any single component dying. But it's also a service tier: separate cluster, history/matching/frontend services, a Postgres or Cassandra of its own, worker SDKs to keep in sync with the server.&lt;/p&gt;

&lt;p&gt;For a lot of Node.js projects, that's overkill. You just want background jobs with the same durability guarantees Temporal taught you to expect — atomic with your business writes, no orphaned state, no in-flight messages that vanish on a crash.&lt;/p&gt;

&lt;p&gt;What if that durability instinct could come in a smaller shape? What if the job queue &lt;strong&gt;was&lt;/strong&gt; your existing Postgres — &lt;code&gt;queue.add()&lt;/code&gt; becoming an &lt;code&gt;INSERT&lt;/code&gt; into a &lt;code&gt;jobs&lt;/code&gt; table inside your transaction?&lt;/p&gt;

&lt;p&gt;That's the design behind &lt;a href="https://github.com/kvet/queuert" rel="noopener noreferrer"&gt;Queuert&lt;/a&gt;, an open-source library I wrote to scratch exactly that itch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;Same signup handler, rewritten with Queuert (Kysely + Postgres in this example):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;withTransactionHooks&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queuert&lt;/span&gt;&lt;span class="dl"&gt;"&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;withTransactionHooks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transactionHooks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alice@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returningAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executeTakeFirstOrThrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startChain&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;transactionHooks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send-welcome-email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&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;Two key things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;startChain&lt;/code&gt; takes the same transaction &lt;code&gt;tx&lt;/code&gt; as the user insert.&lt;/strong&gt; They're literally part of the same Postgres transaction. If the transaction rolls back, the job is never created. If the transaction commits, the job is created exactly once. No outbox needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;transactionHooks&lt;/code&gt; defers side effects until after commit.&lt;/strong&gt; Things like notifying workers (so they pick up the new job immediately) are buffered during the transaction and only fire if commit succeeds. If commit fails, the hooks are discarded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole story for atomic consistency. No second system, no forwarder, no retry loop — just a &lt;code&gt;BEGIN/COMMIT&lt;/code&gt; wrapped around your business write and an &lt;code&gt;INSERT&lt;/code&gt; into the &lt;code&gt;queuert_jobs&lt;/code&gt; table.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you stop needing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Redis as a state store.&lt;/strong&gt; You can still use Redis for low-latency wake-up notifications (Pub/Sub-style), but it's optional and stateless. Jobs are rows in your database. If Redis disappears, workers fall back to polling and nothing is lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An outbox table.&lt;/strong&gt; The jobs table &lt;em&gt;is&lt;/em&gt; the outbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate forwarder process.&lt;/strong&gt; Workers query the jobs table directly with &lt;code&gt;SELECT … FOR UPDATE SKIP LOCKED&lt;/code&gt; — a pattern Postgres has had since 9.5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two backup strategies, two replication setups, two monitoring stacks.&lt;/strong&gt; One database, one set of operational tooling.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you gain (besides the consistency)
&lt;/h2&gt;

&lt;p&gt;If jobs are first-class rows in your database, a lot of things become easier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You can &lt;code&gt;JOIN&lt;/code&gt; on them.&lt;/strong&gt; "Find all pending welcome emails for users created in the last hour" is a query, not a custom analytics pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can use your existing audit logging.&lt;/strong&gt; Every job state transition is a database write. If you already track changes via triggers or CDC, you get job history for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can run integration tests against a real database.&lt;/strong&gt; No mocking the queue. The job table is just another table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema migrations stay in one place.&lt;/strong&gt; Job table changes ride along with your application's migrations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this isn't
&lt;/h2&gt;

&lt;p&gt;This isn't a Temporal replacement, and it isn't trying to be. If you need workflows-as-code, replay semantics, deterministic execution, polyglot SDKs, or a managed control plane — use Temporal. That's still what I reach for on bigger projects, and it's worth every operational dollar when the workflow surface justifies it.&lt;/p&gt;

&lt;p&gt;It also isn't going to outscale dedicated queue infrastructure at the high end. Postgres handles a lot — the &lt;a href="https://kvet.github.io/queuert/benchmarks/" rel="noopener noreferrer"&gt;published benchmarks&lt;/a&gt; show ~21k chain creations per second batched and ~770 jobs/sec processed atomically on a Dockerized Postgres against a single worker — but if you're at the scale where a job queue is its own infrastructure tier with a dedicated team, dedicated tools probably make sense.&lt;/p&gt;

&lt;p&gt;The target is the in-between: small-to-medium Node.js projects where Temporal is too heavy and BullMQ reintroduces the dual-write problem.&lt;/p&gt;

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

&lt;p&gt;Queuert is MIT-licensed and pre-1.0. Postgres and SQLite adapters; in-process, Redis (incl. Cluster), NATS, and Postgres LISTEN/NOTIFY for the optional notify layer; an embeddable web dashboard; OpenTelemetry tracing across job chains.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/kvet/queuert" rel="noopener noreferrer"&gt;github.com/kvet/queuert&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://kvet.github.io/queuert/" rel="noopener noreferrer"&gt;kvet.github.io/queuert&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A follow-up post on the TypeScript story — how job chains stay type-checked end-to-end across &lt;code&gt;continueWith&lt;/code&gt;, branching, loops, and fan-in blockers — is up next.&lt;/p&gt;

</description>
      <category>node</category>
      <category>postgres</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
