Background Jobs in Next.js - Solving the Serverless Problem
Need cron jobs in Next.js? Here's how to run scheduled tasks, background processing, and async workflows on serverless platforms.
Every SaaS eventually needs background jobs. Send a reminder email 24 hours after signup. Generate weekly reports. Clean up expired sessions. Process webhook payloads asynchronously.
On a traditional server, you'd use node-cron or set up a cron job. Easy. But on Vercel or Netlify? There's no persistent process. Your code runs, returns a response, and dies.
This is the serverless background job problem. Here's how to solve it.
Why node-cron Doesn't Work
Let's say you try this:
// This WILL NOT work on Vercel
import cron from 'node-cron'
cron.schedule('0 9 * * *', () => {
console.log('Running daily job at 9am')
sendDailyEmails()
})On localhost, this works perfectly. Deploy to Vercel? Nothing happens.
The reason: serverless functions only run when triggered by a request. There's no long-running process to execute the cron schedule. The function spins up, handles a request, and shuts down. Your cron job never had a chance to run.
Same problem with setInterval, background threads, or any approach that relies on a persistent process.
Solution 1: Vercel Cron Jobs
Vercel has built-in cron job support. It's the simplest solution if you're already on Vercel.
Create a vercel.json in your project root:
{
"crons": [
{
"path": "/api/cron/daily-emails",
"schedule": "0 9 * * *"
},
{
"path": "/api/cron/cleanup",
"schedule": "0 0 * * *"
}
]
}Create the endpoint:
// app/api/cron/daily-emails/route.ts
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
// Verify the request is from Vercel Cron
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Your job logic
await sendDailyEmails()
return NextResponse.json({ success: true })
}Set CRON_SECRET in your environment variables and configure it in Vercel.
Limitations:
- Hobby plan: 2 cron jobs, once per day max
- Pro plan: 40 cron jobs, once per minute max
- No retries: If the job fails, it just fails
- 10 second timeout on Hobby: Complex jobs might not finish
- No job queue: Can't schedule dynamic jobs like "send email in 2 hours"
For simple scheduled tasks, Vercel Cron works great. For anything more complex, keep reading.
Solution 2: External Scheduler + Webhooks
Use an external service to call your API on a schedule. This works on any hosting platform.
Options:
- cron-job.org - Free, reliable
- EasyCron - More features, paid
- GitHub Actions - If you're already using it
- AWS EventBridge - For AWS users
Example with GitHub Actions:
# .github/workflows/daily-job.yml
name: Daily Email Job
on:
schedule:
- cron: '0 9 * * *' # 9 AM UTC daily
jobs:
trigger:
runs-on: ubuntu-latest
steps:
- name: Trigger cron endpoint
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
https://yourapp.com/api/cron/daily-emailsYour endpoint stays the same - it just receives the request from a different source.
Pros:
- Works on any platform
- Free or very cheap
- Simple to set up
Cons:
- Another service to manage
- No built-in retries
- Still limited to scheduled times (can't say "run in 30 minutes")
Solution 3: Inngest (Recommended for Complex Workflows)
Inngest is purpose-built for background jobs in serverless. It handles scheduling, retries, and complex workflows.
pnpm add inngestSet up the client:
// lib/inngest/client.ts
import { Inngest } from 'inngest'
export const inngest = new Inngest({ id: 'my-app' })Create a function:
// lib/inngest/functions.ts
import { inngest } from './client'
import { db } from '@/lib/db'
import { sendEmail } from '@/lib/email'
export const sendWelcomeEmail = inngest.createFunction(
{ id: 'send-welcome-email' },
{ event: 'user/created' },
async ({ event }) => {
const user = event.data.user
// Wait 24 hours
await sleep('24h')
// Check if user is still active
const currentUser = await db.query.users.findFirst({
where: eq(users.id, user.id)
})
if (!currentUser || currentUser.deletedAt) {
return { skipped: true }
}
await sendEmail({
to: user.email,
subject: 'How are you liking the app?',
template: 'welcome-followup'
})
return { sent: true }
}
)Register the functions:
// app/api/inngest/route.ts
import { serve } from 'inngest/next'
import { inngest } from '@/lib/inngest/client'
import { sendWelcomeEmail } from '@/lib/inngest/functions'
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [sendWelcomeEmail],
})Trigger from your app:
// When a user signs up
await inngest.send({
name: 'user/created',
data: { user: { id: user.id, email: user.email } }
})What makes Inngest special:
- Step functions: Break jobs into steps that can be retried independently
- Sleep/delay:
await sleep('24h')actually works - Automatic retries: Failed steps retry with backoff
- Fan-out: Run multiple jobs in parallel
- Cron support: Schedule recurring jobs
- Local development: Test jobs locally with their dev server
Example of a complex workflow:
export const processOrder = inngest.createFunction(
{ id: 'process-order' },
{ event: 'order/placed' },
async ({ event, step }) => {
// Step 1: Validate inventory
const inventory = await step.run('check-inventory', async () => {
return await checkInventory(event.data.items)
})
if (!inventory.available) {
await step.run('notify-out-of-stock', async () => {
await sendEmail(event.data.customerEmail, 'out-of-stock')
})
return { status: 'cancelled' }
}
// Step 2: Charge payment
const payment = await step.run('charge-payment', async () => {
return await chargeCard(event.data.paymentMethod, event.data.total)
})
// Step 3: Send confirmation (parallel with step 4)
await Promise.all([
step.run('send-confirmation', async () => {
await sendEmail(event.data.customerEmail, 'order-confirmed')
}),
step.run('update-inventory', async () => {
await decrementInventory(event.data.items)
})
])
// Step 4: Schedule follow-up
await step.sleep('follow-up-delay', '7d')
await step.run('send-review-request', async () => {
await sendEmail(event.data.customerEmail, 'review-request')
})
return { status: 'completed' }
}
)If step 2 fails, it retries. The other steps don't re-run.
Solution 4: Trigger.dev
Trigger.dev is similar to Inngest but with some different tradeoffs.
pnpm add @trigger.dev/sdk @trigger.dev/nextjsDefine a job:
// jobs/welcome-email.ts
import { client } from '@/trigger'
import { eventTrigger } from '@trigger.dev/sdk'
client.defineJob({
id: 'send-welcome-email',
name: 'Send Welcome Email',
version: '1.0.0',
trigger: eventTrigger({
name: 'user.created',
}),
run: async (payload, io) => {
// Wait 24 hours
await io.wait('wait-24h', 60 * 60 * 24)
// Send email
await io.runTask('send-email', async () => {
await sendEmail(payload.email, 'welcome')
})
},
})Trigger.dev pros:
- Run long-running jobs (up to 1 year)
- Good dashboard and observability
- Self-hostable
Trigger.dev cons:
- Slightly more setup than Inngest
- Smaller community
Solution 5: Convex (Full-Stack Alternative)
If you're open to a different backend entirely, Convex has background jobs built in:
// convex/crons.ts
import { cronJobs } from 'convex/server'
import { internal } from './_generated/api'
const crons = cronJobs()
crons.daily(
'daily cleanup',
{ hourUTC: 9, minuteUTC: 0 },
internal.cleanup.run
)
export default cronsConvex replaces your database and backend entirely. If you're starting fresh, it's worth considering. For existing projects, it's a bigger migration.
Comparison Table
| Feature | Vercel Cron | Inngest | Trigger.dev | Convex |
|---|---|---|---|---|
| Scheduled jobs | ✓ | ✓ | ✓ | ✓ |
| Delayed jobs | ✗ | ✓ | ✓ | ✓ |
| Automatic retries | ✗ | ✓ | ✓ | ✓ |
| Step functions | ✗ | ✓ | ✓ | ✗ |
| Long running | 10s-60s | Hours | Up to 1 year | Minutes |
| Self-hostable | ✗ | ✗ | ✓ | ✗ |
| Free tier | ✓ | ✓ | ✓ | ✓ |
| Setup complexity | Low | Medium | Medium | High |
Which Should You Choose?
Use Vercel Cron if:
- You just need simple scheduled tasks
- Running once per day or hour is fine
- Jobs complete in under 60 seconds
- You don't need dynamic scheduling
Use Inngest if:
- You need "do X in 30 minutes" functionality
- Jobs are complex multi-step workflows
- You want automatic retries
- You're building anything SaaS-y
Use Trigger.dev if:
- You need very long-running jobs
- You might want to self-host
- You prefer their API/dashboard
Use external webhooks if:
- You're on a platform without cron support
- Jobs are simple HTTP calls
- You want maximum simplicity
Real Example: Trial Expiration Flow
Here's how we'd implement trial expiration in a SaaS:
// lib/inngest/functions.ts
export const trialExpirationFlow = inngest.createFunction(
{ id: 'trial-expiration-flow' },
{ event: 'user/trial-started' },
async ({ event, step }) => {
const { userId, email, trialEndsAt } = event.data
// Wait until 3 days before trial ends
const threeDaysBefore = new Date(trialEndsAt)
threeDaysBefore.setDate(threeDaysBefore.getDate() - 3)
await step.sleepUntil('wait-for-warning', threeDaysBefore)
// Check if they've already upgraded
const user = await step.run('check-subscription', async () => {
return await db.query.users.findFirst({
where: eq(users.id, userId),
with: { subscription: true }
})
})
if (user?.subscription?.status === 'active') {
return { converted: true }
}
// Send warning email
await step.run('send-warning', async () => {
await sendEmail(email, 'trial-ending-soon')
})
// Wait until trial actually ends
await step.sleepUntil('wait-for-expiration', new Date(trialEndsAt))
// Check again
const finalCheck = await step.run('final-check', async () => {
return await db.query.users.findFirst({
where: eq(users.id, userId),
with: { subscription: true }
})
})
if (finalCheck?.subscription?.status === 'active') {
return { converted: true }
}
// Send expired email
await step.run('send-expired', async () => {
await sendEmail(email, 'trial-expired')
})
// Downgrade to free tier
await step.run('downgrade', async () => {
await db.update(users)
.set({ plan: 'free' })
.where(eq(users.id, userId))
})
return { expired: true }
}
)This entire flow runs over 2 weeks, with automatic retries at each step. You couldn't do this with basic cron jobs.
The Bottom Line
Serverless doesn't mean you can't have background jobs. It just means you need to think differently about them.
For simple scheduled tasks, Vercel Cron is fine. For anything more complex - delayed jobs, retries, multi-step workflows - use Inngest or Trigger.dev.
The cost of these services is minimal compared to the engineering time you'd spend building a reliable job system yourself.
Questions about background jobs or serverless architecture? Hit me up at raman@vibestacks.io or join us on Discord.
Read more
Edge vs Serverless Functions in Next.js - When to Use Each
Confused about edge vs serverless? Learn when to use each runtime in Next.js with real performance data and practical examples.
Rate Limiting Your Next.js API Routes - The Complete Guide
Protect your API from abuse with rate limiting. From simple in-memory solutions to production-ready Redis implementations.

Why Your Next.js App Feels Broken (And How Tests Save Your Refactor)
A practical look at Next.js caching strategies and why high level tests are your safety net when paying down tech debt