Running Background Jobs and Scheduled Tasks in an AI-Built App
Quick answer
Not everything your app does can happen while a user is staring at a loading spinner. Sending a batch of overdue-invoice reminders, recalculating a leaderboard overnight, purging expired sessions — none of that belongs inside a request that's supposed to return in under a second. It needs its own schedule, its own trigger, and its own failure handling, separate from the page load or API call a user is actually waiting on.
Why stuffing it into a request handler breaks first
The tempting shortcut is to bolt background work onto whatever request happens to be nearby. A "send weekly digest" job gets triggered from inside the dashboard's page load, so the digest only goes out when someone happens to visit the dashboard that day. A cleanup task runs inside a signup handler because that's where someone remembered to add it.
I've seen this pattern cause two kinds of failures. Either the request times out because it's now doing five minutes of report generation before it can respond, or the job silently never runs because the "right" request just didn't happen that day. Neither is a bug you catch in a demo. Both show up two weeks after launch, usually as a customer asking why their invoice reminder never arrived.
The tell that you need a real background job
If the words "every night," "every hour," or "eventually, not right now" show up when you describe a piece of work, it doesn't belong in a request/response cycle. It belongs on a schedule or in a queue.
The three shapes background work actually takes
Most scheduled or async work in a real app falls into one of three buckets, and they're not interchangeable.
Scheduled jobs run on a clock, no user involved — nightly billing runs, weekly digest emails, a 3am cleanup of expired password-reset tokens. These are triggered by time, not by anything a user did.
Queued jobs are triggered by an event but shouldn't block the response to it. A user uploads a video; the upload response comes back instantly, and a separate process handles transcoding in the background. The user gets a "processing" state, then a notification when it's done.
Retryable jobs are the subset of either category that can fail for reasons outside your control — an email provider is down, a third-party API rate-limits you — and need backoff and a retry limit instead of a single silent failure.
Mixing these up is where teams get burned. Treating a queued job like a scheduled one means it waits for the next clock tick instead of running now. Treating a scheduled job like it can't fail means a single bad night takes down the whole billing run with no record of what happened.
Where this lives in a Greta-built app
Because Greta scaffolds a Next.js app with a Prisma-backed database, scheduled and background work has an obvious home instead of needing separate infrastructure bolted on afterward. A scheduled job is a Next.js API route — say app/api/cron/send-digest/route.ts — triggered on a timer through the platform's cron configuration, not a page anyone visits. Queued work gets a simple job table in the same database: a row per task, a status column, and a lightweight route that processes pending rows. No separate message broker to stand up for a project that's sending a few thousand emails a week.
That last point matters more than it sounds. A dedicated queue system like a managed broker is the right call once you're processing tens of thousands of jobs an hour. Below that, a status column and a polling route is simpler to reason about, easier to debug, and one less service that can go down independently of your app.
A simple comparison: hand-rolled vs. scaffolded
| Concern | Hand-rolled from scratch | Greta-scaffolded |
|---|---|---|
| Scheduling infrastructure | Set up a separate cron service or serverless trigger | Cron config lives alongside the app, same deploy |
| Job state tracking | Custom table and status logic per project | Prisma schema gives you a job table pattern from day one |
| Retry and backoff | Written per job, often skipped under deadline | Standard pattern applied consistently across job types |
| Failure visibility | Logs scattered across services | One database, one place to query "what failed last night" |
| Scaling up later | Migrate to a queue system mid-project | Same Next.js routes; swap the job table for a broker when volume demands it |
What this looks like end to end
Say you're running a subscription app and need to downgrade any account whose trial expired without a card on file. That's a scheduled job: once a day, query for expired trials, flip their status, send a heads-up email. It has nothing to do with any single user's request — it needs to run whether or not anyone logs in that day.
Now say a user exports a large report from that same app. The export shouldn't block the page for thirty seconds while it generates. The request creates a job row, returns immediately with "we'll email you when it's ready," and a separate process picks up the row, builds the file, and updates the status. If report generation fails because a third-party data source timed out, the job retries twice with a short delay before it gives up and flags itself for a human to look at.
Neither of these is complicated in isolation. What gets teams is skipping the retry logic on the assumption that things "usually just work," and finding out otherwise the first time an email provider has a bad five minutes.
FAQ
Do I need a message queue like Redis or SQS on day one? Almost never. A database table with a status column handles a surprising amount of volume — most apps don't outgrow it before they've found product-market fit. Add a dedicated queue when you're processing enough jobs per minute that polling the table becomes the bottleneck, not before.
How often can a scheduled job actually run? Depends on the platform's cron granularity, but per-minute triggers are common for serverless cron. For anything more frequent than that, you're usually looking at a persistent worker process instead of a scheduled function.
What happens if a scheduled job overlaps with itself — say it's still running when the next trigger fires? This is a real failure mode, not a hypothetical. The fix is a lock: check whether the last run finished before starting a new one, or use a status flag on the job record itself.
Can I trigger a background job from a webhook instead of a schedule? Yes — that's the queued-job pattern. A webhook arrives, you write a job row instead of processing inline, and a separate route or worker handles it a moment later. See connecting webhooks and third-party APIs for the inbound side of that.
Closing
Background jobs are one of those things that don't show up in a demo — nobody claps for a cron job that ran quietly at 3am — but they're the difference between an app that works when someone's watching and one that actually runs a business unattended. Get the scheduling, the retries, and the failure visibility right, and most of what looked like "infrastructure work" turns out to be a database table and a route.
