How/why to Sweep Async Tasks Under a Postgres Table
Key topics
The debate is heating up around using Postgres tables to manage async tasks, with the original article exploring how this unconventional approach can simplify task management. Commenters are riffing on the trade-offs between leveraging Postgres's reliability and the added complexity of task management within a database. Some are chiming in with their own experiences using similar methods, while others are pointing out potential pitfalls, such as added latency and database load. As async task management continues to be a pressing concern for developers, this discussion is sparking fresh insights into the Postgres-as-task-queue paradigm.
Snapshot generated from the HN discussion
Discussion Activity
Moderate engagementFirst comment
6d
Peak period
8
Day 6
Avg / period
4
Based on 12 loaded comments
Key moments
- 01Story posted
Nov 21, 2025 at 1:28 PM EST
about 2 months ago
Step 01 - 02First comment
Nov 27, 2025 at 4:21 AM EST
6d after posting
Step 02 - 03Peak activity
8 comments in Day 6
Hottest window of the conversation
Step 03 - 04Latest activity
Dec 1, 2025 at 10:55 AM EST
about 1 month ago
Step 04
Generating AI Summary...
Analyzing up to 500 comments to identify key contributors and discussion patterns
Want the full context?
Jump to the original sources
Read the primary article or dive into the live Hacker News thread when you're ready.
I do not agree that it is complex but that’s what I’d hypothesize as why someone would think that.
It does rely on redis, but it's basically the same idea.
It looks like multiple task rows are being retrieved via a delete...returning statement, and for each row an email being sent. If there's an error, the delete statement is rolled back.
Let's hypothesize that a batch of ten tasks are retrieved, and the 9th has a bad email address, so the batch gets rolled back on error. Next retry the welcome email would be sent again for the ones that succeeded, right?
Even marking the task as "processed" with the tx in the task code wouldn't work, because that update statement would also get rolled back.
Am I missing something? (entirely possible, the code is written in "clever" style that makes it harder for me to understand, especially the bit where $sql(usr_id) is passed into the sql template before it's been returned, is there CTE magic there that I don't understand?)
I thought that this was the reason that most systems like this only pull one row at a time from the queue with skip locked...
Thanks to anyone who can clarify this for me if it is indeed correct!
It's a confusing way to do do things to me, like, why not select ordered by task date limit 1? Still using for update and skip locked etc... hold the transaction, and update to 'complete' or delete/move the row when done? What's the advantage of the inner select like that?
And I'm still totally confused by:
const [{ usr_id } = { usr_id: null }] = await sql` with usr_ as ( insert into usr (email, password) values (${email}, crypt(${password}, gen_salt('bf'))) returning * ), task_ as ( insert into task (task_type, params) values ('SEND_EMAIL_WELCOME', ${sql({ usr_id })}) ) select * from usr_ `;
This looks to me like you'd always be passing a null usr_id?
Turns out the article advocates exactly that. The example uses CTEs with multi-table inserts. "Dumb" here means "no synchronous external service calls," not "avoid complex SQL."
Fun fact: A query like this will, once in a blue moon, return more than limit (here 1) row, since the inner query is executed multiple times and returns different ids, which is surprising for a lot of people. If your code does not expect that, it may cause problems. (The article seems to, since it uses a list and iteration to handle the result.)
You can avoid that by using a materialized Common Table Expression. https://stackoverflow.com/questions/73966670/select-for-upda...Also, if your tasks take a long time, it will create long-running transactions, which may cause dead tuple problems. If you need to avoid that, you can mark the task as "running" in a short-lived transaction and delete it in another. It becomes more complicated then, since you need to handle the case that your application dies while it has taken a task.
2 more comments available on Hacker News