<?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: David</title>
    <description>The latest articles on DEV Community by David (@heydavy).</description>
    <link>https://dev.to/heydavy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4152639%2F61ecbd1a-a6d5-4c47-8913-0353158aec1b.jpg</url>
      <title>DEV Community: David</title>
      <link>https://dev.to/heydavy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/heydavy"/>
    <language>en</language>
    <item>
      <title>How I Built a PostgreSQL Job Queue with SKIP LOCKED</title>
      <dc:creator>David</dc:creator>
      <pubDate>Sat, 10 Oct 2026 14:49:03 +0000</pubDate>
      <link>https://dev.to/heydavy/how-i-built-a-postgresql-job-queue-with-skip-locked-gg5</link>
      <guid>https://dev.to/heydavy/how-i-built-a-postgresql-job-queue-with-skip-locked-gg5</guid>
      <description>&lt;p&gt;&lt;em&gt;Building reliable background job processing with PostgreSQL, Node.js, and a few important distributed systems concepts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At some point, almost every backend application needs to execute something asynchronously.&lt;/p&gt;

&lt;p&gt;Maybe you need to process an order, send a webhook, schedule an API request, or retry an operation that failed.&lt;/p&gt;

&lt;p&gt;The typical solution is to introduce a message queue.&lt;/p&gt;

&lt;p&gt;For Node.js applications, that often means BullMQ, Redis, and one or more worker processes.&lt;/p&gt;

&lt;p&gt;There's nothing wrong with that approach. BullMQ is a great tool, and Redis is extremely useful for background processing.&lt;/p&gt;

&lt;p&gt;But while building &lt;a href="https://asynclay.com" rel="noopener noreferrer"&gt;Asynclay&lt;/a&gt;, a service for scheduling and executing background HTTP jobs, I wanted to explore a different approach:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Could PostgreSQL itself handle the job queue reliably enough for my use case?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I was already using PostgreSQL to persist jobs, accounts, projects, and execution history. Introducing another infrastructure dependency just for job coordination didn't seem necessary at this stage.&lt;/p&gt;

&lt;p&gt;So I decided to build a PostgreSQL-backed dispatcher.&lt;/p&gt;

&lt;p&gt;It started with a simple query.&lt;/p&gt;

&lt;p&gt;Then came concurrency, race conditions, worker crashes, retries, leases, and the realization that building a reliable queue is much more interesting than simply polling a database.&lt;/p&gt;

&lt;p&gt;Here's how I approached it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The simplest possible job queue
&lt;/h2&gt;

&lt;p&gt;The initial idea was straightforward.&lt;/p&gt;

&lt;p&gt;Each job would have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A target URL&lt;/li&gt;
&lt;li&gt;A JSON payload&lt;/li&gt;
&lt;li&gt;An execution time&lt;/li&gt;
&lt;li&gt;A status&lt;/li&gt;
&lt;li&gt;An attempt counter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simplified PostgreSQL table might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;target_url&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;run_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;next_attempt_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_attempts&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first implementation could simply query for pending jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then execute each job:&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;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;job&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;executeJob&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a single worker running under ideal conditions, this seems reasonable.&lt;/p&gt;

&lt;p&gt;Unfortunately, it breaks as soon as multiple workers are involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The concurrency problem
&lt;/h2&gt;

&lt;p&gt;Imagine two application instances running the same dispatcher.&lt;/p&gt;

&lt;p&gt;Both execute the query at approximately the same time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Worker A                     Worker B
   |                            |
   | SELECT pending jobs        |
   |                            | SELECT pending jobs
   |                            |
   | Job #123                   | Job #123
   |                            |
   | Execute Job #123           | Execute Job #123
   |                            |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both workers found the same pending job.&lt;/p&gt;

&lt;p&gt;Both execute it.&lt;/p&gt;

&lt;p&gt;This is particularly dangerous when the job triggers an external side effect, such as charging a customer or creating an order.&lt;/p&gt;

