Musa Yılmaz
·13 min read

Vercel Cron Jobs on Hobby Plan: Limits, Security and Fixes

vercelnextjscrondeployment

Vercel cron jobs fail in two typical ways. Either the deploy breaks with "Hobby accounts are limited to daily cron jobs", or the deploy goes through, the job shows up in the dashboard, and nothing happens: no logs, no error, no side effect. The first is a plan limit and the message tells you so. The second is almost always one of a handful of configuration details that Vercel documents but scatters across several pages.

This post puts them in one place for a Next.js 16 App Router project: what the Hobby plan actually allows, how to set up and secure a cron route, the reasons a job silently doesn't run, and what to do when once a day isn't enough. The numbers were checked against Vercel's documentation in October 2026. They have changed before, so confirm them before relying on them.

How Vercel cron actually works

There is no scheduler process running your code. At the scheduled time, Vercel sends an HTTP GET request to a path on your production deployment. That's the whole mechanism, and most of the surprises below follow from it:

  • The request is a normal GET to a normal route. Anything that affects normal requests (redirects, caching, firewall rules) affects the cron call too.
  • Requests carry the user agent vercel-cron/1.0 and an x-vercel-cron-schedule header containing the expression that fired.
  • The schedule is always UTC. There is no timezone setting.
  • Cron jobs run only on production deployments. A preview deployment never gets called.

The job is a Vercel Function invocation, so function limits and pricing apply to it. On Hobby that means the same 300-second duration limit as any other function (default and maximum are both 300 seconds with Fluid compute). If your job needs longer than that, no setting will help on Hobby; I cover the options in Vercel function timeouts and maxDuration.

The Hobby limits, current numbers

HobbyPro / Enterprise
Cron jobs per project100100
Minimum intervalonce per dayonce per minute
Timing precisionwithin the hour (±59 min)within the minute

If you search for this, you'll still find posts and cached pages quoting "2 cron jobs per account" for Hobby. That was the old limit. Vercel's current documentation lists 100 cron jobs per project on every plan.

Two rules matter on Hobby.

Each expression may fire at most once a day, and this is checked at deploy time. 0 * * * * (hourly) or */30 * * * * fails the deployment with the error quoted at the top. The check is about the expression, so something like 0 6,18 * * *, which fires twice a day, falls under the same rule.

The hour is a window, not a time. Vercel spreads Hobby invocations across the hour to distribute load. 0 1 * * * can run at any point between 01:00 and 01:59 UTC. If the job has to happen before something else (a report at 09:00, say), schedule it at least an hour earlier.

And because it's UTC: if you want a job at 07:00 in a UTC+3 timezone, the expression is 0 4 * * *. A few more syntax limits: no names like MON or JAN, and you can't set both day-of-month and day-of-week. One of them has to be *.

Setting up a cron route

Declare the job in vercel.json at the project root:

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "crons": [
    {
      "path": "/api/cron/daily-cleanup",
      "schedule": "0 3 * * *"
    }
  ]
}

Then the route handler. The endpoint is a public URL, so anyone who finds the path can call it. Vercel's answer is CRON_SECRET: add an environment variable with that name to the project, and Vercel sends its value as Authorization: Bearer <value> on every cron invocation. Your handler compares the two:

// app/api/cron/daily-cleanup/route.ts
import { timingSafeEqual } from "node:crypto";
 
function isAuthorized(header: string | null): boolean {
  const secret = process.env.CRON_SECRET;
  if (!secret || !header) return false;
  const expected = Buffer.from(`Bearer ${secret}`);
  const received = Buffer.from(header);
  return (
    expected.length === received.length && timingSafeEqual(expected, received)
  );
}
 
export async function GET(request: Request) {
  if (!isAuthorized(request.headers.get("authorization"))) {
    return new Response("Unauthorized", { status: 401 });
  }
 
  // ...the actual work
 
  return Response.json({ ok: true });
}

