Back to blog

Alternatives · Feb 21, 2026

Netlify Scheduled Functions alternative: any cadence, no 30-second cap

Netlify Scheduled Functions are great if your scheduled work fits in 30 seconds and your cadence is generous. Cross either line and you need a real scheduler. Here is the external cron pattern teams use to ship any cadence, longer runs, and prod-and-preview parity from a single dashboard.
crontap.com / blog
Netlify Scheduled Functions are still in beta with a hard 30-second runtime cap and an hourly minimum on lower plans. Here is the external cron pattern teams use to schedule any cadence, run jobs longer than 30 seconds, and keep prod and preview environments in parity without duplicating config.

You added a [functions] schedule entry to your netlify.toml, deployed, and watched it fire on cadence. Then your function started doing real work and got cut off at 30 seconds. Or you tried to set it to every 5 minutes and discovered the cadence floor on your plan is hourly. Or you wanted prod and preview to fire the same job at the same cadence and ended up with duplicated configs in two netlify.toml files. Netlify Scheduled Functions are still in beta, and beta means real limits. Here is what they actually do, where they break, and the external cron pattern that fixes all three problems with one schedule.

If you want the short version: keep your function (or move the work to a backend that can run longer), drop the [functions] schedule from netlify.toml, point Crontap at the function URL with an Authorization header. You get any cadence (every 1 minute on Pro), no 30-second cap on the target, per-IANA timezones, and one schedule that fires both prod and preview when you point it at both URLs.

What Netlify Scheduled Functions do today

Netlify Scheduled Functions are a feature in beta. You declare a schedule in netlify.toml (or via the inline schedule helper inside the function file), Netlify spins up the function on cadence on its serverless runtime, and the function returns within 30 seconds.

A typical netlify.toml entry looks like this:

[functions."refresh-tokens"]
  schedule = "0 * * * *"

Or, equivalently, inline in the function file:

import { schedule } from "@netlify/functions";

export const handler = schedule("0 * * * *", async () => {
  await refreshTokens();
  return { statusCode: 200 };
});

That is the entire surface. The platform fires the function at the cadence you give it, you write the work, and you get logs in the Netlify console.

The three walls teams hit

The beta is solid for what it does. The friction shows up at three predictable points.

The 30-second function timeout

Netlify Scheduled Functions inherit Netlify's serverless function runtime cap, which is 30 seconds. That is fine for "send a webhook" or "invalidate one cache key". It is not fine for almost anything else: even a small data sync, a few-page report build, an OAuth-token-refresh-with-validation pass, or a queue drain regularly crosses the 30-second line.

If your work runs in 5 seconds today, your work will probably run in 35 seconds in eight months. That is not a critique of your code; it is just how scope creeps. Once you cross 30 seconds even occasionally, the cron fires, hits the cap, returns nothing useful, and the schedule looks healthy in the dashboard while the work silently fails.

The hourly cadence floor on lower plans

Netlify documents Scheduled Functions as supporting cron expressions, but the realistic cadence on a free or starter plan is hourly minimum. If you want every 15 minutes, every 5 minutes, or every minute, you are on a higher plan, or you are not on Scheduled Functions.

The 5-minute and 1-minute cadences are common: poll a third-party API, refresh OAuth tokens with a buffer, ping a healthcheck, drain a queue. None of those fit "once an hour" unless you accept up-to-an-hour latency.

Prod and preview duplicate config

A common Netlify pattern: prod runs on *.netlify.app (or your custom domain), preview runs on *-preview.netlify.app per branch. If you want both environments to fire the same scheduled work, you end up with two paths:

  1. Define the schedule in netlify.toml and let it fire on every deployed environment, including preview branches you do not want to fire (every preview deploy that includes the [functions.x] block fires the schedule, on every branch). Easy to set up, easy to make a mess.
  2. Add if (context.deploy.context !== "production") return; guards inside the function so it no-ops on preview. Now you have logic in your runtime that exists only to dodge a config gap.

There is a quieter shape for this: an external scheduler that points two named schedules at two named URLs, with separate cadences and separate timezones if needed.

The external cron pattern

Here is the shape we recommend. It works for any function, on any Netlify plan, at any cadence the target supports.

