DocumentationSearch docs
  1. 01Getting started
  2. 02Next.js and Vercel
  3. 03Node servers and scripts
  4. More JavaScript platforms

    1. 04SvelteKit
    2. 05Nuxt and Nitro
    3. 06React Router and Remix
    4. 07NestJS
    5. 08Strapi
    6. 09Netlify
    7. 10Firebase
    8. 11Convex
    9. 12Trigger.dev
    10. 13Inngest
    11. 14Cloudflare Workers
    12. 15Supabase and pg_cron
  5. Ruby

    1. 16Ruby on Rails
    2. 17Ruby
  6. Python

    1. 18Django
    2. 19Celery
    3. 20Python
  7. PHP

    1. 21Laravel
    2. 22Symfony
    3. 23WordPress
    4. 24Drupal
    5. 25Craft CMS
    6. 26PHP
  8. Go

    1. 27Go
    2. 28Go schedulers
  9. Rust

    1. 29Rust
    2. 30Rust schedulers
  10. Elixir

    1. 31Elixir
    2. 32Elixir schedulers
  11. Java

    1. 33Java
    2. 34Java schedulers
  12. .NET

    1. 35.NET
    2. 36.NET schedulers
  13. Reference

    1. 37Schedules, grace, and timeouts
    2. 38What it catches
    3. 39Alerts
    4. 40AI triage
    5. 41Stores
    6. 42Dashboard and API
    7. 43MCP server
    8. 44Agent skill
    9. 45TypeScript API reference
    10. 46Limits and design notes
    11. 47Environment variables
    12. 48Stability
    13. 49Deprecations

Convex

Convex

Convex crons are declared in convex/crons.ts and call a mutation or an action on a schedule. CronWatch can watch them, with two conditions that come from how Convex runs code.

  • The store needs the Node runtime. Queries, mutations, and default-runtime actions run in Convex’s own JavaScript runtime, which has no Node built-ins. The SDK itself uses only web APIs, but its Postgres store uses the pg driver, which needs Node. Actions in a file that starts with "use node" run on Node, so the wrapped work lives in such an action.
  • The store is outside Convex. Convex functions cannot keep a SQLite file, and there is no store that writes to Convex’s own database. Use Postgres anywhere a Node action can reach over the network (Neon, Supabase, RDS).

If the job’s work already lives in another app that has CronWatch, the simplest setup is a Convex cron whose action calls that app’s handler() endpoint with fetch, and the run is recorded there. The rest of this page runs the job in Convex.

Node actions for the job and the check

// convex/cronwatch.ts
"use node";

import { cronwatch } from "@cronwatch/sdk";
import { postgres } from "@cronwatch/sdk/postgres";
import { slack } from "@cronwatch/sdk/slack";
import { internal } from "./_generated/api";
import { internalAction } from "./_generated/server";

const cw = cronwatch({
  store: postgres({ connectionString: process.env.CRONWATCH_DATABASE_URL }),
  alerts: [slack({ webhookUrl: process.env.SLACK_WEBHOOK_URL! })],
  defaults: { timezone: "UTC" },     // Convex reads cron expressions in UTC
});

const digest = cw.job("daily-digest", { schedule: "0 6 * * *", grace: "10m", timeout: "15m" });

export const dailyDigest = internalAction({
  args: {},
  handler: async (ctx) => {
    await digest.run(async (job) => {
      const sent: number = await ctx.runMutation(internal.digest.send, {});
      job.metric("emails", sent);
    });
  },
});

export const check = internalAction({
  args: {},
  handler: async () => {
    const result = await cw.check();
    return { jobs: result.jobs.length, alerts: result.alerts.length };
  },
});

The action can do its reads and writes through ctx.runQuery and ctx.runMutation, as above, and the run covers all of it. Declare every job in this file, where the check runs, so a job that has never run is still known.

Set the variables with npx convex env set CRONWATCH_DATABASE_URL ... and npx convex env set SLACK_WEBHOOK_URL .... pg loads an optional native module lazily, so mark it external in convex.json, which installs it on the server instead of bundling it:

{
  "node": {
    "externalPackages": ["pg"]
  }
}

The crons

// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";

const crons = cronJobs();

crons.cron("daily digest", "0 6 * * *", internal.cronwatch.dailyDigest);
crons.interval("cronwatch check", { minutes: 5 }, internal.cronwatch.check);

export default crons;

Helpers like crons.daily() and crons.hourly() take an hour and minute in UTC; declare the job with the equivalent cron expression. For crons.interval(), declare schedule: "every 30m" to match { minutes: 30 }.

Limits that matter

  • Duration. A Node action may run for 10 minutes. One stopped at that limit cannot record its end, so the run stays running until a check passes the job’s timeout and marks it stuck. A timeout of 15m reports it within a few minutes of the limit.
  • Skipped runs. Convex runs at most one instance of a cron at a time, and skips a scheduled run when the previous one is still going. Nothing starts, so after the grace period CronWatch reports the slot missed.
  • No retries. Convex does not retry actions, so each fire is one run.

The dashboard

Convex HTTP actions run in the default runtime, not Node, so the dashboard cannot be served from Convex. Serve cw.routes() from any Node app that uses the same Postgres store (a small Hono or Next.js app will do, see Servers and scripts), and point the MCP server at that URL.