# Vercel cron jobs that keep mobile backends fast and reliable

> Run Vercel cron jobs for cleanup, sync, and push fan-out so your mobile backend stays fast while users sleep.
> By Dave · 2026-09-08
> Source: https://otf-kit.dev/blog/vercel-cron-mobile-backend-jobs

Mobile apps rarely fail at noon on a quiet Tuesday. They fail at 2 a.m. when a token refresh breaks, when a stale cache poisons the home feed, or when an abandoned cart sequence never fires because nobody scheduled it. If your backend is a set of API routes behind your Expo or React Native app, cron jobs are the cheapest reliability upgrade you can ship this week.

This guide shows how to run scheduled jobs for a mobile backend on Vercel Cron: nightly cleanup, push fan-out, subscription reconciliation, and cache warming. It pairs well with our walkthrough of [edge functions for mobile backends](/blog/supabase-edge-functions-mobile-backend) — cron is the trigger, edge functions and API routes do the work.

## Why mobile backends need scheduled jobs

A mobile client is unreliable by design. It goes offline, gets killed by the OS, skips updates, and hammers your API in bursts when connectivity returns. Moving recurring work off the device and onto a schedule gives you three wins: predictable load, consistent state, and fewer background execution headaches on iOS and Android.

Typical jobs for a production mobile backend include expiring old sessions and refresh tokens, recomputing leaderboards and streaks, sending reminder pushes for carts and habits, reconciling RevenueCat or Stripe subscription status, pruning soft-deleted rows, and warming caches before the morning peak. If you are shipping Expo with config plugins, the same production mindset from our [Expo config plugins guide](/blog/expo-config-plugins-production-guide) applies here: define it in code, review it, and deploy it the same way every time.

## How Vercel cron actually works

Vercel Cron invokes an HTTP route in your project on a schedule you declare in `vercel.json`. Each entry has a path and a cron expression. On the scheduled time, Vercel sends a GET request to that path. Your route handler runs, returns a response, and Vercel logs the invocation under your project dashboard.

```json
{
  "crons": [
    {
      "path": "/api/cron/nightly-cleanup",
      "schedule": "0 2 * * *"
    },
    {
      "path": "/api/cron/subscription-sync",
      "schedule": "*/15 * * * *"
    }
  ]
}
```

The schedule uses standard cron syntax in UTC. Keep two constraints in mind. First, the route must respond within your plan's function duration limit, so long jobs must be chunked. Second, cron routes are plain HTTP endpoints, so they must be secured — anyone who guesses the path could trigger them. We cover auth below.

## Design jobs the mobile way

Start from the client contract. Every job should make at least one screen faster or one push more relevant. A cleanup job that deletes expired guest sessions keeps login snappy. A streak recompute job keeps the home screen correct without the client doing math. A subscription sync keeps the paywall honest, which matters if you monetize with the patterns in our [RevenueCat paywalls guide](/blog/revenuecat-paywalls-expo-apps-production).

Keep each job to one responsibility and one database transaction boundary. A job named `nightly` that cleans sessions, sends emails, recomputes rankings, and rebuilds search indexes is four jobs wearing a trench coat. Split them so a failure in one does not block the others, and so you can give each its own schedule and alert threshold.

## Write a safe cron route

A production cron route does four things: verify the caller, claim a batch of work, process it idempotently, and report what happened. Here is a compact Next.js route handler that follows that shape:

```typescript
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

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

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

  const cutoff = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString();
  const { data, error } = await supabase
    .from("guest_sessions")
    .delete()
    .lt("last_seen_at", cutoff)
    .select("id")
    .limit(1000);

  if (error) {
    console.error("nightly-cleanup failed", error.message);
    return NextResponse.json({ ok: false }, { status: 500 });
  }

  return NextResponse.json({ ok: true, deleted: data?.length ?? 0 });
}
```

Three details matter. The `CRON_SECRET` check rejects drive-by GET requests. The `limit(1000)` keeps each run inside the function timeout. The structured JSON response makes dashboard logs greppable when something goes wrong at 2 a.m.

