TL;DR: Vercel Cron Jobs let you schedule HTTP GET requests to your serverless functions using cron expressions defined in vercel.json. They run only on production deployments, always in UTC, and require a CRON_SECRET check to prevent unauthorized invocations. Hobby plans get 2 cron jobs (once per day each), Pro gets 40 (unlimited frequency), and Enterprise gets 100. They're dead simple for recurring tasks like database cleanup or cache warming -- but they have real limits you need to understand before you commit.

What Are Vercel Cron Jobs?

Vercel Cron Jobs are scheduled HTTP GET requests that Vercel sends to your serverless or edge functions on a recurring basis. You define them in your vercel.json file using standard cron expressions, and Vercel handles the scheduling infrastructure.

Here's what the definition looks like:

{
  "crons": [
    {
      "path": "/api/cron",
      "schedule": "0 5 * * *"
    }
  ]
}

The path points to a route handler in your project. The schedule is a five-field cron expression: minute, hour, day of month, month, day of week. That example fires at 05:00 UTC every day.

Vercel Cron Jobs are not background workers, not long-running processes, and not event-driven queues. They're HTTP triggers on a timer. That distinction matters.

How Do Vercel Cron Jobs Work Under the Hood?

Vercel Cron Jobs run on Amazon EventBridge Scheduler. When you deploy with a crons array in vercel.json, Vercel registers each job with EventBridge. At the scheduled time, EventBridge fires an HTTP GET request to your production deployment URL at the specified path.

The exact flow:

  1. Deploy your project with a crons array in vercel.json
  2. Vercel's build process registers each cron job with EventBridge
  3. EventBridge fires an HTTP GET request to your production URL at the scheduled time
  4. Your function executes and returns a response
  5. Vercel logs the invocation in your dashboard under the Cron Jobs tab

Three things matter here:

Only production deployments. Preview deployments are ignored completely. Push to a feature branch, and your cron jobs won't run there.

HTTP GET only. Vercel sends a GET request, not POST. Your route handler needs to handle GET.

User agent identification. Every cron invocation includes vercel-cron/1.0 as the user agent and an x-vercel-cron-schedule header containing the cron expression that triggered it (e.g., 0 5 * * *).

Setting Up Your First Vercel Cron Job

I'll walk through the full setup using a Next.js 14+ App Router project. This works with any framework Vercel supports.

Step 1: Create the Route Handler

Create a file at app/api/cron/route.ts:

// app/api/cron/route.ts
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  console.log('Cron job executed at', new Date().toISOString());

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

Step 2: Add the Cron Configuration

Create or update vercel.json at the project root:

{
  "crons": [
    {
      "path": "/api/cron",
      "schedule": "0 5 * * *"
    }
  ]
}

This schedules the function to run at 05:00 UTC every day.

Step 3: Deploy

Push to your production branch. Vercel picks up the crons array and registers the jobs. You'll see them in your Vercel dashboard under Settings > Cron Jobs after deployment.

As of March 2026, you can manually trigger a cron job from the deployment summary in the dashboard -- useful for testing.

Step 4: Verify

Check the Cron Jobs tab in your Vercel dashboard. You should see your job with its schedule, last execution time, and status.

Securing Cron Jobs with CRON_SECRET

Your cron endpoint is a public URL. Anyone who knows the path can hit it. Without security, someone could trigger your cleanup job, your billing aggregation, or your notification sender whenever they want.

Vercel provides the CRON_SECRET environment variable for this.

How It Works

Add an environment variable called CRON_SECRET in your Vercel project settings. Use a strong random string -- I generate mine with openssl rand -hex 32.

When Vercel triggers your cron job, it includes an Authorization header with the value Bearer <your-CRON_SECRET>.

In your route handler, verify this header:

// app/api/cron/route.ts
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const authHeader = request.headers.get('authorization');

  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }

  // Safe to proceed
  // ... your task logic

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

This was raised in GitHub discussion #5344 by fabioluiz1 -- "Is there a way to use environment variables to make sure the cron API endpoint was initiated by a Vercel Cron scheduler?" Lee Robinson from Vercel confirmed CRON_SECRET as the mechanism.

Don't Skip This

I've seen projects in production where the cron endpoint had no auth check. One was a Supabase row-deletion job at /api/cleanup. It showed up in the sitemap. Bots hit it. Data was deleted during business hours. The fix took five minutes. The data recovery took two days.

Plan Limits: Hobby vs Pro vs Enterprise

Here are the current numbers as of 2026:

Plan Max Cron Jobs per Project Minimum Frequency Duration Limit
Hobby 2 Once per day 10s (Serverless)
Pro 40 Every minute 60s (Serverless), 30s (Edge)
Enterprise 100 Every minute 900s (Serverless)

Key points:

Hobby's once-per-day limit is hard. You cannot run a Hobby cron job every hour. The minimum interval is 24 hours. If you set */5 * * * * on a Hobby plan, the deployment will succeed but the cron job will be throttled to once per day.

Duration limits match your function limits. A cron job isn't a special execution environment. It's a regular function invocation. The same timeout limits apply.

