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 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
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement