EdgeCases Logo
Apr 2026
Next.js
Deep
9 min read

Vercel Cron Jobs Gotchas

Vercel Cron Jobs have strict timeout limits and no execution guarantees. Learn timeout handling, missed execution recovery, and monitoring strategies.

vercel
cron-jobs
timeout
monitoring
distributed-systems

Vercel Cron Jobs simplify scheduled tasks, but they come with strict timeout limits and no execution guarantees. Understanding timeout behaviors, handling missed executions, and implementing proper monitoring is critical for production reliability.

The Problem: Cron Jobs on Vercel

Vercel Cron Jobs are HTTP endpoints triggered on a schedule. They're convenient but have constraints:

  • Timeout: 60 seconds (Hobby), 900 seconds (Pro)
  • No execution guarantee: Jobs may be skipped or delayed
  • No retries: Failed jobs don't automatically retry
  • Concurrency: Multiple instances may run simultaneously

Pattern 1: Cron Job Configuration

Define cron jobs in vercel.json:

{
  "crons": [
    {
      "path": "/api/cron/daily-report",
      "schedule": "0 0 * * *"
    },
    {
      "path": "/api/cron/cleanup",
      "schedule": "0 2 * * 0"
    },
    {
      "path": "/api/cron/hourly-sync",
      "schedule": "0 * * * *"
    }
  ]
}

Cron schedule format:

# ┌───────────── minute (0 - 59)
# │ ┌───────────── hour (0 - 23)
# │ │ ┌───────────── day of month (1 - 31)
# │ │ │ ┌───────────── month (1 - 12)
# │ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday)
# │ │ │ │ │
# * * * * *

Pattern 2: Protecting Cron Endpoints

Prevent unauthorized execution of cron jobs:

// app/api/cron/daily-report/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';

const CRON_SECRET = process.env.CRON_SECRET;

