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:
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:
"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:
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:
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:
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:
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" }) }
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:
"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:
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-signatureheader, 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.