Back to all articles
TechnicalNext.js

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.

Background Jobs in Next.js - Solving the Serverless Problem

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

Your 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")

Inngest is purpose-built for background jobs in serverless. It handles scheduling, retries, and complex workflows.

pnpm add inngest

Set 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/nextjs

Define 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 crons

Convex 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

FeatureVercel CronInngestTrigger.devConvex
Scheduled jobs✓✓✓✓
Delayed jobs✗✓✓✓
Automatic retries✗✓✓✓
Step functions✗✓✓✗
Long running10s-60sHoursUp to 1 yearMinutes
Self-hostable✗✗✓✗
Free tier✓✓✓✓
Setup complexityLowMediumMediumHigh

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.