{"article":{"slug":"building-a-job-queue-in-postgres-with-skip-locked","title":"Building a Job Queue in Postgres with SKIP LOCKED","subtitle":null,"summary":"Michael Guay walks through running background jobs in Postgres instead of Redis: the jobs table, claiming work safely with SKIP LOCKED, leases for crashed workers, retries with backoff, dedup, LISTEN/NOTIFY wakeups, and when not to.","content_type":"tutorial","language":"en","canonical_url":"https://learn.michaelguay.dev/blog/building-a-job-queue-in-postgres-with-skip-locked","author":{"name":"Michael Guay","url":"https://learn.michaelguay.dev/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Michael Guay","url":"https://learn.michaelguay.dev/","listing_slug":null,"listing":null},"topics":[{"name":"Databases","slug":"databases","url":"https://listedarticles.com/topics/databases"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Software Engineering","slug":"software-engineering","url":"https://listedarticles.com/topics/software-engineering"},{"name":"Tutorials","slug":"tutorials","url":"https://listedarticles.com/topics/tutorials"},{"name":"Infrastructure","slug":"infrastructure","url":"https://listedarticles.com/topics/infrastructure"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2138,"reading_minutes":9,"published_at":"2026-10-04T00:00:00.000Z","added_at":"2026-10-04T23:17:04.393Z","updated_at":"2026-10-04T23:17:04.393Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/building-a-job-queue-in-postgres-with-skip-locked","markdown_url":"https://listedarticles.com/articles/building-a-job-queue-in-postgres-with-skip-locked.md","example":false,"citation":"Michael Guay, Michael Guay. \"Building a Job Queue in Postgres with SKIP LOCKED.\" 4 Oct 2026. https://learn.michaelguay.dev/blog/building-a-job-queue-in-postgres-with-skip-locked (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://learn.michaelguay.dev/blog/building-a-job-queue-in-postgres-with-skip-locked"},"body_markdown":"When you need background jobs, the usual answer is Redis plus a queue library. But if you\nalready run Postgres, you may not need another service at all. Postgres has everything a\nreliable job queue needs: row locks, transactions, `SKIP LOCKED` and `LISTEN/NOTIFY`.\n\nI use one on my course platform to send hundreds of videos through a rate-limited video API. It's one table and a small worker. This post covers the pattern in general: the table, how workers claim jobs safely, retries, crash recovery, deduplication, waking workers without polling, and when you should reach for something else.\n\n## Why a queue in Postgres?\n\n- **One less service.** No Redis to run, back up, secure and monitor.\n- **Transactional enqueue.** Insert the job in the same transaction as the data it's about.\nIf the transaction rolls back, the job never existed. With a separate queue, you can save\nan order and lose its \"send receipt\" job, or send a receipt for an order that rolled back.\n- **You can query it.** It's a table. \"Which jobs failed today, and why?\" is a`SELECT` .\n- **It's fast enough** for most apps: thousands of jobs a minute on ordinary hardware.\n\n## The table\n\n```\ncreate table jobs (\n  id          bigint generated always as identity primary key,\n  kind        text        not null,               -- what to run: 'send_email', 'resize_image'...\n  payload     jsonb       not null default '{}',  -- its arguments\n  status      text        not null default 'queued'\n              check (status in ('queued', 'running', 'done', 'failed')),\n  run_at      timestamptz not null default now(), -- not before this\n  attempts    int         not null default 0,\n  max_attempts int        not null default 5,\n  locked_by   uuid,                               -- the worker holding it\n  last_error  text,\n  created_at  timestamptz not null default now()\n);\n-- Workers only ever look for jobs that could run now.\ncreate index jobs_ready_idx on jobs (run_at) where status in ('queued', 'running');\n```\nThe column doing the most work is `run_at`. It means \"not before this time\", and it serves\nthree purposes:\n\n- A **new job** runs at`now()` , or later if you schedule it.\n- A **failed job** waiting to retry gets a`run_at` in the future (backoff).\n- A **running job** gets a`run_at` at the end of its lease. If the worker dies, the job\nbecomes claimable again when the lease runs out (more on that below).\n\nThe index is partial: finished jobs don't bloat it, so it stays small however long the table gets.\n\n## Claiming a job with `FOR UPDATE SKIP LOCKED`\n\nThe core problem: several workers poll the same table. Two of them must never run the same job, and they shouldn't wait on each other either.\n\n`SELECT ... FOR UPDATE` locks the rows it returns. `SKIP LOCKED` (Postgres 9.5+) tells the\nquery to skip rows another transaction has already locked, instead of waiting for them. Put\nthat in a subquery, and claim the job in one statement:\n\n```\nupdate jobs\nset status    = 'running',\n    attempts  = attempts + 1,\n    locked_by = $1,                          -- this worker's id\n    run_at    = now() + interval '5 minutes' -- the lease\nwhere id = (\n  select id\n  from jobs\n  where status in ('queued', 'running')\n    and run_at <= now()\n  order by run_at\n  limit 1\n  for update skip locked\n)\nreturning *;\n```\nOne round trip, one atomic statement. If two workers run it at the same moment, the second one skips the row the first one locked and takes the next job. If nothing is due, it returns no rows.\n\nTo claim a batch, change `limit 1` to `limit 10` and `where id = (...)` to `where id in (...)`.\n\nWhy `status in ('queued', 'running')`? Because a running job whose lease has expired is\nclaimable again. That's the crash recovery, built into the same query.\n\n## Leases: surviving crashed workers\n\nA worker can die mid-job: a deploy, an out-of-memory kill, a lost connection. A queue that\nonly marks jobs `running` leaks those jobs forever. My own first version had exactly this\ngap: a deploy in the middle of a job would have left it `running`, with nothing running it.\n\nThe lease fixes it. Claiming a job sets `run_at` to \"now plus five minutes\". If the worker\nfinishes, it marks the job done. If it dies, nothing marks it, the lease expires, and the\nclaim query picks the job up again.\n\nTwo rules make this safe:\n\n1. **The lease must outlast the job.** If a job can take ten minutes, a five-minute lease\nlets a second worker start it while the first is still going. For long jobs, have the\nworker extend its lease as it goes (`update jobs set run_at = now() + interval '5 minutes' where id = $1 and locked_by = $2` ).\n2. **Only the lease holder may finish the job.** That's what`locked_by` is for. If a slow\nworker's lease expired and another worker took the job over, the slow one must not mark\nit done:\n\n```\nupdate jobs\nset status = 'done', locked_by = null\nwhere id = $1 and locked_by = $2;  -- 0 rows: we lost the lease; someone else has it\n```\n## The worker loop\n\nThe worker is a loop: claim, run, record the outcome. Here it is in TypeScript with the\n[`postgres`](https://github.com/porsager/postgres) driver. Any language looks the same.\n\n```\nimport postgres from 'postgres';\nconst sql = postgres(process.env.DATABASE_URL!);\nconst workerId = crypto.randomUUID();\ntype Job = { id: number; kind: string; payload: unknown; attempts: number; max_attempts: number };\nconst handlers: Record<string, (payload: any) => Promise<void>> = {\n  send_email: async (payload) => { /* ... */ },\n};\nasync function claim(): Promise<Job | undefined> {\n  const [job] = await sql<Job[]>`\n    update jobs\n    set status = 'running', attempts = attempts + 1, locked_by = ${workerId},\n        run_at = now() + interval '5 minutes'\n    where id = (\n      select id from jobs\n      where status in ('queued', 'running') and run_at <= now()\n      order by run_at limit 1\n      for update skip locked\n    )\n    returning *`;\n  return job;\n}\nasync function work() {\n  for (;;) {\n    const job = await claim();\n    if (!job) {\n      await Bun.sleep(1000); // nothing due: wait a moment (or LISTEN, below)\n      continue;\n    }\n    try {\n      await handlers[job.kind]!(job.payload);\n      await sql`update jobs set status = 'done', locked_by = null\n                where id = ${job.id} and locked_by = ${workerId}`;\n    } catch (error) {\n      await fail(job, error as Error);\n    }\n  }\n}\n```\nRun as many of these as you like, in one process or many. `SKIP LOCKED` keeps them out of\neach other's way.\n\n## Retries with backoff (and when not to retry)\n\nNot every failure deserves another try. A timeout, a 429 or a 5xx from an API will probably pass. A validation error won't, and retrying it only wastes time and rate limit. Decide which is which, and retry only the first kind:\n\n```\nfunction isRetryable(error: unknown): boolean {\n  const status = (error as { status?: number }).status;\n  if (status !== undefined) return status === 429 || status >= 500;\n  return /timed? ?out|ECONNRESET|temporar/i.test(String(error));\n}\nasync function fail(job: Job, error: Error) {\n  const again = isRetryable(error) && job.attempts < job.max_attempts;\n  // Exponential backoff with jitter: about 2s, 4s, 8s... capped at 10 minutes.\n  const delay = Math.min(2 ** job.attempts, 600) * (0.5 + Math.random());\n  await sql`\n    update jobs\n    set status     = ${again ? 'queued' : 'failed'},\n        run_at     = now() + ${delay} * interval '1 second',\n        last_error = ${error.message},\n        locked_by  = null\n    where id = ${job.id} and locked_by = ${workerId}`;\n}\n```\nThe jitter (`0.5 + Math.random()`) matters when many jobs fail at once, for example when an\nAPI goes down. Without it they all retry at the same instant and knock it over again.\n\nJobs that run out of attempts stay in the table as `failed`, with their last error. That's\nyour dead-letter queue, and you can query it:\n\n```\nselect kind, last_error, count(*)\nfrom jobs\nwhere status = 'failed' and created_at > now() - interval '1 day'\ngroup by 1, 2\norder by 3 desc;\n```\nTo retry one by hand: `update jobs set status = 'queued', run_at = now(), attempts = 0 where id = $1`.\n\n## Enqueueing, and not enqueueing twice\n\nEnqueueing is an insert, ideally inside the transaction that made the job necessary:\n\n```\nawait sql.begin(async (tx) => {\n  const [order] = await tx`insert into orders ${tx(order)} returning id`;\n  await tx`insert into jobs (kind, payload) values ('send_receipt', ${{ orderId: order.id }})`;\n});\n```\nOften you also want **at most one pending job per thing**: one \"reindex this course\", not\nfive because someone clicked five times. Add a dedupe key and a partial unique index over\nthe jobs that are still pending:\n\n```\nalter table jobs add column dedupe_key text;\ncreate unique index jobs_pending_dedupe_idx\n  on jobs (kind, dedupe_key)\n  where status in ('queued', 'running');\n```\n```\ninsert into jobs (kind, payload, dedupe_key)\nvalues ('reindex_course', '{\"courseId\": 42}', 'course:42')\non conflict (kind, dedupe_key) where status in ('queued', 'running') do nothing;\n```\nThe second click is a no-op while the first job is pending. Once it's done, a new one can be queued.\n\n## Waking workers with `LISTEN/NOTIFY`\n\nPolling every second is simple and fine for most apps, but it adds up to a second of delay and a steady trickle of queries. Postgres can tell workers when there's work instead:\n\n```\ncreate function jobs_notify() returns trigger as $$\nbegin\n  perform pg_notify('jobs', new.kind);\n  return new;\nend;\n$$ language plpgsql;\ncreate trigger jobs_notify after insert on jobs\nfor each row execute function jobs_notify();\n```\n```\nlet wake = () => {};\nawait sql.listen('jobs', () => wake());\n// In the worker loop, instead of a fixed sleep:\nawait Promise.race([\n  new Promise<void>((resolve) => (wake = resolve)),\n  Bun.sleep(30_000), // still poll now and then: retries and scheduled jobs don't notify\n]);\n```\nThree things to know:\n\n- **Notifications are sent on commit.** A job inserted in a transaction that rolls back\nnever wakes anyone.\n- **Keep polling as a fallback.** Notifications aren't stored. A worker that was\nreconnecting misses them, and jobs whose`run_at` arrives (retries, scheduled jobs) don't\nsend one.\n- **`LISTEN` needs a dedicated connection.** It doesn't work through PgBouncer in\ntransaction pooling mode. Connect the listener directly to Postgres.\n\n## Limiting concurrency and rate\n\nSometimes the limit isn't your workers, it's what they call. My video jobs call an API that allows about one request a second and gets overloaded with too many videos processing at once. Two cheap ways to respect limits like that:\n\n- **Global concurrency:** count before claiming, and only claim while there's room:\n`select count(*) from jobs where kind = 'process_video' and status = 'running' and run_atnow() `. With several workers, take a transaction-scoped advisory lock first   (` select pg_advisory_xact_lock(hashtext('process_video'))`) so they don't all see room at\nonce.\n- **Rate:** for a single worker, sleep between calls. For several, store the time of the\nlast call in a one-row table and update it with`select ... for update` , so one worker at\na time decides who may go next.\n\nAnd before an expensive call, check whether it's still needed. A job that waited ten minutes for a retry may find the work already done by something else in the meantime.\n\n## Keeping the table healthy\n\nA queue table sees a lot of updates, and every Postgres update writes a new row version. The old versions are dead tuples until vacuum cleans them up. Keep it in check:\n\n- **Delete or archive finished jobs.** A nightly`delete from jobs where status = 'done' and created_at < now() - interval '7 days'` keeps the table small. Keep failed ones longer.\n- **Let autovacuum keep up.** For a busy queue, make it run more often on this table:`alter table jobs set (autovacuum_vacuum_scale_factor = 0.01)` .\n- **Keep the index partial,** as above. The workers' index then only holds pending jobs,\nhowever many finished ones the table holds.\n\n## Things to get right in your handlers\n\n- **At least once, not exactly once.** A worker can finish the work and die before marking\nthe job done; the lease brings it back and it runs again. Write handlers that are safe to\nrun twice: check before acting, use idempotency keys with external APIs, upsert instead of\ninsert.\n- **Keep jobs small.** One job per email, per image, per video. Big jobs hold leases for\nlong, retry expensively and make failures harder to read.\n- **Store ids, not data,** in the payload. Load the current state when the job runs; the\ndata may have changed since it was queued.\n\n## When not to use Postgres as a queue\n\nPostgres is a good queue when:\n\n- jobs number in the thousands a minute, not hundreds of thousands a second;\n- each job does real work (an HTTP call, a file, an email), so the queue isn't the bottleneck;\n- you already run Postgres, and want enqueueing to be part of your transactions.\n\nUse a dedicated broker (Redis/BullMQ, RabbitMQ, SQS, Kafka) when you need very high throughput, fan-out to many consumers, or millisecond latency at scale, or when the queue's load would compete with your main database.\n\n## Libraries that do this for you\n\nIf you'd rather not write it yourself, these use exactly this pattern:\n\n- **Node.js:**[pg-boss](https://github.com/timgit/pg-boss) ,[Graphile Worker](https://worker.graphile.org/)\n- **Go:**[River](https://riverqueue.com/)\n- **Elixir:**[Oban](https://getoban.pro/)\n- **Ruby on Rails:**[Solid Queue](https://github.com/rails/solid_queue)\n- **Postgres extension:**[pgmq](https://github.com/pgmq/pgmq)\n\nWriting your own is still worth it when your needs are small and specific, like mine: about\na hundred lines, no new dependency, and a table you can read in the admin page. Either way,\nknowing how `SKIP LOCKED`, leases and backoff fit together makes any of them easier to run.\n","body_html":"<p>When you need background jobs, the usual answer is Redis plus a queue library. But if you\nalready run Postgres, you may not need another service at all. Postgres has everything a\nreliable job queue needs: row locks, transactions, <code>SKIP LOCKED</code> and <code>LISTEN/NOTIFY</code>.</p>\n<p>I use one on my course platform to send hundreds of videos through a rate-limited video API. It&#39;s one table and a small worker. This post covers the pattern in general: the table, how workers claim jobs safely, retries, crash recovery, deduplication, waking workers without polling, and when you should reach for something else.</p>\n<h2 id=\"why-a-queue-in-postgres\">Why a queue in Postgres?</h2>\n<ul><li><strong>One less service.</strong> No Redis to run, back up, secure and monitor.</li><li><p><strong>Transactional enqueue.</strong> Insert the job in the same transaction as the data it&#39;s about.</p><p>If the transaction rolls back, the job never existed. With a separate queue, you can save\nan order and lose its &quot;send receipt&quot; job, or send a receipt for an order that rolled back.</p></li><li><strong>You can query it.</strong> It&#39;s a table. &quot;Which jobs failed today, and why?&quot; is a<code>SELECT</code> .</li><li><strong>It&#39;s fast enough</strong> for most apps: thousands of jobs a minute on ordinary hardware.</li></ul>\n<h2 id=\"the-table\">The table</h2>\n<pre><code>create table jobs (\n  id          bigint generated always as identity primary key,\n  kind        text        not null,               -- what to run: &#39;send_email&#39;, &#39;resize_image&#39;...\n  payload     jsonb       not null default &#39;{}&#39;,  -- its arguments\n  status      text        not null default &#39;queued&#39;\n              check (status in (&#39;queued&#39;, &#39;running&#39;, &#39;done&#39;, &#39;failed&#39;)),\n  run_at      timestamptz not null default now(), -- not before this\n  attempts    int         not null default 0,\n  max_attempts int        not null default 5,\n  locked_by   uuid,                               -- the worker holding it\n  last_error  text,\n  created_at  timestamptz not null default now()\n);\n-- Workers only ever look for jobs that could run now.\ncreate index jobs_ready_idx on jobs (run_at) where status in (&#39;queued&#39;, &#39;running&#39;);</code></pre>\n<p>The column doing the most work is <code>run_at</code>. It means &quot;not before this time&quot;, and it serves\nthree purposes:</p>\n<ul><li>A <strong>new job</strong> runs at<code>now()</code> , or later if you schedule it.</li><li>A <strong>failed job</strong> waiting to retry gets a<code>run_at</code> in the future (backoff).</li><li><p>A <strong>running job</strong> gets a<code>run_at</code> at the end of its lease. If the worker dies, the job</p><p>becomes claimable again when the lease runs out (more on that below).</p></li></ul>\n<p>The index is partial: finished jobs don&#39;t bloat it, so it stays small however long the table gets.</p>\n<h2 id=\"claiming-a-job-with-for-update-skip-locked\">Claiming a job with <code>FOR UPDATE SKIP LOCKED</code></h2>\n<p>The core problem: several workers poll the same table. Two of them must never run the same job, and they shouldn&#39;t wait on each other either.</p>\n<p><code>SELECT ... FOR UPDATE</code> locks the rows it returns. <code>SKIP LOCKED</code> (Postgres 9.5+) tells the\nquery to skip rows another transaction has already locked, instead of waiting for them. Put\nthat in a subquery, and claim the job in one statement:</p>\n<pre><code>update jobs\nset status    = &#39;running&#39;,\n    attempts  = attempts + 1,\n    locked_by = $1,                          -- this worker&#39;s id\n    run_at    = now() + interval &#39;5 minutes&#39; -- the lease\nwhere id = (\n  select id\n  from jobs\n  where status in (&#39;queued&#39;, &#39;running&#39;)\n    and run_at &lt;= now()\n  order by run_at\n  limit 1\n  for update skip locked\n)\nreturning *;</code></pre>\n<p>One round trip, one atomic statement. If two workers run it at the same moment, the second one skips the row the first one locked and takes the next job. If nothing is due, it returns no rows.</p>\n<p>To claim a batch, change <code>limit 1</code> to <code>limit 10</code> and <code>where id = (...)</code> to <code>where id in (...)</code>.</p>\n<p>Why <code>status in (&#39;queued&#39;, &#39;running&#39;)</code>? Because a running job whose lease has expired is\nclaimable again. That&#39;s the crash recovery, built into the same query.</p>\n<h2 id=\"leases-surviving-crashed-workers\">Leases: surviving crashed workers</h2>\n<p>A worker can die mid-job: a deploy, an out-of-memory kill, a lost connection. A queue that\nonly marks jobs <code>running</code> leaks those jobs forever. My own first version had exactly this\ngap: a deploy in the middle of a job would have left it <code>running</code>, with nothing running it.</p>\n<p>The lease fixes it. Claiming a job sets <code>run_at</code> to &quot;now plus five minutes&quot;. If the worker\nfinishes, it marks the job done. If it dies, nothing marks it, the lease expires, and the\nclaim query picks the job up again.</p>\n<p>Two rules make this safe:</p>\n<ol><li><p><strong>The lease must outlast the job.</strong> If a job can take ten minutes, a five-minute lease</p><p>lets a second worker start it while the first is still going. For long jobs, have the\nworker extend its lease as it goes (<code>update jobs set run_at = now() + interval &#39;5 minutes&#39; where id = $1 and locked_by = $2</code> ).</p></li><li><p><strong>Only the lease holder may finish the job.</strong> That&#39;s what<code>locked_by</code> is for. If a slow</p><p>worker&#39;s lease expired and another worker took the job over, the slow one must not mark\nit done:</p></li></ol>\n<pre><code>update jobs\nset status = &#39;done&#39;, locked_by = null\nwhere id = $1 and locked_by = $2;  -- 0 rows: we lost the lease; someone else has it</code></pre>\n<h2 id=\"the-worker-loop\">The worker loop</h2>\n<p>The worker is a loop: claim, run, record the outcome. Here it is in TypeScript with the\n<a href=\"https://github.com/porsager/postgres\" rel=\"nofollow ugc noopener\"><code>postgres</code></a> driver. Any language looks the same.</p>\n<pre><code>import postgres from &#39;postgres&#39;;\nconst sql = postgres(process.env.DATABASE_URL!);\nconst workerId = crypto.randomUUID();\ntype Job = { id: number; kind: string; payload: unknown; attempts: number; max_attempts: number };\nconst handlers: Record&lt;string, (payload: any) =&gt; Promise&lt;void&gt;&gt; = {\n  send_email: async (payload) =&gt; { /* ... */ },\n};\nasync function claim(): Promise&lt;Job | undefined&gt; {\n  const [job] = await sql&lt;Job[]&gt;`\n    update jobs\n    set status = &#39;running&#39;, attempts = attempts + 1, locked_by = ${workerId},\n        run_at = now() + interval &#39;5 minutes&#39;\n    where id = (\n      select id from jobs\n      where status in (&#39;queued&#39;, &#39;running&#39;) and run_at &lt;= now()\n      order by run_at limit 1\n      for update skip locked\n    )\n    returning *`;\n  return job;\n}\nasync function work() {\n  for (;;) {\n    const job = await claim();\n    if (!job) {\n      await Bun.sleep(1000); // nothing due: wait a moment (or LISTEN, below)\n      continue;\n    }\n    try {\n      await handlers[job.kind]!(job.payload);\n      await sql`update jobs set status = &#39;done&#39;, locked_by = null\n                where id = ${job.id} and locked_by = ${workerId}`;\n    } catch (error) {\n      await fail(job, error as Error);\n    }\n  }\n}</code></pre>\n<p>Run as many of these as you like, in one process or many. <code>SKIP LOCKED</code> keeps them out of\neach other&#39;s way.</p>\n<h2 id=\"retries-with-backoff-and-when-not-to-retry\">Retries with backoff (and when not to retry)</h2>\n<p>Not every failure deserves another try. A timeout, a 429 or a 5xx from an API will probably pass. A validation error won&#39;t, and retrying it only wastes time and rate limit. Decide which is which, and retry only the first kind:</p>\n<pre><code>function isRetryable(error: unknown): boolean {\n  const status = (error as { status?: number }).status;\n  if (status !== undefined) return status === 429 || status &gt;= 500;\n  return /timed? ?out|ECONNRESET|temporar/i.test(String(error));\n}\nasync function fail(job: Job, error: Error) {\n  const again = isRetryable(error) &amp;&amp; job.attempts &lt; job.max_attempts;\n  // Exponential backoff with jitter: about 2s, 4s, 8s... capped at 10 minutes.\n  const delay = Math.min(2 ** job.attempts, 600) * (0.5 + Math.random());\n  await sql`\n    update jobs\n    set status     = ${again ? &#39;queued&#39; : &#39;failed&#39;},\n        run_at     = now() + ${delay} * interval &#39;1 second&#39;,\n        last_error = ${error.message},\n        locked_by  = null\n    where id = ${job.id} and locked_by = ${workerId}`;\n}</code></pre>\n<p>The jitter (<code>0.5 + Math.random()</code>) matters when many jobs fail at once, for example when an\nAPI goes down. Without it they all retry at the same instant and knock it over again.</p>\n<p>Jobs that run out of attempts stay in the table as <code>failed</code>, with their last error. That&#39;s\nyour dead-letter queue, and you can query it:</p>\n<pre><code>select kind, last_error, count(*)\nfrom jobs\nwhere status = &#39;failed&#39; and created_at &gt; now() - interval &#39;1 day&#39;\ngroup by 1, 2\norder by 3 desc;</code></pre>\n<p>To retry one by hand: <code>update jobs set status = &#39;queued&#39;, run_at = now(), attempts = 0 where id = $1</code>.</p>\n<h2 id=\"enqueueing-and-not-enqueueing-twice\">Enqueueing, and not enqueueing twice</h2>\n<p>Enqueueing is an insert, ideally inside the transaction that made the job necessary:</p>\n<pre><code>await sql.begin(async (tx) =&gt; {\n  const [order] = await tx`insert into orders ${tx(order)} returning id`;\n  await tx`insert into jobs (kind, payload) values (&#39;send_receipt&#39;, ${{ orderId: order.id }})`;\n});</code></pre>\n<p>Often you also want <strong>at most one pending job per thing</strong>: one &quot;reindex this course&quot;, not\nfive because someone clicked five times. Add a dedupe key and a partial unique index over\nthe jobs that are still pending:</p>\n<pre><code>alter table jobs add column dedupe_key text;\ncreate unique index jobs_pending_dedupe_idx\n  on jobs (kind, dedupe_key)\n  where status in (&#39;queued&#39;, &#39;running&#39;);</code></pre>\n<pre><code>insert into jobs (kind, payload, dedupe_key)\nvalues (&#39;reindex_course&#39;, &#39;{&quot;courseId&quot;: 42}&#39;, &#39;course:42&#39;)\non conflict (kind, dedupe_key) where status in (&#39;queued&#39;, &#39;running&#39;) do nothing;</code></pre>\n<p>The second click is a no-op while the first job is pending. Once it&#39;s done, a new one can be queued.</p>\n<h2 id=\"waking-workers-with-listen-notify\">Waking workers with <code>LISTEN/NOTIFY</code></h2>\n<p>Polling every second is simple and fine for most apps, but it adds up to a second of delay and a steady trickle of queries. Postgres can tell workers when there&#39;s work instead:</p>\n<pre><code>create function jobs_notify() returns trigger as $$\nbegin\n  perform pg_notify(&#39;jobs&#39;, new.kind);\n  return new;\nend;\n$$ language plpgsql;\ncreate trigger jobs_notify after insert on jobs\nfor each row execute function jobs_notify();</code></pre>\n<pre><code>let wake = () =&gt; {};\nawait sql.listen(&#39;jobs&#39;, () =&gt; wake());\n// In the worker loop, instead of a fixed sleep:\nawait Promise.race([\n  new Promise&lt;void&gt;((resolve) =&gt; (wake = resolve)),\n  Bun.sleep(30_000), // still poll now and then: retries and scheduled jobs don&#39;t notify\n]);</code></pre>\n<p>Three things to know:</p>\n<ul><li><p><strong>Notifications are sent on commit.</strong> A job inserted in a transaction that rolls back</p><p>never wakes anyone.</p></li><li><p><strong>Keep polling as a fallback.</strong> Notifications aren&#39;t stored. A worker that was</p><p>reconnecting misses them, and jobs whose<code>run_at</code> arrives (retries, scheduled jobs) don&#39;t\nsend one.</p></li><li><p><strong><code>LISTEN</code> needs a dedicated connection.</strong> It doesn&#39;t work through PgBouncer in</p><p>transaction pooling mode. Connect the listener directly to Postgres.</p></li></ul>\n<h2 id=\"limiting-concurrency-and-rate\">Limiting concurrency and rate</h2>\n<p>Sometimes the limit isn&#39;t your workers, it&#39;s what they call. My video jobs call an API that allows about one request a second and gets overloaded with too many videos processing at once. Two cheap ways to respect limits like that:</p>\n<ul><li><p><strong>Global concurrency:</strong> count before claiming, and only claim while there&#39;s room:</p><p><code>select count(*) from jobs where kind = &#39;process_video&#39; and status = &#39;running&#39; and run_atnow() </code>. With several workers, take a transaction-scoped advisory lock first   (<code> select pg_advisory_xact_lock(hashtext(&#39;process_video&#39;))</code>) so they don&#39;t all see room at\nonce.</p></li><li><p><strong>Rate:</strong> for a single worker, sleep between calls. For several, store the time of the</p><p>last call in a one-row table and update it with<code>select ... for update</code> , so one worker at\na time decides who may go next.</p></li></ul>\n<p>And before an expensive call, check whether it&#39;s still needed. A job that waited ten minutes for a retry may find the work already done by something else in the meantime.</p>\n<h2 id=\"keeping-the-table-healthy\">Keeping the table healthy</h2>\n<p>A queue table sees a lot of updates, and every Postgres update writes a new row version. The old versions are dead tuples until vacuum cleans them up. Keep it in check:</p>\n<ul><li><strong>Delete or archive finished jobs.</strong> A nightly<code>delete from jobs where status = &#39;done&#39; and created_at &lt; now() - interval &#39;7 days&#39;</code> keeps the table small. Keep failed ones longer.</li><li><strong>Let autovacuum keep up.</strong> For a busy queue, make it run more often on this table:<code>alter table jobs set (autovacuum_vacuum_scale_factor = 0.01)</code> .</li><li><p><strong>Keep the index partial,</strong> as above. The workers&#39; index then only holds pending jobs,</p><p>however many finished ones the table holds.</p></li></ul>\n<h2 id=\"things-to-get-right-in-your-handlers\">Things to get right in your handlers</h2>\n<ul><li><p><strong>At least once, not exactly once.</strong> A worker can finish the work and die before marking</p><p>the job done; the lease brings it back and it runs again. Write handlers that are safe to\nrun twice: check before acting, use idempotency keys with external APIs, upsert instead of\ninsert.</p></li><li><p><strong>Keep jobs small.</strong> One job per email, per image, per video. Big jobs hold leases for</p><p>long, retry expensively and make failures harder to read.</p></li><li><p><strong>Store ids, not data,</strong> in the payload. Load the current state when the job runs; the</p><p>data may have changed since it was queued.</p></li></ul>\n<h2 id=\"when-not-to-use-postgres-as-a-queue\">When not to use Postgres as a queue</h2>\n<p>Postgres is a good queue when:</p>\n<ul><li>jobs number in the thousands a minute, not hundreds of thousands a second;</li><li>each job does real work (an HTTP call, a file, an email), so the queue isn&#39;t the bottleneck;</li><li>you already run Postgres, and want enqueueing to be part of your transactions.</li></ul>\n<p>Use a dedicated broker (Redis/BullMQ, RabbitMQ, SQS, Kafka) when you need very high throughput, fan-out to many consumers, or millisecond latency at scale, or when the queue&#39;s load would compete with your main database.</p>\n<h2 id=\"libraries-that-do-this-for-you\">Libraries that do this for you</h2>\n<p>If you&#39;d rather not write it yourself, these use exactly this pattern:</p>\n<ul><li><strong>Node.js:</strong><a href=\"https://github.com/timgit/pg-boss\" rel=\"nofollow ugc noopener\">pg-boss</a> ,<a href=\"https://worker.graphile.org/\" rel=\"nofollow ugc noopener\">Graphile Worker</a></li><li><strong>Go:</strong><a href=\"https://riverqueue.com/\" rel=\"nofollow ugc noopener\">River</a></li><li><strong>Elixir:</strong><a href=\"https://getoban.pro/\" rel=\"nofollow ugc noopener\">Oban</a></li><li><strong>Ruby on Rails:</strong><a href=\"https://github.com/rails/solid_queue\" rel=\"nofollow ugc noopener\">Solid Queue</a></li><li><strong>Postgres extension:</strong><a href=\"https://github.com/pgmq/pgmq\" rel=\"nofollow ugc noopener\">pgmq</a></li></ul>\n<p>Writing your own is still worth it when your needs are small and specific, like mine: about\na hundred lines, no new dependency, and a table you can read in the admin page. Either way,\nknowing how <code>SKIP LOCKED</code>, leases and backoff fit together makes any of them easier to run.</p>","headings":[{"level":2,"text":"Why a queue in Postgres?","id":"why-a-queue-in-postgres"},{"level":2,"text":"The table","id":"the-table"},{"level":2,"text":"Claiming a job with FOR UPDATE SKIP LOCKED","id":"claiming-a-job-with-for-update-skip-locked"},{"level":2,"text":"Leases: surviving crashed workers","id":"leases-surviving-crashed-workers"},{"level":2,"text":"The worker loop","id":"the-worker-loop"},{"level":2,"text":"Retries with backoff (and when not to retry)","id":"retries-with-backoff-and-when-not-to-retry"},{"level":2,"text":"Enqueueing, and not enqueueing twice","id":"enqueueing-and-not-enqueueing-twice"},{"level":2,"text":"Waking workers with LISTEN/NOTIFY","id":"waking-workers-with-listen-notify"},{"level":2,"text":"Limiting concurrency and rate","id":"limiting-concurrency-and-rate"},{"level":2,"text":"Keeping the table healthy","id":"keeping-the-table-healthy"},{"level":2,"text":"Things to get right in your handlers","id":"things-to-get-right-in-your-handlers"},{"level":2,"text":"When not to use Postgres as a queue","id":"when-not-to-use-postgres-as-a-queue"},{"level":2,"text":"Libraries that do this for you","id":"libraries-that-do-this-for-you"}]}}