&lt;p&gt;A simple application-level mutex wouldn't solve the problem because the workers might run in different processes or servers.&lt;/p&gt;

&lt;p&gt;I needed a way to coordinate workers through PostgreSQL.&lt;/p&gt;

&lt;p&gt;That's where &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; comes in.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Understanding FOR UPDATE SKIP LOCKED
&lt;/h2&gt;

&lt;p&gt;PostgreSQL supports row-level locking through &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When a transaction selects a row with &lt;code&gt;FOR UPDATE&lt;/code&gt;, it acquires a lock that prevents another transaction from acquiring a conflicting lock on that row until the first transaction completes.&lt;/p&gt;

&lt;p&gt;For a queue, this is useful because multiple workers can safely claim different jobs.&lt;/p&gt;

&lt;p&gt;But there's a problem with ordinary &lt;code&gt;FOR UPDATE&lt;/code&gt;: workers can end up waiting for rows locked by other workers.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;SKIP LOCKED&lt;/code&gt; changes that behavior.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of waiting for locked rows, PostgreSQL skips them.&lt;/p&gt;

&lt;p&gt;Imagine three available jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Job #101
Job #102
Job #103
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worker A locks Job #101.&lt;/p&gt;

&lt;p&gt;While that transaction is still open, Worker B executes the same query.&lt;/p&gt;

&lt;p&gt;Because Job #101 is locked, Worker B skips it and can claim Job #102.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Worker A                     Worker B
   |                            |
   | Lock Job #101              |
   |                            | Skip Job #101
   |                            | Lock Job #102
   |                            |
   | Process #101               | Process #102
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives us a useful foundation for concurrent job dispatching.&lt;/p&gt;

&lt;p&gt;One important detail: &lt;code&gt;SKIP LOCKED&lt;/code&gt; provides an intentionally inconsistent view of the table. That's generally acceptable for a queue, but it's not appropriate for arbitrary business queries requiring a consistent snapshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Claiming jobs atomically
&lt;/h2&gt;

&lt;p&gt;The next step was ensuring that claiming a job and changing its status happened inside the same database transaction.&lt;/p&gt;

&lt;p&gt;A simplified SQL implementation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Update the selected jobs before committing.&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A more compact approach uses a common table expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;claimable&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PROCESSING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'60 seconds'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;claimable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="o"&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 statement selects available jobs, locks them, updates their state, and returns the claimed rows.&lt;/p&gt;

&lt;p&gt;Concurrent workers executing it won't normally claim the same row at the same time.&lt;/p&gt;

&lt;p&gt;For a production implementation, the claim predicate should also account for retryable jobs, execution limits, and any account-level concurrency rules.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'RETRYING'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;next_attempt_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important principle is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claim the job atomically before performing the external operation.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Never keep the database transaction open during HTTP execution
&lt;/h2&gt;

&lt;p&gt;This was one of the most important architectural decisions.&lt;/p&gt;

&lt;p&gt;At first, it might seem convenient to lock the job and execute it while the transaction is still active.&lt;/p&gt;

&lt;p&gt;Something like:&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;dataSource&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;manager&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;job&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;claimJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;manager&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;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&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;targetUrl&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;payload&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;markCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;manager&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;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But this is a bad idea.&lt;/p&gt;

&lt;p&gt;HTTP requests can take several seconds or fail entirely.&lt;/p&gt;

&lt;p&gt;Keeping the transaction open during that time means holding database locks and consuming a database connection while waiting for an external service.&lt;/p&gt;

&lt;p&gt;Instead, I separated claiming from execution.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Begin transaction
2. Lock and claim job
3. Mark PROCESSING
4. Commit transaction
5. Execute HTTP request
6. Persist execution result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In TypeScript, the structure becomes:&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;claimedJobs&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;claimJobs&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;claimedJobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;executeJob&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database transaction is short.&lt;/p&gt;

