Skip to main content
Stackbastion

Draft, not yet published

Background jobs and queues: what AI app builders don't set up for you

7 July 2026· 6 min read · by Stackbastion

A user signs up and your app sends a welcome email, resizes their avatar, and calls a third-party API, all inside the same web request. Most of the time it works. Then one day the email provider is slow, the request hangs for 30 seconds, and the user gets an error even though their account was created fine. That’s what happens when slow work runs in the request instead of behind it.

Why this happens

When an AI tool builds a feature, it puts everything in one place: the route handler. Receive the request, do all the work, send the response. It reads clean and it demos fine, because on your laptop the email sends in 200 milliseconds.

Production is different. That email call now crosses the internet to a provider that occasionally takes 10 seconds. Your web server holds the connection open the whole time. Three things go wrong:

  • Timeouts. Most hosts cut off a request after 30 or 60 seconds. Slow work gets killed halfway, and the user sees a 500.
  • Lost work. If the email fails, the whole request fails, even though the signup itself succeeded. Or the account is made but the welcome email silently never sends. There’s no retry.
  • A blocked server. Each hanging request ties up a worker. Enough of them and your whole app stops responding, because every worker is stuck waiting on a slow API.

The fix is old and boring: do the slow part after you’ve answered the user. Save “send welcome email to user 812” as a job, return the response immediately, and let a separate worker pick the job up and run it. If it fails, the worker retries. AI builders almost never set this up, because the naive version works well enough to ship.

How to fix it

You don’t need Redis or a hosted queue service to start. If you already run Postgres, it can be your queue. A single table plus FOR UPDATE SKIP LOCKED gives you a real job queue that survives restarts and lets multiple workers run side by side without stepping on each other.

Step 1: the jobs table

CREATE TABLE jobs (
  id          bigserial PRIMARY KEY,
  type        text        NOT NULL,
  payload     jsonb       NOT NULL,
  run_at      timestamptz NOT NULL DEFAULT now(),
  attempts    int         NOT NULL DEFAULT 0,
  max_attempts int        NOT NULL DEFAULT 5,
  locked_at   timestamptz,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- Fast lookup for the next job that's due and not locked
CREATE INDEX idx_jobs_ready ON jobs (run_at) WHERE locked_at IS NULL;

Step 2: enqueue instead of doing the work inline

In your signup handler, don’t send the email. Just record that it needs sending, then respond:

// Inside the request: fast, no external calls
await db.query(
  `INSERT INTO jobs (type, payload) VALUES ($1, $2)`,
  ["send_welcome_email", { userId: user.id, email: user.email }]
);
return res.status(201).json({ ok: true }); // user gets an instant response

Step 3: a worker that claims and runs jobs

Run this as a separate process. The magic is FOR UPDATE SKIP LOCKED: it grabs one due job and locks its row, and any other worker running at the same time simply skips that row and takes the next one. No two workers ever run the same job.

async function claimAndRun(db, handlers) {
  const { rows } = await db.query(`
    UPDATE jobs
    SET locked_at = now(), attempts = attempts + 1
    WHERE id = (
      SELECT id FROM jobs
      WHERE locked_at IS NULL AND run_at <= now()
      ORDER BY run_at
      FOR UPDATE SKIP LOCKED
      LIMIT 1
    )
    RETURNING *;
  `);

  const job = rows[0];
  if (!job) return; // nothing to do right now

  try {
    await handlers[job.type](job.payload);
    await db.query(`DELETE FROM jobs WHERE id = $1`, [job.id]); // done
  } catch (err) {
    console.error(`Job ${job.id} (${job.type}) failed:`, err.message);
    if (job.attempts >= job.max_attempts) {
      // Give up, but keep the row for inspection instead of losing it
      await db.query(
        `UPDATE jobs SET locked_at = NULL, run_at = 'infinity' WHERE id = $1`,
        [job.id]
      );
    } else {
      // Back off: retry later, with the delay growing each attempt
      const backoffSeconds = Math.min(3600, 2 ** job.attempts * 10);
      await db.query(
        `UPDATE jobs SET locked_at = NULL, run_at = now() + ($1 || ' seconds')::interval WHERE id = $2`,
        [backoffSeconds, job.id]
      );
    }
  }
}

// Poll a few times a second
const handlers = {
  send_welcome_email: async ({ email }) => { /* call your email API */ },
};
setInterval(() => claimAndRun(db, handlers).catch(console.error), 500);

That’s a working queue with retries and exponential backoff, in one table and one loop. Failed jobs back off and try again, and after max_attempts they get parked with run_at = 'infinity' so you can look at them instead of losing them.

When to reach for a dedicated queue

If you outgrow this, BullMQ (backed by Redis) gives you scheduling, priorities, and a dashboard for a few lines of setup:

import { Queue } from "bullmq";
const emailQueue = new Queue("email", { connection: { host: "127.0.0.1", port: 6379 } });
await emailQueue.add("welcome", { userId: 812 }, { attempts: 5, backoff: { type: "exponential", delay: 10000 } });

But don’t add Redis just to send a welcome email. Start with the Postgres table. Move up only when volume or scheduling needs force it. That’s the KISS answer.

Or, we do it for you

Moving slow work off the request path, with retries and a worker that actually stays up, is standard in every production setup we run. If your app does emails, image processing, or third-party calls inside the request, get a free audit and we’ll show you what’s at risk.

FAQ

Is a Postgres queue really good enough for production?

For most small apps, yes. SKIP LOCKED is a proven pattern that handles thousands of jobs an hour without breaking a sweat. You get retries, durability, and no extra infrastructure. Reach for Redis-based tools when you need very high throughput or complex scheduling.

What happens to jobs if the worker crashes mid-run?

The row stays locked with a locked_at timestamp. Add a small sweeper that clears locks older than a few minutes so those jobs become claimable again. That way a crashed worker doesn’t strand its job forever.

Should the worker run in the same process as my web server?

Keep it separate if you can. A separate process means a stuck job can’t tie up the web server, and you can scale workers independently. On a small setup, a second process on the same machine is fine.

How do I avoid running the same job twice?

Make your handlers idempotent, meaning running one twice does no harm. Check “did I already send this email?” before sending, or use a unique key. Any queue can deliver a job twice after a crash, so idempotency is your real safety net.