BackendIntermediate~1 hour

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.

the approach

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. 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. 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. 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. 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. 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. 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. 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. 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

typescript
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.