Back to blog

Alternatives · Apr 2, 2026

Heroku Scheduler alternative: full cron expressions without the add-on

Heroku Scheduler is fine if every 10 minutes, every hour, or every day at a UTC hour is enough. The wall most teams hit is sub-10-minute cadence, per-schedule timezones, and a one-off dyno spin-up tax on every run. Here is the external cron pattern that fixes all three.
crontap.com / blog
Heroku Scheduler ships three cadences (every 10 minutes, every hour, every day), is account-wide UTC, and spins a one-off dyno per run. Here is the external cron pattern Heroku teams use to ship any cron expression, per-IANA timezones, and zero per-execution dyno cost.

You opened the Heroku Scheduler add-on, expecting a normal cron field, and got three radio buttons: every 10 minutes, every hour, every day. That's the whole product. If you needed every 5 minutes, every 15, or "Tuesday and Thursday at 22:30 Europe/Berlin", you've already noticed the form does not let you type any of those. This post is the long version of why that gap exists, what it costs you in dyno time, and the external cron pattern most Heroku teams end up running instead.

If you want the short version: keep your existing Heroku app, expose the work as one HTTP route, and point Crontap at it. You get any 5-field cron expression, per-IANA timezones, central logs, failure alerts, and one dashboard across every Heroku app (and every non-Heroku target) you own.

Heroku Scheduler's three cadences

Before reaching for an alternative, it's worth being precise about what Heroku Scheduler actually does, because the constraints look small in the UI and large in production. The numbers below come from the Heroku Scheduler docs and the Heroku pricing page.

  • Cadences. Three options: every 10 minutes, every hour at minute 0, every day at a specific hour. There is no */5, no */15, no Tuesday-only, no last-day-of-the-month, no second Wednesday at 14:00. The form does not accept cron expressions; it shows three buckets.
  • Timezone. Account-wide UTC. Every job runs against the same UTC clock. If you want 09:00 in Europe/Berlin, you compute the right UTC hour and live with the fact that the run drifts by an hour twice a year when DST flips.
  • Execution model. Each scheduled task spins up a dedicated one-off dyno, runs your Rake or shell command, and shuts down. You pick the dyno size (Eco, Basic, Standard-1X, etc.); the time billed runs from spin-up to shutdown.
  • Add-on cost. The Scheduler add-on itself is free. You pay for the one-off dynos by the second.
  • Logging. Output lands in the dyno log for the duration of the run. There is no per-task history view, no response code, no payload to inspect later. You scrape heroku logs --tail if you want to know what happened.

That's the whole product surface. If your job is "send the daily digest at 06:00 UTC" and you do not mind a fresh dyno per run, Heroku Scheduler is fine. For anything more interesting, the gaps stack up quickly.

What that means in practice (no every 15 minutes, no sub-10-minute)

The first wall is the cadence. The numbers most teams actually want are not in the three radio buttons.

  • Every 5 minutes. Health checks, queue drains, "did the last sync land yet" probes. Not an option. The closest is every 10 minutes, which is twice as slow.
  • Every 15 minutes. Polling third-party APIs that update on a slow cadence (Shopify checkouts, GitHub status, support inboxes). Not an option. The closest is every 10 minutes, which doubles your API call volume, or every hour, which makes you four times slower.
  • Every 30 minutes. Token refresh with a buffer ahead of the 1-hour expiry. Not an option. The closest is every 10 minutes (3x the work) or every hour (catches the boundary, defeats the buffer).
  • Tuesday and Thursday at 22:30. Office-hours-only batch jobs, weekly maintenance windows, "ship the dunning email twice a week". Not an option. You cannot pick a day-of-week or a minute-of-hour.

Heroku's stance, reasonable as a product decision, is that Heroku Scheduler is "best-effort" and not for jobs that demand a specific minute. The docs are explicit about this. The trouble is that the gap between "best-effort hourly" and "every 5 minutes precisely" covers most of the work teams want to schedule once they're past their first three crons.

The usual workarounds you've already seen on the Heroku forums:

  1. Long-running clock process. A dedicated dyno running a custom Ruby/Node loop that calls your task at the cadence you want. Works, but you pay for a 24/7 dyno just to keep a clock alive.
  2. Sidekiq-Cron, Whenever, or APScheduler. Run the scheduler inside your worker. Same dyno cost as above; also conflates schedule state with deploy state, so you redeploy to change cadence.
  3. External cron service. Something else holds the clock and calls your existing app over HTTPS at the cadence you actually want.

Option 3 is the one this post is about. It's why Crontap exists.

The cost surprise (one-off dynos per execution)

The cadence ceiling is the loud problem. The quiet one is dyno billing.