Crontap (cron)  →  HTTPS POST  →  Your /.netlify/functions/<fn> URL  →  the actual work

Netlify still owns the runtime. Crontap owns the clock. The contract between them is one HTTPS POST per cadence with a bearer token.

Step 1: Convert the function from scheduled to regular

Strip the schedule() wrapper or remove the [functions.x] schedule = "..." block from netlify.toml. The function becomes a regular Netlify function with a public URL. If your work was already wrapped in a regular handler, this is one delete.

export async function handler(event) {
  if (event.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) {
    return { statusCode: 401, body: "Unauthorized" };
  }

  await refreshTokens();
  return { statusCode: 200, body: JSON.stringify({ ok: true }) };
}

Three things to note:

  1. The handler reads the Authorization header and refuses anonymous requests. Without this, your function URL is a public POST endpoint anyone can fire.
  2. The work returns 200 quickly. If the actual work is longer than 30 seconds, the function still hits the runtime cap, so move the work to a backend that does not have it (Railway, Cloud Run, your own box) and have the Netlify function be a thin "kick off the work" endpoint.
  3. The endpoint is the same for prod and preview because preview deploys get their own URL automatically. We will use that.

Step 2: Generate and store a bearer token

Generate a long random string locally and store it as an environment variable:

openssl rand -base64 32

Add it as CRON_SECRET in your Netlify project's Site settings, Environment variables. Apply to Production and Preview if you want the same secret to gate both. Redeploy once so the variable is live.

If you already use a secrets manager (Doppler, 1Password CLI), wire it up the same way you do any other secret. The point is that the function refuses anonymous traffic.

Step 3: Point Crontap at the function URL

Head to Crontap and create a new schedule.

  1. URL. Paste your function URL: https://your-app.netlify.app/.netlify/functions/refresh-tokens. Custom domain works the same.
  2. Method. POST.
  3. Headers. Add Authorization: Bearer <your CRON_SECRET>. Crontap stores the value encrypted; you do not see it again after saving.
  4. Cadence. Type plain English ("every 5 minutes") or paste a cron expression. Crontap previews the next 5 fires inline so you can sanity-check before saving.
  5. Timezone. Pick the IANA zone the schedule actually runs in. Netlify Scheduled Functions are UTC; Crontap is per-schedule.
  6. Failure alerts. Add an integration: email / webhook (Slack / Discord / Telegram). Crontap fires on 4xx and 5xx with the response body and timing in the payload, so a Slack alert lands the moment the function returns 401 or 500.

Press Perform test to fire a real request before you trust the cadence. If the function returns 200, you are done. If you see 401, the bearer is mismatched. If you see 5xx, the work itself failed and the alerting just proved itself.

Fix this in 60 seconds with Crontap. Free tier available. No credit card. Schedule your first job →

Worked example: prod and preview parity

Take a team running Netlify preview + Railway prod, with a function refresh-tokens that needs to fire every 30 minutes in Europe/Berlin. They want the same job to run on the production site and on the long-lived preview branch the team uses for QA.

Old shape (Netlify Scheduled Functions): one netlify.toml entry that fires on every deploy, including the preview branch the team did not want firing, plus the QA branches that fire and double-call the third party. Workarounds: if (context.deploy.context !== "production") return; guards, or stripping the schedule on every PR review.

New shape (external cron): two Crontap schedules, both */30 * * * * in Europe/Berlin, one pointing at https://acme.netlify.app/.netlify/functions/refresh-tokens (prod), one pointing at https://qa--acme.netlify.app/.netlify/functions/refresh-tokens (long-lived preview). Same Authorization: Bearer ... header on both, different bearer secret per environment if you want full isolation. No code in the function checks where it is running because the schedule itself decides what runs where.

If they want prod every 30 minutes but preview only every 2 hours, that is two separate cadences in two separate schedules. No deploy. No netlify.toml edit.

What about jobs that need longer than 30 seconds

