Background Jobs in Next.js

How to run scheduled tasks, cron jobs, and async work from a Next.js app deployed on Vercel.

Prerequisites

  • Node.js 18+ and a Next.js 13+ project using the App Router
  • A Runlater account (free tier works)
  • Your app deployed to a public URL (Vercel, Railway, etc.)

SDK overview

The runlater-js SDK methods used in this guide:

Method Description
rl.send(url, opts) Queue a task for immediate execution
rl.delay(url, opts) Execute after a delay (e.g. "30m", "2h")
rl.schedule(url, opts) Execute at a specific date/time
rl.cron(name, opts) Create or update a recurring cron job (idempotent)
rl.sync(opts) Declare all tasks at once — creates, updates, and removes to match

Why you need this

Next.js on Vercel runs on serverless functions. There are no persistent processes, no background workers, and no way to run code after a response is sent. When a user signs up and you want to send a welcome email in 30 minutes, there's nowhere to put that timer. When you need to sync data from Stripe every 5 minutes, Vercel's built-in cron only supports daily intervals on the free plan.

Runlater solves this by acting as your background job infrastructure. You schedule tasks via the SDK, and Runlater calls your Next.js API routes when it's time to execute. Your app stays serverless — Runlater handles the persistence, retries, and scheduling.

Setup

Install the SDK and set your API key:

npm install runlater-js

Add your API key and app URL to .env.local:

RUNLATER_KEY=pk_live_your_api_key
APP_URL=https://myapp.vercel.app

Create a shared client you can import anywhere:

lib/runlater.ts
import Runlater from "runlater-js"

export const rl = new Runlater(process.env.RUNLATER_KEY!)

Example: Send a welcome email after signup

When a user signs up, you want to send a welcome email 30 minutes later. In a traditional server you'd use a job queue. In Next.js, you can use rl.delay() to schedule it from a server action:

app/actions/signup.ts
"use server"
import { rl } from "@/lib/runlater"

export async function onSignup(userId: string) {
  // Send welcome email in 30 minutes
  await rl.delay(`${process.env.APP_URL}/api/send-welcome`, {
    delay: "30m",
    body: { user_id: userId },
    retries: 3,
  })
}

Then create the API route that Runlater will call:

app/api/send-welcome/route.ts
import { sendEmail } from "@/lib/email"
import { prisma } from "@/lib/db" // Your Prisma client

export async function POST(request: Request) {
  const { user_id } = await request.json()
  const user = await prisma.user.findUnique({ where: { id: user_id } })

  if (!user) {
    return Response.json({ error: "User not found" }, { status: 404 })
  }

  await sendEmail({
    to: user.email,
    subject: "Welcome aboard!",
    template: "welcome",
  })

  return Response.json({ ok: true })
}

Runlater holds the task for 30 minutes, then POSTs to your route. If it fails (cold start timeout, transient error), it retries automatically with exponential backoff.

Example: Sync data on a cron schedule

Pull data from an external API every 5 minutes. Use rl.cron() to create a recurring task — it's idempotent, so you can call it on every deploy without creating duplicates:

scripts/setup-tasks.ts
import { rl } from "@/lib/runlater"

const BASE = process.env.APP_URL!

// Sync Stripe data every 5 minutes
await rl.cron("stripe-sync", {
  url: `${BASE}/api/stripe/sync`,
  schedule: "*/5 * * * *",
})

// Clean up expired sessions every night at 2 AM
await rl.cron("session-cleanup", {
  url: `${BASE}/api/cleanup/sessions`,
  schedule: "0 2 * * *",
})

// Generate daily usage report at 8 AM
await rl.cron("daily-report", {
  url: `${BASE}/api/reports/daily`,
  schedule: "0 8 * * *",
  method: "POST",
})

The handler for the Stripe sync:

app/api/stripe/sync/route.ts
import Stripe from "stripe"
import { prisma } from "@/lib/db"

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  // Fetch recent charges from Stripe
  const charges = await stripe.charges.list({
    created: { gte: Math.floor(Date.now() / 1000) - 300 },
  })

  // Update your database
  for (const charge of charges.data) {
    await prisma.payment.upsert({
      where: { stripeId: charge.id },
      update: { status: charge.status },
      create: { stripeId: charge.id, amount: charge.amount, status: charge.status },
    })
  }

  return Response.json({ synced: charges.data.length })
}