&lt;p&gt;The potentially slow network operation happens afterward.&lt;/p&gt;

&lt;p&gt;However, this creates another problem.&lt;/p&gt;

&lt;p&gt;What happens if the worker crashes after claiming a job?&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Recovering jobs after worker crashes
&lt;/h2&gt;

&lt;p&gt;Imagine this sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Worker claims Job #123
        |
        v
Status = PROCESSING
        |
        v
Worker crashes
        |
        X
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job remains in &lt;code&gt;PROCESSING&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without a recovery mechanism, it could stay there forever.&lt;/p&gt;

&lt;p&gt;To solve this, I introduced a &lt;strong&gt;lease&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When a worker claims a job, it sets a deadline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'60 seconds'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lease represents the period during which the worker is expected to complete the execution.&lt;/p&gt;

&lt;p&gt;If the worker crashes and never updates the job, the lease eventually expires.&lt;/p&gt;

&lt;p&gt;A separate recovery process can find expired jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PROCESSING'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It can then move them back to a retryable state or mark them as permanently failed if the maximum attempt count has been reached.&lt;/p&gt;

&lt;p&gt;A simplified recovery operation might look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;
        &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'FAILED'&lt;/span&gt;
        &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'RETRYING'&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;next_attempt_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;
        &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
        &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PROCESSING'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a real implementation, recovery should coordinate with concurrent workers, usually by claiming expired rows transactionally.&lt;/p&gt;

&lt;p&gt;There is also an important limitation: &lt;strong&gt;a lease is not a guarantee that the original worker has stopped executing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If an HTTP request hangs beyond the lease duration, another worker may recover and retry the job while the original request is still running.&lt;/p&gt;

&lt;p&gt;That means the HTTP timeout, lease duration, and recovery behavior must be designed together.&lt;/p&gt;

&lt;p&gt;For my current workload, I use a shorter HTTP timeout than the processing lease, with additional time reserved for database updates.&lt;/p&gt;

&lt;p&gt;For long-running jobs, I would consider lease renewal, worker fencing, or a different execution model.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Retries with exponential backoff
&lt;/h2&gt;

&lt;p&gt;Failures are inevitable when executing external HTTP requests.&lt;/p&gt;

&lt;p&gt;The target server might be temporarily unavailable.&lt;/p&gt;

&lt;p&gt;The connection might time out.&lt;/p&gt;

&lt;p&gt;A dependency might be experiencing an outage.&lt;/p&gt;

&lt;p&gt;Immediately retrying every failed request can make the situation worse.&lt;/p&gt;

&lt;p&gt;Instead, I use exponential backoff.&lt;/p&gt;

&lt;p&gt;A simple formula is:&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;function&lt;/span&gt; &lt;span class="nf"&gt;calculateRetryDelay&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="kr"&gt;number&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&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;baseDelay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Delay before next retry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;30 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;60 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;120 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The actual number of retries depends on the configured maximum attempt count.&lt;/p&gt;

&lt;p&gt;After a failed execution, the job can be updated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'RETRYING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;next_attempt_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'30 seconds'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dispatcher later picks it up using the same claim mechanism.&lt;/p&gt;

&lt;p&gt;For a larger system, I'd also introduce jitter to prevent many failed jobs from retrying simultaneously.&lt;/p&gt;

&lt;p&gt;One subtle but important point: retries should be based on the semantics of the operation. Not every HTTP failure is transient, and retrying permanent errors indefinitely is rarely useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The exactly-once execution problem
&lt;/h2&gt;

&lt;p&gt;This is where things become especially interesting.&lt;/p&gt;

&lt;p&gt;Suppose a worker sends an HTTP request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Worker
   |
   | POST /process-payment
   v
Target API
   |
   | Payment processed successfully
   v
HTTP 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But before the worker can save the successful result, its process crashes.&lt;/p&gt;

&lt;p&gt;The database still says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PROCESSING
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eventually, the lease expires.&lt;/p&gt;