Every Heroku Scheduler run spins up a fresh one-off dyno. Heroku bills dyno time by the second (with a small minimum). That's fine for a once-a-day Rake task. It's less fine when you turn the dial up.

A worked example, using the Heroku pricing numbers:

  • Pick the every-10-minutes cadence. That's 6 runs per hour, 144 per day, roughly 4,320 per month.
  • Each run spins a one-off dyno. Even if your task takes 5 seconds, you pay for the dyno boot time on top of that. Boot time on a Standard dyno is in the 5-15 second range; on Performance dynos it's faster but the per-second rate is higher.
  • A 10-second per-run dyno window across 4,320 runs is around 12 hours of dyno time per month, billed on top of your normal web dyno that's already running.

Compare to the external-cron pattern: Crontap fires an HTTPS request at a route on your existing web dyno. The web dyno is already running, already billed. The marginal cost of one more HTTP request is effectively zero. There is no spin-up tax.

If you've ever opened your Heroku invoice and noticed a "one-off dyno hours" line item that's larger than you expected, this is usually why.

External cron pattern for Heroku apps

The shape works whether your app is on Eco, Basic, Standard, or Performance dynos, because the schedule lives outside the deployment. Heroku still owns the runtime. Crontap owns the clock. The contract is one HTTPS call per cadence with an Authorization header.

Crontap (cron)  →  HTTPS POST  →  https://your-app.herokuapp.com/scheduled/<job>  →  the actual work

That's it. Two pieces. Let's wire them up.

Step 1: Pick the route you want triggered

Most Heroku teams already have a Rake task or a worker module that does the work. The translation is a thin HTTP wrapper that calls into the same code path.

In a Rails app, that looks like:

# config/routes.rb
post "/scheduled/refresh-tokens", to: "scheduled#refresh_tokens"

# app/controllers/scheduled_controller.rb
class ScheduledController < ApplicationController
  skip_before_action :verify_authenticity_token

  def refresh_tokens
    return head :unauthorized unless valid_cron_token?
    RefreshTokensJob.perform_later
    head :ok
  end

  private

  def valid_cron_token?
    expected = "Bearer #{ENV.fetch('CRON_SECRET')}"
    ActiveSupport::SecurityUtils.secure_compare(
      request.headers["Authorization"].to_s,
      expected
    )
  end
end

Three things to call out:

  1. The route is POST so a casual GET from a crawler cannot trigger it.
  2. The Authorization header is the thing the route trusts. The secret is read from the Heroku config var CRON_SECRET, set with heroku config:set CRON_SECRET=<long-random-value>.
  3. The handler returns 200 quickly and pushes the actual work to a background job (perform_later in Sidekiq, ActiveJob, Resque, whatever you already run). Crontap is your scheduler, not your queue; you don't want a 90-second Rake task hanging an HTTP connection open.

Node, Python, Go, anything Heroku runs accepts the same shape. Add the route, check the header, kick off the work, return 200.

Step 2: Create the Crontap schedule with the full expression you want

Head to Crontap and create a new schedule.

  1. URL. https://your-app.herokuapp.com/scheduled/refresh-tokens, or your custom domain if you've added one to Heroku.
  2. Method. POST.
  3. Headers. Add Authorization: Bearer <your CRON_SECRET>. Crontap stores the value encrypted; you don't see it again after saving.
  4. Cadence. Type plain English ("every 5 minutes", "every 15 minutes weekdays") or paste a cron expression like */15 * * * 1-5. Crontap previews the next 5 fires inline so you can sanity-check the parse before saving.
  5. Timezone. Pick the IANA zone that matches the schedule's intent. Europe/Berlin for "17:00 Berlin time", America/New_York for the 09:00 ET digest, UTC for anything that genuinely is UTC. DST is handled per zone; you do not pre-compute offsets.
  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 is immediately useful.

Press Perform test to fire a real request before you trust the cadence. If the route returns 200, you're done. If you see 401, your bearer is mismatched. If you see 5xx, the route 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 →

A worked example: daily user notifications at 17:00 Europe/Berlin

Here's a pattern from a Heroku customer running daily user notifications at 17:00 Europe/Berlin. The shape is small but it's exactly the case Heroku Scheduler cannot represent.

The job: send a "your daily summary" push at 17:00 local time in Berlin. Heroku Scheduler is UTC. 17:00 Berlin time is 16:00 UTC in summer (CEST) and 16:00... no wait, 16:00 UTC in summer and 16:00 UTC in winter, except DST flips it to 16:00 vs 15:00. You see the problem already; that sentence took you three seconds and the notification team has had to think it through every March and October for years.