Vercel's own example does a plain !== comparison, which works. The timingSafeEqual version just avoids leaking how many leading characters matched; it requires equal-length buffers, hence the length check first. Two details from the docs that matter:

  • Refuse when the secret is missing. The !secret check means a deployment where someone forgot the variable returns 401 instead of running the job for everyone.
  • Keep the value header-safe. Vercel recommends a random string of at least 16 characters. Its troubleshooting guide lists newlines or special characters in CRON_SECRET as a cause of failed invocations. A long alphanumeric string avoids the problem. Watch for a trailing newline if you paste it from a terminal.

If you add or change CRON_SECRET, redeploy. Environment variables apply to new deployments, not the running one.

Why the job doesn't run (and the logs are empty)

The one fact that explains most of these: when a cron call ends in a redirect or a cached response, it doesn't appear in the logs. So an empty log doesn't mean the job wasn't called. It can just as well mean the call was answered before your function ran.

1. A redirect in front of the route

Cron invocations do not follow redirects. A 3xx response is final; the job "completes" and your code never runs. On a Next.js site, the usual source is the proxy (the file formerly called middleware): locale prefixes from an i18n library, a redirect to a canonical host, or a trailing-slash rule.

On this site, for example, the proxy redirects *.vercel.app hostnames to the main domain with a 308. Vercel's docs show a *.vercel.app URL as an example of what a cron request targets. What keeps an /api/... cron route out of that redirect is the matcher:

export const config = {
  matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};

If your matcher runs the proxy on everything, either exclude the cron path or make sure the proxy passes it through untouched. The same applies to trailingSlash: true in next.config: then the path in vercel.json needs the trailing slash, or the call gets redirected.

2. A cached response

If the GET handler's response gets cached, later cron calls get the cached copy and your code doesn't run. In Next.js 16 the rule depends on whether Cache Components is enabled:

  • Without Cache Components, Route Handlers aren't cached by default; you'd have to opt in with something like dynamic = 'force-static'. Vercel's troubleshooting guide still recommends export const dynamic = 'force-dynamic' on the cron route, which is harmless insurance in this model.
  • With Cache Components, the dynamic export is removed, so that advice doesn't apply. Instead, a GET handler is prerendered at build time if it doesn't touch request or uncached data. Reading request.headers stops prerendering, so a handler that checks CRON_SECRET first, like the one above, runs at request time.

That's another reason to put the authorization check at the very top. It protects the route, and it reads the request on every invocation.

3. It's not production

Cron runs only on production deployments. Testing a new job on a preview URL and waiting for it to fire won't work. Also, schedules come from the deployed configuration. Editing vercel.json locally changes nothing until it's deployed.

4. The path doesn't exist

If the path in vercel.json doesn't match a route (a typo, a route that moved, a missing trailing slash), Vercel still sends the request and gets a 404. The job counts as having run. The fix is in the runtime logs: filter by request path and look for 404s.

5. Something blocks the request

Custom firewall (WAF) rules can block the cron request like any other. Vercel's guide lists this as a cause of jobs that never execute. If you have rules that block by user agent or path, make sure vercel-cron/1.0 and your cron paths get through.

Missed runs and duplicate runs

Two properties of Vercel cron that are easy to miss, and that shape how the handler should be written:

  • No retries. If an invocation fails, Vercel doesn't try again. The next attempt is the next scheduled run, which on Hobby is tomorrow.
  • Delivery is best effort. Occasionally a scheduled call doesn't reach your function at all, with no runtime log. And occasionally the same scheduled run is delivered more than once.

So the job has to survive being skipped and being run twice. Vercel's recommendation is idempotent, reconciliation-based work: instead of "process today's items", do "process everything that isn't done yet". A run that was skipped then gets caught up by the next one, and a duplicate run finds nothing left to do.

With PostgreSQL, a single statement can both pick the work and mark it as taken:

UPDATE invoices
SET reminder_sent_at = now()
WHERE due_date < now()
  AND reminder_sent_at IS NULL
RETURNING id, customer_email;

If two invocations run this at the same moment, at PostgreSQL's default isolation level (Read Committed), they can't both claim the same row. The second one waits on the row lock, re-checks the WHERE condition, sees reminder_sent_at is no longer null, and skips it. Each row comes back to exactly one invocation.