export async function GET(request: NextRequest) {
  // Verify cron secret
  const authHeader = request.headers.get('authorization');

  if (authHeader !== `Bearer ${CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Execute cron job
  await generateDailyReport();

  return NextResponse.json({ success: true, timestamp: new Date() });
}

Alternative: Use Vercel's built-in cron authentication:

// Check if request is from Vercel cron
export async function GET(request: NextRequest) {
  const vercelCron = request.headers.get('x-vercel-cron');

  if (!vercelCron) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Execute cron job...
}

Edge Case 1: Timeout Limits

Cron jobs hit hard timeout limits:

// ❌ Bad: No timeout handling
export async function GET() {
  // This might exceed 60s on Hobby plan
  const users = await db.users.findMany();
  for (const user of users) {
    await sendEmail(user);
  }
}

Handle timeouts gracefully:

// ✅ Good: Timeout-aware processing
const MAX_DURATION = 50 * 1000; // 50s (5s buffer)
const BATCH_SIZE = 100;

export async function GET() {
  const startTime = Date.now();

  try {
    let offset = 0;
    let processed = 0;

    while (true) {
      // Check timeout
      if (Date.now() - startTime > MAX_DURATION) {
        await db.jobState.update({
          name: 'daily-report',
          offset,
          status: 'timeout',
        });

        return NextResponse.json({
          success: false,
          processed,
          message: 'Timeout reached, will resume next run',
        });
      }

      // Process batch
      const users = await db.users.findMany({
        take: BATCH_SIZE,
        skip: offset,
      });

      if (users.length === 0) break;

      await Promise.all(users.map(sendEmail));

      processed += users.length;
      offset += BATCH_SIZE;
    }

    return NextResponse.json({ success: true, processed });
  } catch (error) {
    await db.jobState.update({
      name: 'daily-report',
      offset,
      status: 'error',
      error: error.message,
    });

    return NextResponse.json(
      { success: false, error: error.message },
      { status: 500 }
    );
  }
}

Edge Case 2: Missed Executions

Vercel may skip cron executions during outages:

// Track last successful execution
interface JobState {
  name: string;
  lastRun: Date;
  lastSuccess: Date;
  status: 'idle' | 'running' | 'timeout' | 'error';
  offset?: number;
}

export async function GET() {
  const job = await db.jobState.findUnique({
    where: { name: 'daily-report' },
  });

  // Check for missed executions (e.g., outage skipped daily job)
  const lastSuccess = job?.lastSuccess || new Date('2000-01-01');
  const now = new Date();
  const daysSinceLastSuccess = Math.floor(
    (now.getTime() - lastSuccess.getTime()) / (1000 * 60 * 60 * 24)
  );

  if (daysSinceLastSuccess > 1) {
    // Missed executions - catch up
    console.warn(`Missed ${daysSinceLastSuccess} daily runs`);

    // Run twice or more frequently to catch up
    for (let i = 0; i < Math.min(daysSinceLastSuccess, 5); i++) {
      await generateDailyReport();
    }
  }

  // Regular run
  await generateDailyReport();

  return NextResponse.json({ success: true });
}

Edge Case 3: Concurrent Executions

Vercel may spawn multiple cron job instances:

// ❌ Bad: No concurrency control
export async function GET() {
  // Multiple instances might run this simultaneously
  const report = await generateReport();
  await sendReport(report);
}

Implement locking:

// ✅ Good: Distributed locking
import { kv } from '@vercel/kv';

const LOCK_KEY = 'cron:daily-report:lock';
const LOCK_TTL = 60; // seconds

export async function GET() {
  // Try to acquire lock
  const lock = await kv.set(LOCK_KEY, 'locked', {
    nx: true, // only set if doesn't exist
    ex: LOCK_TTL, // expire after 60s
  });

  if (!lock) {
    return NextResponse.json({
      success: false,
      message: 'Job already running',
    });
  }

  try {
    // Execute job
    await generateDailyReport();

    return NextResponse.json({ success: true });
  } finally {
    // Release lock
    await kv.del(LOCK_KEY);
  }
}

Pattern 3: Monitoring and Alerting

Track cron job health:

// Log cron job execution
export async function GET() {
  const startTime = Date.now();

  try {
    await generateDailyReport();

    const duration = Date.now() - startTime;

    // Log to monitoring service
    await logToDatadog({
      event: 'cron_success',
      job: 'daily-report',
      duration,
    });

    return NextResponse.json({ success: true, duration });
  } catch (error) {
    const duration = Date.now() - startTime;

    await logToDatadog({
      event: 'cron_error',
      job: 'daily-report',
      duration,
      error: error.message,
    });

    // Send alert for failures
    await sendSlackAlert({
      channel: '#alerts',
      text: `Cron job failed: daily-report
Error: ${error.message}`,
    });

    return NextResponse.json(
      { success: false, error: error.message },
      { status: 500 }
    );
  }
}

Monitor cron job health with external services:

// Health check endpoint
export async function GET() {
  const jobs = await db.jobState.findMany({
    where: {
      lastSuccess: {
        lt: new Date(Date.now() - 48 * 60 * 60 * 1000), // 2 days ago
      },
    },
  });

  if (jobs.length > 0) {
    return NextResponse.json({
      status: 'unhealthy',
      failedJobs: jobs.map(j => j.name),
    }, { status: 503 });
  }

  return NextResponse.json({ status: 'healthy' });
}

Pattern 4: Idempotent Jobs

Make cron jobs safe to run multiple times:

// ❌ Bad: Not idempotent
export async function generateDailyReport() {
  // Running twice sends duplicate emails
  const users = await db.users.findMany();
  await Promise.all(users.map(sendDailyReportEmail));
}

// ✅ Good: Idempotent
export async function generateDailyReport() {
  const today = new Date().toISOString().split('T')[0];

  const users = await db.users.findMany({
    where: {
      // Only send if not already sent today
      lastReportDate: {
        lt: today,
      },
    },
  });

  await Promise.all(users.map(sendDailyReportEmail));

  // Mark as sent
  await db.users.updateMany({
    where: {
      id: { in: users.map(u => u.id) },
    },
    data: {
      lastReportDate: today,
    },
  });
}

Pattern 5: Progress Tracking

Track job progress for resumability:

interface JobProgress {
  jobId: string;
  total: number;
  processed: number;
  status: 'running' | 'completed' | 'failed';
  lastItem?: string;
}

export async function generateDailyReport() {
  const jobId = crypto.randomUUID();

  // Initialize progress
  await kv.set(`progress:${jobId}`, {
    jobId,
    total: 0,
    processed: 0,
    status: 'running',
  });

  const users = await db.users.findMany();
  const total = users.length;

  await kv.hset(`progress:${jobId}`, { total });

  for (let i = 0; i < users.length; i++) {
    await sendReport(users[i]);

    // Update progress
    await kv.hset(`progress:${jobId}`, {
      processed: i + 1,
      lastItem: users[i].id,
    });
  }

  // Mark complete
  await kv.hset(`progress:${jobId}`, {
    status: 'completed',
  });

  await kv.expire(`progress:${jobId}`, 7 * 24 * 60 * 60); // 7 days
}

Key Takeaways

  • Timeouts: Implement batch processing with timeout checks
  • Missed executions: Track last run and catch up on outages
  • Concurrency: Use distributed locking with Vercel KV
  • Monitoring: Log to Datadog/New Relic, set up alerts
  • Idempotency: Design jobs safe to run multiple times
  • Progress tracking: Store progress in KV for resumability
  • Authentication: Protect endpoints with secrets or Vercel headers

Advertisement

Related Insights

Explore related edge cases and patterns

architecture
Surface
Vercel Edge Config vs KV vs Blob
6 min
Next.js
Deep
Neon on Vercel: The Connection Pooling Maze
7 min

Advertisement