Example: Process uploads in the background

When a user uploads a file, you don't want them waiting while you resize images or parse CSVs. Use rl.send() to queue the work immediately and return a response right away:

app/api/upload/route.ts
import { rl } from "@/lib/runlater"

export async function POST(request: Request) {
  const { fileUrl, userId } = await request.json()

  // Queue the processing — returns immediately
  await rl.send(`${process.env.APP_URL}/api/process-upload`, {
    body: { file_url: fileUrl, user_id: userId },
    retries: 5,
    timeout: 120000, // 2 min for heavy processing
  })

  return Response.json({ status: "processing" })
}
app/api/process-upload/route.ts
import sharp from "sharp"
import { prisma } from "@/lib/db"

export async function POST(request: Request) {
  const { file_url, user_id } = await request.json()

  // Download and resize the image
  const response = await fetch(file_url)
  const buffer = await response.arrayBuffer()

  const thumbnail = await sharp(Buffer.from(buffer))
    .resize(200, 200)
    .toBuffer()

  await uploadToS3(thumbnail, `thumbnails/${user_id}.jpg`)
  await prisma.user.update({
    where: { id: user_id },
    data: { avatarProcessed: true },
  })

  return Response.json({ ok: true })
}

Example: Trial expiration reminders

Schedule a reminder for a specific date using rl.schedule(). When a user starts a 14-day trial, schedule a reminder for day 12:

app/actions/start-trial.ts
"use server"
import { rl } from "@/lib/runlater"

export async function startTrial(userId: string) {
  const trialEnd = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000)
  const reminderDate = new Date(Date.now() + 12 * 24 * 60 * 60 * 1000)

  // Remind them 2 days before trial ends
  await rl.schedule(`${process.env.APP_URL}/api/trial-reminder`, {
    at: reminderDate,
    body: { user_id: userId, trial_end: trialEnd.toISOString() },
  })
}

Advanced: Declarative task management

Instead of creating tasks individually, use rl.sync() to declare all your scheduled tasks in one place. Run this as part of your deploy process — it creates, updates, and optionally deletes tasks to match your spec:

scripts/sync-tasks.ts
import { rl } from "@/lib/runlater"

const BASE = process.env.APP_URL!

await rl.sync({
  tasks: [
    {
      name: "stripe-sync",
      url: `${BASE}/api/stripe/sync`,
      schedule: "*/5 * * * *",
    },
    {
      name: "session-cleanup",
      url: `${BASE}/api/cleanup/sessions`,
      schedule: "0 2 * * *",
    },
    {
      name: "daily-report",
      url: `${BASE}/api/reports/daily`,
      schedule: "0 8 * * *",
    },
  ],
  deleteRemoved: true, // Remove tasks not in this list
})

Add it to your package.json build script so tasks stay in sync with your code:

"scripts": {
  "build": "next build && npx tsx scripts/sync-tasks.ts"
}

Best practices

  • Protect your handler endpoints. Your API routes are public URLs. Verify that requests come from Runlater by checking the webhook signature in the x-runlater-signature header, or check for a shared secret you set in the task headers.
  • Keep handlers idempotent. Runlater retries failed tasks, so your API routes should be safe to call more than once. Use database upserts instead of inserts, and check for duplicates before sending emails.
  • Return 2xx for success, 4xx/5xx for failure. Runlater uses the HTTP status code to determine if the task succeeded. A non-2xx response triggers a retry (up to the configured limit). Retries use exponential backoff.
  • Return quickly. Your handler has up to 5 minutes (configurable via timeout), but faster is better. If processing takes longer, break it into smaller tasks.
  • Use the dashboard. Every execution is logged with status, duration, and response body. When something breaks, check the execution history before digging into logs.
  • Set up notifications. Configure email or Slack alerts in your organization settings so you know immediately when a task fails.
Ready to get started? Create a free account and schedule your first task in under 5 minutes. See the getting started guide for a full walkthrough.

Back to all guides