The trade-off is deliberate: the row is marked before the email goes out. If sending fails, that reminder is lost rather than sent twice. For reminders that's usually the right call. For something that must happen, use a status column (pending, processing, done) and let the next run pick up rows that have been stuck in processing for too long.

If a job can run long enough to overlap with the next one, add a lock (Vercel's docs suggest a Redis lock). On Hobby, with one run a day and a 300-second limit, overlap mostly comes from duplicate delivery or a manual run while the scheduled one is still going. The atomic update above already covers that.

Testing without waiting a day

Locally, a cron route is just a route. Call it with the header:

curl -i -H "Authorization: Bearer $CRON_SECRET" \
  http://localhost:3000/api/cron/daily-cleanup

Schedules don't fire under next dev or vercel dev. Only the request itself can be tested locally. And note the difference: curl without -L, like cron, doesn't follow redirects, so a 3xx response here is the same problem cron would hit. Your browser would silently follow it and hide the problem.

For the deployed job, the Vercel CLI can trigger it on demand (the crons command is in beta at the time of writing):

vercel crons ls
vercel crons run /api/cron/daily-cleanup

vercel crons run reads the cron definitions from the deployed project, not your local vercel.json, so it only finds jobs that are already in production. Then check the result in the runtime logs, filtered by the request path.

When once a day isn't enough

First, check whether you need cron at all. Publishing scheduled content is the most common reason people reach for it, and on Next.js it doesn't need a scheduler. A date filter plus ISR's revalidation window does it, which is how posts on this site go live. I describe the setup in Vercel's free tier in production.

If you do need a more frequent trigger, the options are:

Upgrade to Pro. Per-minute schedules and per-minute precision, same setup.

Trigger the route from outside. The route is just an HTTPS endpoint protected by a bearer token. Anything that can send a scheduled request with that header can drive it. GitHub Actions is the obvious choice if the code already lives on GitHub:

# .github/workflows/sync.yml
name: hourly-sync
on:
  schedule:
    - cron: "17 * * * *"
  workflow_dispatch:
 
jobs:
  trigger:
    runs-on: ubuntu-latest
    steps:
      - name: Call the sync endpoint
        run: |
          curl --fail-with-body -sS \
            -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
            https://example.com/api/cron/sync

Store the same value as a repository secret named CRON_SECRET, and the handler above works unchanged. GitHub's schedule has its own caveats, all from its documentation:

  • The shortest interval is every 5 minutes.
  • Runs can be delayed under load, especially at the start of every hour, and with enough load some queued runs are dropped. That's why the example uses minute 17 instead of 0.
  • Scheduled workflows run only from the default branch.
  • In a public repository, scheduled workflows are disabled automatically after 60 days without repository activity.

So the same rule applies here as with Vercel: an external trigger can be late or skip a run, and the handler has to handle both. workflow_dispatch adds a manual "Run workflow" button, which is handy for testing. For deploys driven from the same CI, see building Docker images in GitHub Actions.

There are also hosted HTTP schedulers built for this. I haven't used them in production, so I'm not comparing them here. The same checklist applies to any of them: bearer token, no redirects, idempotent handler.

Move scheduled work off Vercel. If the backend already runs on a server (a Django app with Celery Beat, a VPS with system cron), the schedule belongs there, next to the data it works on. Vercel then serves the frontend and doesn't need to know about the schedule at all.

Summary

  • On Hobby, each cron expression may fire once a day. More frequent expressions fail at deploy, and the run happens somewhere within the scheduled hour, in UTC.
  • A cron job is an HTTP GET to production. Redirects end it, cached responses skip it, and neither shows up in the logs. Check your proxy matcher and trailing-slash setting first.
  • Protect the route with CRON_SECRET, and refuse the request when the variable is missing. On Next.js 16 with Cache Components, reading the header is also what keeps the handler dynamic, because force-dynamic isn't available there.
  • Vercel doesn't retry and can deliver twice. Write the job as "process everything not yet done", with an atomic claim, and missed or duplicate runs stop mattering.
  • Need more than once a day? Pro, an external trigger like GitHub Actions (5-minute minimum, can be late or skip runs), or a scheduler on the server that owns the data.