&lt;p&gt;Another worker retries the job.&lt;/p&gt;

&lt;p&gt;The target API receives the same request again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The job was executed twice, even though PostgreSQL prevented two workers from claiming it concurrently.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This isn't a bug in &lt;code&gt;SKIP LOCKED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It's a fundamental problem when coordinating database state with external side effects.&lt;/p&gt;

&lt;p&gt;You cannot make an arbitrary HTTP request and a local PostgreSQL update part of the same atomic transaction.&lt;/p&gt;

&lt;p&gt;That's why Asynclay uses &lt;strong&gt;at-least-once delivery semantics&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A job may be delivered more than once.&lt;/p&gt;

&lt;p&gt;The target application should be prepared to handle duplicates.&lt;/p&gt;

&lt;p&gt;One useful approach is to include a stable job identifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-Asynclay-Job-Id: 123
X-Asynclay-Attempt: 2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiving application can store processed job IDs and reject or safely return a previous result for duplicates.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;job_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For operations that modify business state, the idempotency record and the business operation should be committed atomically whenever possible.&lt;/p&gt;

&lt;p&gt;Otherwise, the target could save the idempotency record, crash before performing the operation, and incorrectly treat future deliveries as completed.&lt;/p&gt;

&lt;p&gt;This is an important distinction:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A queue can coordinate job delivery, but exactly-once business effects require cooperation from the receiving application.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Handling concurrency limits
&lt;/h2&gt;

&lt;p&gt;Another requirement I encountered was limiting the number of simultaneously executing jobs.&lt;/p&gt;

&lt;p&gt;For example, one account might be allowed two concurrent executions while another has a higher limit.&lt;/p&gt;

&lt;p&gt;A simple in-memory counter doesn't work reliably across multiple application instances.&lt;/p&gt;

&lt;p&gt;If Worker A and Worker B each see one active job, they might both start another execution and exceed the account's limit.&lt;/p&gt;

&lt;p&gt;I used PostgreSQL transactions and account-level row locking to coordinate these decisions.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Begin transaction
       |
       v
Lock account row
       |
       v
Count active jobs
       |
       v
Check concurrency limit
       |
       v
Claim eligible jobs
       |
       v
Commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This serializes concurrency decisions for the same account across workers.&lt;/p&gt;

&lt;p&gt;There are tradeoffs.&lt;/p&gt;

&lt;p&gt;A heavily used account can become a contention point, and repeatedly counting active jobs isn't free.&lt;/p&gt;

&lt;p&gt;For the current scale of the project, however, this is a reasonable tradeoff for correctness and simplicity.&lt;/p&gt;

&lt;p&gt;If throughput grows significantly, the design can evolve toward more specialized scheduling and coordination.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Indexing matters
&lt;/h2&gt;

&lt;p&gt;Polling PostgreSQL every second is not inherently a problem.&lt;/p&gt;

&lt;p&gt;Polling a large table without appropriate indexes is.&lt;/p&gt;

&lt;p&gt;The dispatcher frequently queries jobs by status and scheduled execution time.&lt;/p&gt;

&lt;p&gt;Useful indexes include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_jobs_pending&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_jobs_retrying&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_attempt_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'RETRYING'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For recovery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_jobs_processing_lease&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locked_until&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PROCESSING'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These partial indexes help PostgreSQL focus on the rows relevant to each operation.&lt;/p&gt;

&lt;p&gt;Of course, indexes aren't free.&lt;/p&gt;

&lt;p&gt;Every status transition also updates index entries, so the exact index strategy should be validated against real query plans and workload patterns.&lt;/p&gt;

&lt;p&gt;I would rather start with a few targeted indexes and measure than create an index for every column.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. What I learned about PostgreSQL as a queue
&lt;/h2&gt;

&lt;p&gt;After implementing the dispatcher, retries, leases, and recovery, I came away with a few conclusions.&lt;/p&gt;