Per-project, not per-account. The 40-job limit on Pro is per project. Five projects means 40 cron jobs in each.

There's some confusion around a supposed increase to 100 cron jobs per project on all plans as of January 2026. I haven't confirmed this against Vercel's docs, which still show the tiered numbers above. Check your dashboard for your actual quota.

Timezone Behavior: Everything Is UTC

Vercel Cron Jobs run in UTC. There is no timezone configuration option.

Want a job to run at 9:00 AM Eastern Time? Calculate the UTC offset yourself:

  • During EST (winter): 9:00 AM ET = 14:00 UTC → 0 14 * * *
  • During EDT (summer): 9:00 AM ET = 13:00 UTC → 0 13 * * *

Vercel does not handle daylight saving time transitions. If you need a job to run at a consistent local time year-round, you have two options:

  1. Set two cron jobs -- one for the DST offset and one for standard time, with each job checking the current date to decide whether to execute
  2. Use an external scheduler that supports timezone-aware scheduling

This UTC-only behavior is the biggest source of confusion I see. Teams set 0 9 * * * expecting 9 AM in their local timezone, and the job runs at 4 AM or 5 AM local time instead.

Silent-Failure Traps That Will Bite You

Vercel Cron Jobs have several failure modes that don't produce obvious errors.

No Built-In Alerting

If your cron function throws an error or returns a 500, Vercel logs it -- but there's no built-in alerting. No email, no Slack notification, no PagerDuty alert unless you set it up yourself. The job shows as "failed" in the dashboard. If nobody checks the dashboard, nobody knows.

Fix this by adding error handling and external alerting inside your function:

export async function GET(request: Request) {
  try {
    await doTheWork();
    return NextResponse.json({ success: true });
  } catch (error) {
    await fetch('https://hooks.slack.com/services/YOUR/WEBHOOK/URL', {
      method: 'POST',
      body: JSON.stringify({ text: `Cron job /api/cron failed: ${error}` }),
    });
    return NextResponse.json({ error: 'Failed' }, { status: 500 });
  }
}

No Automatic Retries

Vercel does not retry failed cron jobs. If the function times out, crashes, or returns an error, that's it. The next execution happens at the next scheduled time. For a daily job, you lose a full day.

Build retry logic into the function itself, or use an external queue like Inngest or QStash from Upstash that has retry semantics.

Cold Starts Eating Into Your Duration

If your cron job runs infrequently (once a day on Hobby), the function will almost certainly cold-start. On Hobby, you have a 10-second timeout. If your cold start takes 3-4 seconds and your actual work takes 8 seconds, you'll time out.

Keep your cron functions lean. Move heavy work to a separate service. Or switch to Edge Functions, which have faster cold starts (with a 30-second timeout on Pro).

Mismatched Paths Fail Silently

If your path doesn't match an actual route in your deployed project, Vercel won't warn you. The cron job gets registered, the GET request fires, hits a 404, and that's logged as a failure. No build-time validation.

After every deployment that changes cron paths, manually trigger the cron job from the dashboard and verify a 200 response.

Multiple Deployments Create Confusion

If you have multiple production deployments (promoting a staging deployment to production), only the latest production deployment's cron jobs are active. If you're confused about which deployment is "current," you might have stale cron configurations running.

Real Example: Nightly Supabase Cleanup

Here's a pattern we use: cleaning up expired sessions and soft-deleted rows from a Supabase database every night at 2 AM UTC.

// app/api/cron/cleanup/route.ts
import { createClient } from '@supabase/supabase-js';
import { NextResponse } from 'next/server';

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function GET(request: Request) {
  const authHeader = request.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }

  const thirtyDaysAgo = new Date(
    Date.now() - 30 * 24 * 60 * 60 * 1000
  ).toISOString();

  // Delete expired sessions
  const { error: sessionError, count: sessionCount } = await supabase
    .from('sessions')
    .delete()
    .lt('expires_at', new Date().toISOString())
    .select('*', { count: 'exact', head: true });

  // Permanently delete soft-deleted records older than 30 days
  const { error: deleteError, count: deleteCount } = await supabase
    .from('documents')
    .delete()
    .not('deleted_at', 'is', null)
    .lt('deleted_at', thirtyDaysAgo)
    .select('*', { count: 'exact', head: true });

  if (sessionError || deleteError) {
    const errorMsg = sessionError?.message || deleteError?.message;
    console.error('Cleanup failed:', errorMsg);
    return NextResponse.json({ error: errorMsg }, { status: 500 });
  }

  console.log(`Cleaned up ${sessionCount} sessions, ${deleteCount} documents`);
  return NextResponse.json({
    sessionsDeleted: sessionCount,
    documentsDeleted: deleteCount,
  });
}

The vercel.json entry:

{
  "crons": [
    {
      "path": "/api/cron/cleanup",
      "schedule": "0 2 * * *"
    }
  ]
}

I'm using the SUPABASE_SERVICE_ROLE_KEY here, not the anon key, because this is a server-side operation that needs to bypass Row Level Security.

