Run background jobs & cron
Some work shouldn't block an HTTP request — sending emails, generating reports, syncing third-party data, nightly cleanup. You need to run code outside the request/response cycle, either on a schedule (cron) or in response to events, and have it survive failures and restarts.
Separate the trigger from the work. Your web app should emit an event or enqueue a job and return instantly; a separate durable worker picks it up, runs it in small retryable steps, and reports success or failure. Cron is just a scheduled trigger onto the same machinery. On serverless (Vercel, Cloudflare, Netlify), do NOT hand-roll a queue or use setInterval/node-cron — those die on scale-to-zero and cold starts. Use a managed durable-execution platform (Inngest, Trigger.dev, or QStash) that handles retries, concurrency, and scheduling for you. On a long-lived Node server you own, BullMQ on Redis is the boring, correct choice.
Step by step
- 1
Pick the tool that matches your runtime
If you deploy to serverless (Vercel/Cloudflare), reach for Inngest or Trigger.dev for real jobs, or Upstash QStash for simple scheduled HTTP pings. If you run a long-lived server (Railway, Fly, a VM), BullMQ + Redis is fine. Never assume setInterval or node-cron will work on serverless — it won't survive scale-to-zero.
- 2
Split the trigger from the work
In your request handler, do the minimum: validate input, then enqueue a job or emit an event and return 200 immediately. All the slow, failure-prone work (API calls, PDF generation, emails) happens in the background worker, never inline in the request.
- 3
Define jobs as durable, stepped functions
Break each job into discrete step.run() calls — one per side effect (fetch data, build payload, send email). The platform checkpoints each step, so a failure retries only the failed step instead of re-running everything and double-charging or double-emailing.
- 4
Add the cron schedule as a trigger
Attach a cron expression (e.g. "0 9 * * *") to a function to run it on a schedule. Remember cron is UTC by default on almost every platform — convert your intended local time. For heavy scheduled work, have the cron job just fan out one event per item so each runs as its own retryable job.
- 5
Wire up and secure the endpoint
Serverless platforms serve jobs via an API route (e.g. /api/inngest) protected by a signing key in an env var. If you use Vercel Cron or a raw QStash schedule that hits your own route, verify the request signature or a shared secret so randoms on the internet can't trigger it.
- 6
Make every job idempotent
Retries mean a job can run more than once. Pass a stable idempotency key (Inngest and Stripe both support this) or check-then-act against your DB so re-running 'send invoice for order #123' is a no-op the second time. This is the single most important correctness property of background jobs.
- 7
Add observability and a dead-letter path
Configure a max retry count, then route exhausted jobs somewhere you'll see them — the platform's failure dashboard plus an alert (Sentry, a Slack webhook). Silent job failures are the classic production bug: the email just never sends and nobody notices for a week.
- 8
Test it before trusting the schedule
Run the platform's dev server locally (npx inngest-cli dev) and trigger the job by hand — send the event or invoke the function — instead of waiting for 9am to find out it throws. Confirm retries fire by forcing a step to fail once.
Starter snippet
import { Inngest } from "inngest";
export const inngest = new Inngest({ id: "my-app" });
// Scheduled trigger (cron is UTC) that fans out one durable job per user
export const dailyDigest = inngest.createFunction(
{ id: "daily-digest" },
{ cron: "0 9 * * *" },
async ({ step }) => {
const users = await step.run("fetch-users", () => db.getActiveUsers());
await step.sendEvent("fan-out", users.map((u) => ({
name: "email/digest.requested",
data: { userId: u.id },
})));
}
);
// Event-driven job: retried up to 4x, each step checkpointed independently
export const sendDigest = inngest.createFunction(
{ id: "send-digest", retries: 4 },
{ event: "email/digest.requested" },
async ({ event, step }) => {
const digest = await step.run("build", () => buildDigest(event.data.userId));
// Only this step retries on failure — the digest isn't rebuilt
await step.run("send", () => resend.emails.send(digest));
}
);✕ Watch out for
- Running the work inside the request handler or in a setInterval/node-cron loop on serverless — it hits the function timeout or vanishes on cold start, so the job silently never runs.
- Non-idempotent jobs: a retry re-sends the email or re-charges the card because there's no idempotency key or check-then-act guard.
- Forgetting cron is UTC — your '9am' job fires at 4am local and you assume it's broken.
- No dead-letter handling or alerting, so exhausted-retry failures disappear and you learn about them from an angry customer.
- An unauthenticated cron/webhook route that anyone on the internet can POST to and trigger your jobs at will.
✓ Pro tips
- Tell your AI agent the exact platform AND runtime up front — e.g. 'Inngest on Vercel with the Next.js App Router' — and explicitly say 'wrap every side effect in step.run and add an idempotency key.' Then check its output for two things: that it did NOT reach for setInterval/node-cron (agents love to), and that retries + idempotency are actually configured.
- Make idempotency a hard rule, not an afterthought: every job that writes data or hits an external API needs a stable key so a retry is a no-op.
- Trigger the job manually from the dev dashboard before relying on the schedule — waiting until 9am to discover a typo in your cron expression wastes a day.
- Keep cron functions thin: have them fan out one event per work item so each item retries independently instead of one giant job that fails and takes everything down with it.