The Heroku Scheduler form lets you pick "every day at HH:00 UTC". You pick 16:00 UTC, accept that it'll fire at 18:00 Berlin time for half the year, and either move the entry twice a year or live with the wrong-by-an-hour summer drift.

The Crontap version:

  • URL: https://your-app.herokuapp.com/scheduled/daily-summary
  • Method: POST
  • Headers: Authorization: Bearer <CRON_SECRET>
  • Cadence: 0 17 * * *
  • Timezone: Europe/Berlin

That's the entire change. Crontap evaluates the cron expression in Berlin local time. DST is applied per zone; the schedule lands at 17:00 Berlin every day, summer or winter, no further action required. You add a Slack failure alert in the same form and stop thinking about it.

If the same notification needs to land at 17:00 in three different markets, you duplicate the schedule, change the timezone field, and save. Three schedules, three IANA zones, no DST math at any of them.

The same shape applies to "every 15 minutes weekdays" (*/15 * * * 1-5), "Tuesday and Thursday at 22:30" (30 22 * * 2,4), or any other expression Heroku Scheduler's three buckets cannot reach.

Keeping Heroku Scheduler for the two cadences it does well (optional)

External cron is a shape, not a religion. There are jobs where the built-in is the right answer.

  • A daily Rake task that you do not want to expose over HTTP. Heroku Scheduler runs Rake or shell directly without a public route; if "no public endpoint" is a hard requirement, keep it.
  • A truly UTC-only daily task with a low cadence and no DST sensitivity (database vacuum, log rotation, monthly dunning sweep at midnight UTC).
  • A team that has exactly one or two crons, both at supported cadences, and zero appetite for an extra service.

Many teams pair them: keep Heroku Scheduler for the one daily Rake task that already works, add Crontap for everything that needs every-15-minute cadence, per-schedule timezones, central logs, or alerts on failure. The two do not conflict; they fire at different times against different paths.

Pricing math

Heroku Scheduler is "free", in the sense that the add-on costs nothing. The cost shows up as one-off dyno hours in your monthly invoice. At Eco dyno rates, every-10-minutes runs add up; on Standard or Performance dynos they add up faster. The Heroku Advanced Scheduler (the paid alternative) costs more and still does not give you full cron expressions.

Crontap is $0 on the free tier (free tier available, no credit card). Pro is $3.25/mo billed annually for unlimited schedules with any 5-field cron expression, per-IANA timezones, and central failure alerts. The marginal cost per fire on your Heroku app is the cost of one HTTPS request hitting your existing web dyno, which is zero.

If your invoice already has a "one-off dyno hours" line that exists only because Heroku Scheduler is firing, the external pattern usually pays for itself in the first month.

FAQ

Can Heroku Scheduler run every 5 minutes?

No. The cadences are 10 minutes, 1 hour, 24 hours, full stop. There is no cron expression field in the UI. If you need every 5 minutes, every 15 minutes, or any other interval, you need an external scheduler pointing at a route on your Heroku app, or a custom clock process.

Will this work if my app is on an Eco dyno that sleeps?

Eco dynos sleep after 30 minutes of inactivity. A Crontap call wakes the dyno just like any other HTTPS request. If your cadence is shorter than 30 minutes, the dyno effectively never sleeps; if it's longer, the first request after a sleep takes the cold-start hit. Both behaviours are unchanged from any other HTTPS traffic to the dyno.

Can I keep Heroku Scheduler running too?

Yes. Many teams pair them: keep Heroku Scheduler for one daily Rake task and add Crontap for everything that needs a cadence Heroku Scheduler does not offer. The two are independent.

What about timezones?

Heroku Scheduler is account-wide UTC. There is no per-task timezone. Workarounds are computing the offset in your code (DST drift twice a year) or maintaining multiple UTC schedules at the right offsets (more drift, more bookkeeping). Crontap stores timezone per schedule and handles DST automatically.

Will Crontap save me money?

Often, because you skip the per-execution dyno spin-up. Heroku Scheduler bills a small one-off dyno per run; Crontap calls the route on the web dyno you already pay for. At every-10-minutes cadence the difference shows up in the next invoice; at every-hour cadence it's marginal.

What about Heroku Advanced Scheduler?

Heroku Advanced Scheduler is the paid third-party add-on you reach for when the free Scheduler isn't enough. It costs more and still does not give you full cron expressions. Crontap Pro is $3.25/mo billed annually and supports any 5-field expression with per-schedule timezones.

Does this require a public HTTP endpoint?

Yes. The endpoint can be auth-protected: Crontap sends an Authorization header you control, and your route checks it before doing any work. If "no public endpoint" is a hard constraint, the right answer is to stay on Heroku Scheduler with the cadence it offers, or to run a custom clock process inside your app.

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.