If the actual work needs to run for 90 seconds, you cannot fix that on Netlify Scheduled Functions, the cap is the cap. The fix is to move the work, not the trigger. Two clean shapes:

  1. The Netlify function becomes a "kick off the work" endpoint that fires a webhook into a backend (Railway, Cloud Run, your own box) that runs the long job. The Netlify function returns 200 in ~50 ms; Crontap is happy; the actual work runs without a cap on the backend.
  2. Skip Netlify entirely for the scheduled work. Crontap fires directly at the backend URL. The Netlify function exists only because that is where your code already lives, and a long job does not belong on a 30-second runtime.

Either shape works. The 30-second cap stops being a constraint because the cap is on the runtime, not on the schedule.

When to keep Netlify Scheduled Functions

External cron is a shape, not a religion. Cases where the built-in is the right answer:

  • The work fits comfortably under 30 seconds and is unlikely to grow.
  • The cadence is hourly or daily.
  • You want logs next to the function in one place, and you do not need cross-environment parity.
  • The function is tightly coupled to the Netlify deploy lifecycle, and you actually want it to ship and roll back with the code.

For everything else, the external pattern reads cleaner.

FAQ

Can Crontap call my Netlify function on the free plan?

Yes. The function URL is public regardless of the Netlify plan; Crontap fires HTTP at it the same way any HTTP client would. The cadence floor on Crontap Pro is every 1 minute, billed at $3.25/mo annually, regardless of which Netlify plan you are on.

Will Netlify Scheduled Functions and Crontap conflict if I run both?

No. They run independently. Many teams keep one or two [functions.x] schedules for tightly-coupled-to-deploy schedules and add Crontap for everything that needs sub-hour cadence, longer runtime, or cross-environment parity.

Does Crontap work with Netlify preview environments?

Yes. Point a separate Crontap schedule at each environment's URL: prod at your-app.netlify.app, long-lived preview at qa--your-app.netlify.app, custom domains the same way. Per-schedule timezones, headers, and payloads stay independent so prod and preview can carry different config without duplicating cron files.

What if my function takes 90 seconds to finish?

You cannot fix that on Netlify Scheduled Functions, the runtime is capped at 30 seconds. Move the long work to a backend without the cap (Railway, Cloud Run, your own box). The Netlify function (or Crontap directly) becomes a thin trigger that returns 200 quickly while the real work runs elsewhere.

Does this work with Netlify Edge Functions?

Yes. Edge Functions accept HTTPS calls just like regular Functions. The Authorization-header pattern is identical. Edge Functions still have a runtime cap, so the 30-second-cap caveat applies the same way.

Can I keep my code in Netlify and skip both Scheduled Functions and the redeploy dance?

That is the common shape. Keep the function as a regular non-scheduled Netlify function, gate it with an Authorization header, and let Crontap fire it. Your code still ships through Netlify; only the clock changes.

References

Related on Crontap

From the blog

Read the blog

Guides, patterns and product updates.

Tutorials on scheduling API calls, webhooks and automations, plus deep dives into cron syntax, timezones and reliability.

Alternatives

Vercel Cron every minute: beating the Hobby hourly limit

Vercel Cron caps Hobby at hourly cadence and 5 jobs, and ties every change to a redeploy. Here is the external cron pattern teams use to ship per-minute schedules, per-IANA timezones, and one dashboard across projects without paying $20/mo per user for Pro.

Alternatives

Cloud Run cron without Cloud Scheduler

Cloud Scheduler costs $0.10 per job per month after the first 3 and asks for OIDC plus IAM bindings on every target. Here is the IAM-free pattern Cloud Run teams use to fire their .run.app URLs on a clock with one bearer token and one dashboard across every GCP project.

Alternatives

Heroku Scheduler alternative: any cron expression without the add-on

Heroku Scheduler caps you at three cadences and account-wide UTC, and spins a one-off dyno per run. Here is the external cron pattern that gives you any cron expression, per-schedule timezones, and zero per-execution dyno spin-up cost.

Guides

Running an OpenAI sentiment pipeline on a real scheduler

OpenAI batch work needs a clock, not a user session. Here is the scheduled HTTP-route pattern teams use to drain LLM batches at a sustainable rate inside OpenAI's rate limits, with per-task failure alerts.

Reference

Cron syntax cheat sheet with real-world examples

Cron syntax without the math. Every pattern you're likely to reach for (every 5 minutes, weekdays, business hours, first of the month), with a practical example and a link to a free debugger.