The Supabase team has written about using database functions and pg_cron for scheduled tasks directly at the database level. That's valid -- running cleanup as a Postgres cron via pg_cron means the work never leaves the database. But I prefer the Vercel cron approach when the cleanup logic involves application-level concerns (sending notification emails about deleted accounts, updating external analytics) that don't belong in a SQL function.

Real Example: Cache Warming on a Schedule

Another pattern: pre-warming your ISR cache every hour so users never hit a stale page.

// app/api/cron/warm-cache/route.ts
import { NextResponse } from 'next/server';

const PAGES_TO_WARM = [
  '/',
  '/pricing',
  '/blog',
  '/docs',
  '/features',
];

export async function GET(request: Request) {
  const authHeader = request.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }

  const baseUrl = process.env.NEXT_PUBLIC_SITE_URL!;
  const results = await Promise.allSettled(
    PAGES_TO_WARM.map((path) =>
      fetch(`${baseUrl}${path}`, {
        headers: { 'x-prerender-revalidate': process.env.REVALIDATION_TOKEN! },
      })
    )
  );

  const failed = results.filter((r) => r.status === 'rejected').length;

  return NextResponse.json({
    total: PAGES_TO_WARM.length,
    succeeded: PAGES_TO_WARM.length - failed,
    failed,
  });
}
{
  "crons": [
    {
      "path": "/api/cron/warm-cache",
      "schedule": "0 * * * *"
    }
  ]
}

This runs at the top of every hour. On a Hobby plan, you're limited to once per day, which defeats the purpose -- so this pattern only works on Pro or higher.

When to Reach for an External Scheduler Instead

Vercel Cron Jobs are great for simple recurring tasks. But they have hard limits that push you toward external tools in specific scenarios.

You Need Dynamic Scheduling

Vercel cron schedules are static -- defined at deploy time in vercel.json. If you need to schedule a job based on user action ("send a reminder 48 hours after signup"), you can't do that with Vercel Cron Jobs. The common workaround of running a cron every minute and polling your database is a polling anti-pattern -- it wastes compute and creates its own reliability surface.

For dynamic scheduling, look at:

  • Upstash QStash -- HTTP-based message queue with scheduled delivery. $1/100k messages on the pay-as-you-go plan.
  • Inngest -- event-driven functions with scheduling, retries, and step functions. Free tier covers 5,000 runs/month.
  • Trigger.dev v3 -- background jobs with scheduling. Generous free tier.

You Need Retries and Exactly-Once Semantics

Vercel Cron Jobs are fire-and-forget. No retries. If your function fails, the data doesn't get processed until the next scheduled run. For billing aggregation or financial reconciliation, that's not acceptable.

You Need Sub-Minute Scheduling

The finest granularity Vercel supports is once per minute (* * * * *), and only on Pro/Enterprise. If you need something to run every 10 seconds, you need a different approach -- a long-running process, a WebSocket, or an edge-based polling pattern.

You Process Images or Large Files

If your cron job needs to process a batch of images (resize, optimize, upload to a CDN), the 60-second Pro timeout and 250MB memory limit on serverless functions will likely be too tight for large batches. Offload that to a dedicated worker service or use Cloudinary's async processing APIs.

When Vercel Cron Jobs Are the Right Call

Use them when:

  • The task is recurring on a fixed schedule
  • The work completes in under 60 seconds (or under 10s on Hobby)
  • You don't need retries beyond "it'll run again tomorrow"
  • You don't need dynamic or per-user scheduling
  • You want zero infrastructure to manage

For most marketing sites, content platforms, and SaaS dashboards, Vercel Cron Jobs cover 80% of scheduling needs with zero operational overhead.

FAQ

Do Vercel Cron Jobs run on preview deployments?

No. Cron jobs only execute against your production deployment. Preview and branch deployments are completely ignored by the cron scheduler, even if they contain a valid vercel.json with cron entries.

What happens if my cron job function times out?

Vercel treats it as a failed invocation and logs it in the dashboard. There is no automatic retry. The function will next run at its regularly scheduled time. Build your own retry logic if you need guaranteed execution.

Can I pass query parameters or a request body to a cron job?

You can add query parameters to the path in vercel.json (e.g., /api/cron?type=cleanup). The request is always a GET -- there is no request body. Use query strings or separate endpoints for different tasks.

How do I test cron jobs locally?

Call the endpoint directly with curl, passing the Authorization header: curl -H "Authorization: Bearer your-secret" http://localhost:3000/api/cron. As of March 2026, you can also manually trigger cron jobs from the Vercel dashboard.

Are Vercel Cron Jobs free?

The cron scheduling itself doesn't have a separate charge, but the function invocations count against your plan's serverless function usage. On Hobby, that's 100 GB-hours/month. On Pro, it's 1,000 GB-hours included, then $0.18 per additional GB-hour.

Can I use Edge Functions with cron jobs?

Yes. Your cron path can point to an Edge Function. Edge Functions have faster cold starts, which helps on tight timeout budgets. Export your handler with export const runtime = 'edge' in Next.js App Router.

What cron expression runs a job every Monday at 9 AM UTC?

Use 0 9 * * 1. The fifth field is day of week, where 0 = Sunday and 1 = Monday. Remember, this is UTC -- adjust for your local timezone.