How to Add Background Jobs to an AI-Built App

A user clicks a button and the page hangs for eleven seconds. The pattern causing it is the one almost every AI-generated app starts with.

Steve Jefferson
Steve Jefferson
Developer Advocate
12 August 20261 min read

The symptom arrives first, and it is always the same one. A user clicks a button, the page hangs for eleven seconds, and sometimes it fails with a timeout instead. Nothing is broken exactly. The app is doing real work, generating a report, calling a model, resizing an image, sending twelve emails, and it is doing all of it while the browser waits. Learning how to add background jobs to an AI-built app is the fix, and the reason it comes up so often is that the pattern that causes it is the pattern almost every AI-generated app starts with.

Why AI-built apps land here by default

Ask any coding assistant to build a feature that sends a welcome email and it will write the email send inside the request handler. This is correct, in the sense that it works, and it is what the training data mostly looks like, because most tutorial code is written to be readable rather than operable.

It stays fine until one of three things happens:

  • The work takes longer than a few seconds, so users start seeing spinners and timeouts.

  • The work can fail transiently, so a failed third-party call now means a failed user action.

  • The work needs to happen on a schedule rather than in response to a click, at which point there is no request to attach it to.

The first is annoying, the second is the one that loses data, and it is worth being clear about why. When a model call inside a request handler fails, the user sees an error and retries. But the parts of the handler that already succeeded, the database row you inserted, the payment you captured, do not un-happen. You end up with half-completed operations and no record of which half.

The one metric that tells you it is time

You do not need a rule about which operations belong in the background. You need one number: the 95th percentile response time of your slowest endpoint.

Once that crosses roughly two seconds, and especially once it varies wildly between requests, you have crossed the line. Variance matters more than the average here. An endpoint that takes 400ms most of the time and 14 seconds when a model call is slow is worse than one that consistently takes 3 seconds, because the inconsistency is what produces timeouts and duplicate submissions.

If you have no instrumentation to answer that question, that is the first job, and it is a smaller one than it sounds. Adding analytics to an AI-built app covers the basics, and until you can see per-endpoint timings you are guessing about which work to move.

The three shapes of background work

Before choosing tooling, be clear about which of these you actually need, because the answers are different.

Shape

Trigger

Example

Simplest tool

Deferred task

A user action

Send welcome email, generate a summary

Queue

Scheduled task

The clock

Nightly digest, expire stale sessions

Cron

Recurring poll

The clock, checking something

Retry failed webhooks

Cron plus a status column

Most people asking about background jobs need the first. Most people who think they need a full job runner actually need the second, which is far simpler.

How to add background jobs to an AI-built app: the core pattern

Every background job system, from a hosted queue to a table you poll yourself, is the same three steps. Understanding this makes the tooling choice much less important than it feels.

  1. Record the intent durably. Before responding to the user, write a row saying what needs to happen, with a status of pending. This is the part that must be transactional with whatever else the request did.

  2. Return immediately. The user gets a response in milliseconds and a way to see progress.

  3. Process it elsewhere. A separate worker picks up pending rows, does the work, and marks them done or failed.

The step people skip is the first one, and it is the only step that provides the guarantee. If the intent is not durably recorded before you respond, a crash between the response and the work means the work silently never happens.

Here is the minimum viable version of that table:

sql
create table jobs (
  id          uuid primary key default gen_random_uuid(),
  kind        text not null,
  payload     jsonb not null default '{}',
  status      text not null default 'pending',
  attempts    int  not null default 0,
  run_after   timestamptz not null default now(),
  last_error  text,
  created_at  timestamptz not null default now()
);
create index on jobs (status, run_after);

That schema handles retries (attempts), delays and backoff (run_after), and debugging (last_error). Those three columns are the difference between a job table you can operate and one you will be reading production logs to understand.

Picking up work without hammering the database

The naive worker polls every second and does select * from jobs where status = 'pending'. With two workers this double-processes jobs, which for an email sender means users get two welcome emails.

The fix is claiming rows atomically rather than reading then updating:

sql
update jobs
set status = 'running', attempts = attempts + 1
where id = (
  select id from jobs
  where status = 'pending' and run_after <= now()
  order by created_at
  for update skip locked
  limit 1
)
returning *;

for update skip locked is the important part. It lets multiple workers pull from the same table concurrently without ever handing the same row to two of them. This one line is what separates a toy job table from a usable one, and it is worth understanding rather than copying, because an assistant asked to "add a job queue" will frequently produce the racy version.

If you would rather not poll at all, PostgreSQL's LISTEN and NOTIFY lets the database tell a waiting worker that something arrived, though polling every few seconds is genuinely fine for most applications and much easier to reason about.

What to run the worker on

This is where the AI-built app hits a real constraint. Serverless platforms, which is where most of these apps are deployed, do not give you a process that runs forever. You have three realistic options.

Scheduled function plus a job table. A function that runs on a timer, claims a batch, processes it, and exits. This covers the overwhelming majority of cases and requires no new infrastructure. If your app is on Supabase, its cron documentation covers scheduling straight from the database. The tradeoff is latency: work waits up to one interval before starting.

A managed queue. A dedicated service that holds messages and invokes a consumer when they arrive, handling retries and dead letters for you. Cloudflare Queues is one example of the shape. Lower latency and less code to maintain, at the cost of another service and another set of credentials.

A small always-on process. A single container running a loop. Conceptually simplest, and the right answer if you already run a server, but it reintroduces a thing you have to keep alive.

Start with the scheduled function. It is the least infrastructure, it is trivially debuggable because everything is a row you can look at, and moving to a managed queue later is a change to step three only.

The failure handling that actually matters

Two rules cover most of it.

Make jobs idempotent. A job may run twice. Networks fail after the work succeeded but before the status update. Design so that running twice is harmless: check whether the email was already sent, use a deterministic key for the created record, make the operation a no-op if it has already happened. This is much easier than guaranteeing exactly-once delivery, which is not something you are going to build.

Back off, then give up. Retry with increasing delays rather than immediately, and stop after a small number of attempts. A job that has failed five times will fail a sixth. Set run_after = now() + (interval '1 minute' * power(2, attempts)) and cap attempts at five, then leave it as failed for a human to look at.

What you should not do is retry forever. A permanently poisoned job retried in a tight loop will consume a worker, and if it is calling a paid model API, it will do so expensively. Setting a cap is also the moment to check you have spending limits configured on anything a runaway job could bill against.

Telling the user what is happening

Moving work into the background changes the interface, and this is the part AI assistants handle worst because it spans frontend and backend.

The minimum is a status the user can see. Return the job id, store the status on the row, and either poll a status endpoint every couple of seconds or push updates over a connection you already have. If your app already has real-time chat plumbing, reuse that transport rather than adding polling.

The mistake to avoid is optimistic messaging. Do not say "your report has been sent" when what you mean is "we have queued a job that will attempt to send your report". When it fails, the user has no reason to check, and a queued job that fails silently is worse than a synchronous request that failed loudly.

Frequently asked questions

Do I need a queue service, or is a database table enough?

A table is enough for the vast majority of small applications, and it is easier to debug because every job is a row you can query. Reach for a managed queue when you need sub-second pickup latency, very high throughput, or you are tired of maintaining the worker loop.

How do I stop the same job running twice?

Claim rows atomically with for update skip locked rather than selecting and then updating, and make the job itself idempotent so that a duplicate run is harmless. Both together, because either one alone still leaves a window.

Where do background jobs run if my app is serverless?

On a scheduled function that wakes on a timer, claims pending work and exits, or on a managed queue that invokes a consumer when a message arrives. Serverless platforms do not offer a long-lived process, so the loop has to be triggered from outside.

Will background jobs make my app cheaper to run?

Usually slightly, because you stop paying for request time spent waiting on slow external calls, though the bigger effect is that costs become predictable rather than spiky. The general cost picture is in how much it costs to run an AI-built app.

My app is slow but I am not sure background jobs are the answer.

Measure before restructuring. Slowness caused by unindexed queries or oversized payloads will not improve by moving work to a queue, and the diagnostic order is worth following properly, starting with why an AI-built app is slow and the wider structure covered in how to build an app with AI.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.