&lt;h3&gt;
  
  
  PostgreSQL is more capable than people sometimes assume
&lt;/h3&gt;

&lt;p&gt;For moderate workloads, PostgreSQL can coordinate background jobs effectively.&lt;/p&gt;

&lt;p&gt;Row-level locks and &lt;code&gt;SKIP LOCKED&lt;/code&gt; provide a practical foundation for concurrent workers.&lt;/p&gt;

&lt;p&gt;If your application already depends on PostgreSQL, using it for job coordination can significantly reduce operational complexity.&lt;/p&gt;

&lt;h3&gt;
  
  
  SKIP LOCKED doesn't solve everything
&lt;/h3&gt;

&lt;p&gt;It solves an important part of concurrent job claiming.&lt;/p&gt;

&lt;p&gt;It does not automatically solve:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Worker crashes&lt;/li&gt;
&lt;li&gt;Expired leases&lt;/li&gt;
&lt;li&gt;Duplicate external side effects&lt;/li&gt;
&lt;li&gt;Retry policies&lt;/li&gt;
&lt;li&gt;Fair scheduling&lt;/li&gt;
&lt;li&gt;Account-level concurrency&lt;/li&gt;
&lt;li&gt;Monitoring&lt;/li&gt;
&lt;li&gt;Long-running execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those problems still require application-level design.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reliability is mostly about failure handling
&lt;/h3&gt;

&lt;p&gt;The happy path is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claim → Execute → Complete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difficult cases are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claim → Crash

Execute → Timeout

Execute → Success → Crash before saving

Retry → Target processes duplicate

Lease expires → Original worker still running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reliable job system is defined by how it handles these situations, not just how quickly it executes successful jobs.&lt;/p&gt;

&lt;h3&gt;
  
  
  PostgreSQL isn't always the right choice
&lt;/h3&gt;

&lt;p&gt;I wouldn't recommend building your own PostgreSQL queue for every application.&lt;/p&gt;

&lt;p&gt;If you need very high throughput, sophisticated scheduling, complex workflows, or mature operational tooling, an established solution may be a better fit.&lt;/p&gt;

&lt;p&gt;BullMQ, RabbitMQ, dedicated workflow engines, and managed background-job services all solve different problems.&lt;/p&gt;

&lt;p&gt;For my use case, PostgreSQL offered a good balance between reliability, operational simplicity, and control.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Where this led: Asynclay
&lt;/h2&gt;

&lt;p&gt;This implementation became part of &lt;a href="https://asynclay.com" rel="noopener noreferrer"&gt;Asynclay&lt;/a&gt;, a developer tool I'm building for background HTTP jobs.&lt;/p&gt;

&lt;p&gt;The idea is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your application
       |
       | POST job
       v
    Asynclay
       |
       | PostgreSQL queue
       | Scheduling
       | Retries
       | Recovery
       v
Your HTTP endpoint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of deploying and maintaining a separate queue and worker infrastructure, an application submits an HTTP job and lets Asynclay handle its execution.&lt;/p&gt;

&lt;p&gt;It's intentionally narrower than a full workflow engine.&lt;/p&gt;

&lt;p&gt;The goal isn't to replace BullMQ, Celery, or other established tools.&lt;/p&gt;

&lt;p&gt;It's to make one specific use case easier:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"I already have an HTTP endpoint. I just need it executed later, reliably."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm currently looking for developers willing to try it and share feedback, especially around reliability, developer experience, and where this approach might fall short.&lt;/p&gt;

&lt;p&gt;You can check it out at &lt;a href="https://asynclay.com" rel="noopener noreferrer"&gt;asynclay.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;What started as a simple PostgreSQL polling loop turned into a practical exercise in concurrency control and distributed systems.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; was the foundation, but the most valuable lessons came from handling failures around it.&lt;/p&gt;

