Cron Jobs

Schedule recurring background tasks using Inngest cron functions or Vercel cron — with idempotency patterns for safe re-runs.

Overview

Nebutra supports two approaches for scheduled recurring jobs:

  • Inngest cron — recommended for jobs that benefit from step functions, retries, and observability
  • Vercel cron — simple HTTP-triggered schedule, good for lightweight tasks or triggering Inngest events

Inngest cron functions

Define a cron schedule directly on your Inngest function using standard cron syntax:

import { inngest } from "@nebutra/event-bus";

export const weeklyUsageReport = inngest.createFunction(
  { id: "weekly-usage-report", retries: 2 },
  { cron: "0 9 * * MON" },   // every Monday at 09:00 UTC
  async ({ step }) => {
    const tenants = await step.run("fetch-active-tenants", async () => {
      return db.tenant.findMany({ where: { status: "active" } });
    });

    // Fan-out: generate a report for each tenant in parallel
    await step.run("generate-reports", async () => {
      return Promise.all(
        tenants.map((tenant) =>
          generateUsageReport(tenant.id, "weekly")
        )
      );
    });
  }
);

Cron syntax reference

┌─────────── minute (0–59)
│ ┌───────── hour (0–23)
│ │ ┌─────── day of month (1–31)
│ │ │ ┌───── month (1–12)
│ │ │ │ ┌─── day of week (0–6, 0 = Sunday)
│ │ │ │ │
* * * * *

Common schedules:

ExpressionMeaning
0 9 * * MONEvery Monday at 09:00 UTC
0 0 1 * *First day of every month at midnight
0 * * * *Every hour on the hour
*/15 * * * *Every 15 minutes
0 9 * * 1-5Weekdays at 09:00 UTC

All Inngest cron times are in UTC. Convert tenant-local times before scheduling if your users expect jobs at a specific local time.

Vercel cron

For simple scheduled tasks that just need to hit an endpoint, configure Vercel cron in vercel.json:

{
  "crons": [
    {
      "path": "/api/v1/cron/quota-reset",
      "schedule": "0 0 1 * *"
    },
    {
      "path": "/api/v1/cron/cleanup-expired-sessions",
      "schedule": "0 3 * * *"
    }
  ]
}

The Vercel cron runner calls your endpoint via an authenticated GET request. Verify the request is from Vercel using the Authorization header:

// backends/gateway/src/routes/cron.ts
import { Hono } from "hono";

const cron = new Hono();

cron.get("/quota-reset", async (c) => {
  const authHeader = c.req.header("Authorization");
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return c.json({ error: "Unauthorized" }, 401);
  }

  // Trigger an Inngest event instead of doing work inline
  await inngest.send({ name: "quota/monthly-reset", data: {} });

  return c.json({ ok: true });
});

Set CRON_SECRET to a random secret in your Vercel environment variables.

Prefer triggering an Inngest event from the cron endpoint rather than doing heavy work inline. This gives you retries, step isolation, and observability for free.

Common cron jobs

Monthly quota reset

export const monthlyQuotaReset = inngest.createFunction(
  { id: "monthly-quota-reset", retries: 3 },
  { cron: "0 0 1 * *" },   // 1st of every month, midnight UTC
  async ({ step }) => {
    await step.run("reset-all-quotas", async () => {
      return db.tenantQuota.updateMany({
        data: { usedTokens: 0, usedApiCalls: 0 },
      });
    });
  }
);

Daily cleanup of expired data

export const dailyCleanup = inngest.createFunction(
  { id: "daily-cleanup", retries: 2 },
  { cron: "0 3 * * *" },   // 3:00 AM UTC daily
  async ({ step }) => {
    await step.run("delete-expired-sessions", async () => {
      return db.session.deleteMany({
        where: { expiresAt: { lt: new Date() } },
      });
    });

    await step.run("delete-expired-exports", async () => {
      const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days ago
      return db.export.deleteMany({
        where: { createdAt: { lt: cutoff }, status: "completed" },
      });
    });
  }
);

Weekly usage reports

export const weeklyReport = inngest.createFunction(
  { id: "weekly-report-dispatch", retries: 2 },
  { cron: "0 9 * * MON" },
  async ({ step }) => {
    const tenants = await step.run("get-pro-tenants", async () => {
      return db.tenant.findMany({ where: { plan: { in: ["PRO", "ENTERPRISE"] } } });
    });

    // Emit one event per tenant — each handled independently
    await step.run("emit-report-events", async () => {
      return inngest.send(
        tenants.map((t) => ({
          name: "report/weekly-requested",
          data: { tenantId: t.id },
        }))
      );
    });
  }
);

Idempotency

Cron jobs can run more than once in edge cases (clock skew, deployment retries). Always write cron handlers to be idempotent — running them twice produces the same result as running once.

Upsert instead of insert

// ❌ Not idempotent — creates a duplicate on re-run
await db.report.create({ data: { tenantId, period: "2025-03", ... } });

// ✅ Idempotent — second run updates the existing row
await db.report.upsert({
  where: { tenantId_period: { tenantId, period: "2025-03" } },
  create: { tenantId, period: "2025-03", ... },
  update: { updatedAt: new Date() },
});

Lock with a unique execution ID

export const monthlyReset = inngest.createFunction(
  { id: "monthly-quota-reset" },
  { cron: "0 0 1 * *" },
  async ({ event, step }) => {
    const lockKey = `quota-reset:${new Date().toISOString().slice(0, 7)}`; // e.g. "quota-reset:2025-03"

    await step.run("acquire-lock-and-reset", async () => {
      const existing = await db.jobLock.findUnique({ where: { key: lockKey } });
      if (existing) return { skipped: true };  // already ran this month

      await db.jobLock.create({ data: { key: lockKey, createdAt: new Date() } });
      await resetAllQuotas();
    });
  }
);

Avoid scheduling heavy jobs more frequently than every 15 minutes. Frequent cron jobs can exhaust your Inngest plan's run quota and cause unexpected costs at scale.

Testing cron jobs locally

Use the Inngest Dev Server to trigger a cron function on demand without waiting for the schedule:

# Start the Dev Server
npx inngest-cli@latest dev -u http://localhost:3001/api/v1/inngest

Then open http://localhost:8288, navigate to your function, and click Trigger. You can also send a manual trigger from the CLI:

npx inngest-cli@latest trigger --event "inngest/scheduled.timer" --function "weekly-usage-report"

How is this guide?

Edit on GitHub

Last updated on

On this page