## Make every job idempotent

Cron invocations can overlap or retry. Design handlers so running twice changes nothing the second time. Deletes keyed on timestamps are naturally idempotent. Updates should set absolute state (`status = 'expired'`) rather than toggling. Inserts should use a deterministic deduplication key such as `job_name + date + entity_id` with a unique constraint, so a retry hits a conflict instead of creating a duplicate push or charge.

For fan-out work like pushes, separate claiming from sending. Mark a batch as `claimed` with a `claimed_at` timestamp and a worker id, then send, then mark `sent`. If a run crashes mid-batch, the next run reclaims rows whose `claimed_at` is older than your timeout. This lease pattern is the difference between "the job failed" and "half our users got the reminder twice."

## Handle timeouts with chunking

Serverless functions end. Budgets differ by plan, but the safe assumption is seconds, not minutes. Any table with more than a few thousand rows needs pagination. Process in batches of 500 to 1000 rows, order by primary key, and carry a cursor. If a single run cannot finish, let the next scheduled run continue — that is exactly what idempotency buys you.

For heavy recomputes such as leaderboards, write results to a separate summary table instead of updating live rows in place. The client reads the summary table, so it never sees half-written state. Swap atomically by writing the new snapshot with a version column and flipping a `current_version` pointer. Your morning-peak users get a consistent read even while the job is mid-run.

## Secure cron endpoints properly

Treat cron paths as admin endpoints. Require a long random `CRON_SECRET` in the Authorization header. On Vercel, first-party cron requests can also carry a signature you can verify, but a shared secret checked in constant time is a fine baseline for most teams. Never put the secret in the URL query string — URLs land in logs. Never reuse the same secret across staging and production.

Scope database credentials tightly. Cron routes run server-side, so they can use a service-role key, but prefer Postgres row-level security policies plus a dedicated role where you can. Our [Supabase RLS guide for mobile data safety](/blog/supabase-rls-mobile-data-safety) walks through the policy patterns that keep a service key from becoming a skeleton key.

## Observe what runs at night

A job without logging is a rumor. Log one structured line per run: job name, duration, rows processed, and outcome. Vercel keeps invocation logs per cron execution, and piping those into your alerting means you hear about the third consecutive failure, not the thirtieth. Add a lightweight heartbeat row in your database — each successful run upserts `job_name` and `finished_at` — and alert when any heartbeat goes stale past twice its schedule interval.

Track client-visible effects too. If the subscription sync runs green but paywall enables lag by an hour, the schedule is too sparse. If cleanup deletes nothing for a week, either the query is wrong or retention changed. Review cron dashboards in the same weekly pass where you check crash rates and API latency.

## Keep costs and cold starts down

Cron jobs are cheap until they scan million-row tables every five minutes. Index the columns your jobs filter on — `last_seen_at`, `status`, `claimed_at` — and check query plans before tightening a schedule. Prefer one run per hour over one per five minutes unless users feel the difference. Push fan-out and subscription syncs usually justify frequent runs; analytics rollups and pruning do not.

Cold starts matter less for background jobs than for user-facing routes, but keep bundles lean anyway. Import only what the handler needs. Share validation and Supabase client helpers with your API routes rather than duplicating them, so fixes land everywhere at once.

## Ship checklist before you merge

Before your first production schedule, confirm each item: every cron path returns 401 without the secret, every handler is safe to run twice, no run touches more than one batch without a cursor, each job logs one summary line, heartbeats exist with alerts, and staging runs the same `vercel.json` on a sparser schedule. That last point catches expression typos — UTC versus local time has bitten every team at least once.

Start with two jobs: one cleanup and one sync that users can feel. Grow the schedule as your app grows, and retire jobs that no longer move a metric. A small, honest cron table beats an ambitious one nobody trusts.

## Sources

- Vercel documentation on cron jobs, including schedule syntax and configuration: https://vercel.com/docs/cron-jobs