&lt;p&gt;If you're building a queue yourself, my biggest recommendation is to think about the failure cases before optimizing throughput.&lt;/p&gt;

&lt;p&gt;Ask yourself:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if the worker crashes at every possible point in the execution flow?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answers will tell you far more about the reliability of your system than a successful benchmark.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you used PostgreSQL for background jobs in production? I'd be interested to hear how you handled concurrency, worker recovery, and duplicate execution.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>node</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I didn't want Redis + workers just to run an HTTP request later</title>
      <dc:creator>David</dc:creator>
      <pubDate>Wed, 30 Sep 2026 16:00:11 +0000</pubDate>
      <link>https://dev.to/heydavy/i-didnt-want-redis-workers-just-to-run-an-http-request-later-1b9n</link>
      <guid>https://dev.to/heydavy/i-didnt-want-redis-workers-just-to-run-an-http-request-later-1b9n</guid>
      <description>&lt;p&gt;A common requirement in backend applications sounds simple:&lt;/p&gt;

&lt;p&gt;"Do this later."&lt;/p&gt;

&lt;p&gt;Maybe it's sending an email, processing an order, calling another service, generating something asynchronously or retrying a failed operation.&lt;/p&gt;

&lt;p&gt;But the architecture can quickly become:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;API → Redis → Queue → Worker → Retry Logic → Monitoring&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;There's nothing wrong with that architecture. For complex background processing, it's often exactly what you want.&lt;/p&gt;

&lt;p&gt;But I started wondering about the simpler case.&lt;/p&gt;

&lt;p&gt;What if the job is already representable as an HTTP request?&lt;/p&gt;

&lt;p&gt;That's the idea behind a project I've been building called &lt;strong&gt;Asynclay&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The model is intentionally narrow:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;application → Asynclay → HTTP endpoint&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The application submits a target URL, payload and optional execution time.&lt;/p&gt;

&lt;p&gt;Asynclay persists the job, executes it independently, retries failures and records what happened.&lt;/p&gt;

&lt;p&gt;Building it led me much deeper into distributed systems than I expected.&lt;/p&gt;

&lt;p&gt;The dispatcher uses PostgreSQL &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; to allow multiple instances to claim work without normally processing the same job concurrently.&lt;/p&gt;

&lt;p&gt;Jobs use leases so a worker dying halfway through execution doesn't leave them stuck forever.&lt;/p&gt;

&lt;p&gt;Delivery is at-least-once rather than pretending exactly-once HTTP delivery exists, so targets receive a stable job identifier they can use for idempotency.&lt;/p&gt;

&lt;p&gt;Retries use backoff and failed executions remain observable.&lt;/p&gt;

&lt;p&gt;And because users can provide arbitrary target URLs, the executor also needs to treat every outbound request as potentially hostile — including SSRF, private IP ranges, DNS changes and redirects.&lt;/p&gt;

&lt;p&gt;The result is deliberately not an alternative to every queue or workflow system.&lt;/p&gt;

&lt;p&gt;If you need complex workflows, CPU-heavy processing, custom workers or complete control over infrastructure, tools such as BullMQ and dedicated workers make much more sense.&lt;/p&gt;

&lt;p&gt;The use case I'm interested in is narrower:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"I have an HTTP endpoint. Execute it later and make sure failures are retried."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm reaching the stage where feedback from actual developers is more useful than adding another feature.&lt;/p&gt;

&lt;p&gt;Asynclay is available here:&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://asynclay.com/" rel="noopener noreferrer" class="c-link"&gt;
            Asynclay — Schedule HTTP Jobs &amp;amp; Webhooks With Automatic Retries
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Schedule delayed HTTP callbacks with automatic retries, idempotency and full execution logs through a simple REST API. Free tier included.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fasynclay.com%2Ffavicon.ico" width="16" height="16"&gt;
          asynclay.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;I'd particularly like to hear where you think this model breaks down — or what would prevent you from trusting an external service with background execution.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>api</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
