<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ronen Cypis</title>
    <description>The latest articles on DEV Community by Ronen Cypis (@ronency).</description>
    <link>https://dev.to/ronency</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3975320%2Fd8536c7d-7442-4fcf-9cb2-678e612cbceb.jpeg</url>
      <title>DEV Community: Ronen Cypis</title>
      <link>https://dev.to/ronency</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ronency"/>
    <language>en</language>
    <item>
      <title>Kubernetes CronJob Not Running? Fixing Missed Schedules</title>
      <dc:creator>Ronen Cypis</dc:creator>
      <pubDate>Sun, 27 Sep 2026 16:51:19 +0000</pubDate>
      <link>https://dev.to/ronency/kubernetes-cronjob-not-running-fixing-missed-schedules-n73</link>
      <guid>https://dev.to/ronency/kubernetes-cronjob-not-running-fixing-missed-schedules-n73</guid>
      <description>&lt;p&gt;You check on a CronJob that's supposed to run every hour and find it hasn't fired since yesterday. No failed pod, no alert, no error in your dashboards — it just stopped, and the only clue is buried in the controller's events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cannot determine if job needs to be started: too many missed start times
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes CronJobs are convenient, but they fail quietly in several distinct ways. Once you know the failure modes, you can decide which jobs are safe to leave on the built-in controller and which need a more reliable trigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Kubernetes CronJobs Miss Runs
&lt;/h2&gt;

&lt;p&gt;The CronJob controller is best-effort, and several mechanisms can silently drop a run:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The controller fell behind, or was down
&lt;/h3&gt;

&lt;p&gt;The controller has to be running at the scheduled minute to create the Job. If it's unavailable — a control-plane hiccup, a node drain, an upgrade — the scheduled time can pass unhandled. Whether the run is recovered depends on &lt;code&gt;startingDeadlineSeconds&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;startingDeadlineSeconds&lt;/code&gt; skipped it
&lt;/h3&gt;

&lt;p&gt;If the controller is late by more than &lt;code&gt;startingDeadlineSeconds&lt;/code&gt;, it &lt;strong&gt;skips that run entirely&lt;/strong&gt; rather than starting it late. Leave the field unset and a missed window is simply lost. Set it too low and normal scheduling jitter causes skips.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;concurrencyPolicy: Forbid&lt;/code&gt; suppressed it
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;Forbid&lt;/code&gt;, if the previous run is still executing when the next is due, the new run is &lt;strong&gt;skipped, not queued&lt;/strong&gt;. A job that occasionally runs long can quietly eat its own next execution — and the one after that.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. "Too many missed start times"
&lt;/h3&gt;

&lt;p&gt;If the controller counts &lt;strong&gt;more than 100 missed schedules&lt;/strong&gt; since the last successful start — common after a CronJob is suspended, or after the controller is down for a while — it gives up with &lt;code&gt;too many missed start times&lt;/code&gt; and stops trying. The job stays dead until someone notices.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Timezone confusion
&lt;/h3&gt;

&lt;p&gt;Unless you set &lt;code&gt;spec.timeZone&lt;/code&gt;, many clusters interpret the schedule in &lt;strong&gt;UTC&lt;/strong&gt;. A &lt;code&gt;0 9 * * *&lt;/code&gt; you meant for 9am local runs at the wrong hour, which looks a lot like "not running" to whoever's expecting it.&lt;/p&gt;

&lt;p&gt;The common thread: &lt;strong&gt;the CronJob controller optimizes for in-cluster batch scheduling, not for guaranteed timing or visibility.&lt;/strong&gt; When a run is skipped, nothing pages you — you find out when the downstream data is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Trigger and Watch From Outside the Cluster
&lt;/h2&gt;

&lt;p&gt;For jobs where timing and guaranteed execution actually matter, the reliable pattern is to &lt;strong&gt;drive the schedule from outside the cluster&lt;/strong&gt; and treat the in-cluster workload as the thing that runs, not the thing that keeps time.&lt;/p&gt;

&lt;p&gt;Expose the work behind an authenticated endpoint — an existing service route, or a small handler that creates the Kubernetes Job via the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The Job template stays in the cluster; an external call decides WHEN it runs.&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Job&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;generateName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nightly-report-&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;report&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myorg/report:latest&lt;/span&gt;
      &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An external scheduler calls your endpoint (or the Kubernetes API) at the exact time. Because the trigger lives outside the control plane, a controller hiccup, a suspended CronJob, or a &lt;code&gt;too many missed start times&lt;/code&gt; condition can't silently swallow a run — and every attempt is logged and alertable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a DIY External Cron Isn't Enough
&lt;/h2&gt;

&lt;p&gt;Moving the trigger to a cron job on a bastion host just relocates the problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Requires an always-on machine&lt;/strong&gt; that you now have to patch and monitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fails silently&lt;/strong&gt; — a failed &lt;code&gt;kubectl create job&lt;/code&gt; or API call gets no retry and no alert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No execution history&lt;/strong&gt; across runs, so a missed trigger looks identical to a run that never was.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No retries&lt;/strong&gt; — a transient API error means a lost run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You'd rebuild retries, logging, and alerting around a script — which is a scheduler with none of the guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Runhooks Triggers Kubernetes Jobs Reliably
&lt;/h2&gt;

&lt;p&gt;Runhooks is a scheduled HTTP execution service, and triggering an in-cluster job is just an HTTP call:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a job&lt;/strong&gt; — name it "Trigger nightly report."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the URL&lt;/strong&gt; — your authenticated in-cluster endpoint (behind an Ingress) or the Kubernetes API endpoint that creates the Job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the method and auth&lt;/strong&gt; — &lt;code&gt;POST&lt;/code&gt; with the appropriate token or shared secret header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the schedule&lt;/strong&gt; — any cron expression, in a timezone you specify explicitly, with retries enabled.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What you get that the native controller doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accurate timing&lt;/strong&gt; — the job is triggered when you asked, independent of control-plane state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic retries&lt;/strong&gt; — a transient API error retries instead of silently losing the run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution logs&lt;/strong&gt; — every trigger recorded with status and response, so a skipped run is visible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missed-run alerts&lt;/strong&gt; — you're notified when a run fails or doesn't happen, instead of discovering it from missing data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unambiguous timezone&lt;/strong&gt; — the schedule is defined once, in the timezone you choose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same principle behind &lt;a href="https://runhooks.app/blog/github-actions-scheduled-workflows-unreliable/" rel="noopener noreferrer"&gt;triggering GitHub Actions reliably&lt;/a&gt;: separate the timing from the execution, and put a scheduler you can observe in front of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Built-in CronJob Is Fine
&lt;/h2&gt;

&lt;p&gt;To be fair, native CronJobs are a good fit for &lt;strong&gt;in-cluster batch work that tolerates drift&lt;/strong&gt; — log rotation, cache warming, cleanup jobs where "sometime in the next few minutes" and an occasional skipped run are acceptable. Set &lt;code&gt;startingDeadlineSeconds&lt;/code&gt; and &lt;code&gt;spec.timeZone&lt;/code&gt; sensibly and they'll serve you well.&lt;/p&gt;

&lt;p&gt;Reach for an external trigger when &lt;strong&gt;exact timing, guaranteed execution, or missed-run alerting&lt;/strong&gt; matter — billing runs, data exports, SLA-bound syncs — the jobs where a silent skip has real consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;p&gt;Kubernetes is excellent at &lt;em&gt;running&lt;/em&gt; jobs and only best-effort at &lt;em&gt;scheduling&lt;/em&gt; them. Separate the two:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Expose the job as an authenticated endpoint (or use the Kubernetes API to create the Job).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Create a Runhooks account&lt;/a&gt; and trigger it on an exact schedule with retries, logs, and missed-run alerts.&lt;/li&gt;
&lt;li&gt;Build and preview your cron expression with the &lt;a href="https://runhooks.app/tools/cron-visualizer" rel="noopener noreferrer"&gt;cron visualizer&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why is my Kubernetes CronJob not running?
&lt;/h3&gt;

&lt;p&gt;The most common causes are: the CronJob controller was unavailable past &lt;code&gt;startingDeadlineSeconds&lt;/code&gt; so the run was skipped; &lt;code&gt;concurrencyPolicy: Forbid&lt;/code&gt; suppressed the run because the previous job was still active; the schedule is being interpreted in UTC rather than your local timezone; or the job hit the "too many missed start times" condition after being paused or after the controller fell behind. None of these produce an obvious error unless you're watching events.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does "too many missed start times" mean in Kubernetes?
&lt;/h3&gt;

&lt;p&gt;The CronJob controller logs "Cannot determine if job needs to be started: too many missed start times" when it counts more than 100 missed schedules since the last successful run. This usually happens after the CronJob was suspended, or the controller was down long enough to miss many runs. Once it trips, the controller stops trying to catch up and your job stays stopped until you fix the underlying gap.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I make a Kubernetes CronJob run reliably on time?
&lt;/h3&gt;

&lt;p&gt;For jobs where exact timing and guaranteed execution matter, trigger the work from outside the cluster instead of relying solely on the CronJob controller. An external scheduler like &lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt; calls an in-cluster endpoint (or an API that creates the Job) at the precise time, retries if the call fails, logs every attempt, and alerts you when a run is missed — the visibility native CronJobs don't provide.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do Kubernetes CronJobs support timezones?
&lt;/h3&gt;

&lt;p&gt;Modern Kubernetes supports a &lt;code&gt;spec.timeZone&lt;/code&gt; field on CronJobs, but many clusters and manifests still rely on the controller's default, which interprets schedules in UTC. If you set &lt;code&gt;0 9 * * *&lt;/code&gt; expecting 9am local time and the controller uses UTC, the job runs at the wrong hour. Always set &lt;code&gt;timeZone&lt;/code&gt; explicitly, or drive the schedule from an external scheduler where the timezone is unambiguous.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm the founder of &lt;a href="https://runhooks.app" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt;, one of the tools mentioned in this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>cron</category>
      <category>sre</category>
    </item>
    <item>
      <title>Keeping AWS Lambda Warm Without Provisioned Concurrency</title>
      <dc:creator>Ronen Cypis</dc:creator>
      <pubDate>Sun, 27 Sep 2026 15:00:25 +0000</pubDate>
      <link>https://dev.to/ronency/keeping-aws-lambda-warm-without-provisioned-concurrency-kpg</link>
      <guid>https://dev.to/ronency/keeping-aws-lambda-warm-without-provisioned-concurrency-kpg</guid>
      <description>&lt;p&gt;Your Lambda-backed API is quick — until the first call after a quiet period. That one takes a second or two longer than the rest, sometimes enough to trip a client timeout. Call it again and it's snappy.&lt;/p&gt;

&lt;p&gt;That first slow call is a &lt;strong&gt;cold start&lt;/strong&gt;, and AWS's official fix for it — provisioned concurrency — solves the latency but adds a bill you may not want for a low-traffic function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Lambda Cold Starts Happen
&lt;/h2&gt;

&lt;p&gt;Lambda runs your code in an &lt;strong&gt;execution environment&lt;/strong&gt; that AWS creates on demand. When a function goes idle, AWS reclaims that environment. The next invocation has to build a new one: download your deployment package, start the runtime, and run your initialization code (imports, SDK clients, database connections) before your handler even begins.&lt;/p&gt;

&lt;p&gt;That setup is the cold start. It's most painful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Functions in a VPC&lt;/strong&gt;, which historically pay extra for network setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large dependency bundles&lt;/strong&gt;, which take longer to load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JVM and .NET runtimes&lt;/strong&gt;, which have heavier startup than Node.js or Python.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AWS's answer is &lt;strong&gt;provisioned concurrency&lt;/strong&gt;: keep a set number of environments initialized and ready at all times. It delivers consistent low latency — and it bills you to keep those environments warm continuously, whether or not anyone is calling. For a function that's idle most of the day, that's paying full-time to fix an occasional delay.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: A Scheduled Warm-Up Invocation
&lt;/h2&gt;

&lt;p&gt;There's a lighter approach: &lt;strong&gt;invoke the function yourself on a schedule&lt;/strong&gt; so AWS keeps at least one environment warm between real requests.&lt;/p&gt;

&lt;p&gt;Most Lambda functions sit behind an API Gateway route or a Function URL. Point a scheduled &lt;code&gt;GET&lt;/code&gt; at a lightweight health path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// A cheap path that skips the real work when it's just a warm-up.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rawPath&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;warmup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... real handler logic&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then schedule that request &lt;strong&gt;every 5 minutes&lt;/strong&gt; (&lt;code&gt;*/5 * * * *&lt;/code&gt;). Each invocation keeps an execution environment alive, so the next real caller lands on a warm function instead of paying the cold-start tax — and you only pay for a handful of short pings an hour, not a continuously provisioned environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a DIY Cron Job Isn't Enough
&lt;/h2&gt;

&lt;p&gt;You could schedule the ping with a cron job, or even an EventBridge rule. Both have gaps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A laptop or VPS cron job&lt;/strong&gt; stops when the machine sleeps, and means running a server to keep your serverless function warm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fails silently.&lt;/strong&gt; If a warm-up call errors, there's no retry, no alert, no log — the environment goes cold and the next user notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No unified execution history.&lt;/strong&gt; Correlating a slow request with a missed warm-up means digging through scattered logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No retries.&lt;/strong&gt; A single dropped ping can be the gap where the environment is reclaimed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can bolt retries, logging, and alerting onto a &lt;code&gt;curl&lt;/code&gt; or a Lambda-invoking script — but at that point you've rebuilt a scheduler.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Runhooks Keeps Lambda Warm
&lt;/h2&gt;

&lt;p&gt;Runhooks is a scheduled HTTP execution service with reliability built in. Warming a function is a two-minute setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a job&lt;/strong&gt; — name it "Lambda keep-warm."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the URL&lt;/strong&gt; — your API Gateway or Function URL health path, e.g. &lt;code&gt;https://abc123.execute-api.us-east-1.amazonaws.com/health&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the schedule&lt;/strong&gt; — &lt;code&gt;*/5 * * * *&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable retries&lt;/strong&gt; — 3 attempts with exponential backoff.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What you get beyond a bare cron job or EventBridge rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Execution logs&lt;/strong&gt; — every warm-up recorded with status, response, and duration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic retries&lt;/strong&gt; — a transient failure retries instead of leaving the function to go cold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure alerts&lt;/strong&gt; — if the function starts erroring, you're notified immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed infrastructure&lt;/strong&gt; — runs 24/7, independent of your own machines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because each ping is logged and alertable, you get &lt;strong&gt;keep-warm and uptime monitoring in one job&lt;/strong&gt;: the invocations keep an environment ready, and the alerts catch a genuinely broken function rather than just a cold one.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Provisioned Concurrency Is Still Worth It
&lt;/h2&gt;

&lt;p&gt;To be fair, warming isn't a total replacement. If you need &lt;strong&gt;guaranteed zero cold starts under load&lt;/strong&gt; — a high-traffic, latency-critical API where a burst of concurrent requests must all be fast — provisioned concurrency pre-initializes the environments a warm-up ping can't. A single warm environment handles the trickle after it; it doesn't pre-warm the extra environments Lambda spins up when concurrency spikes.&lt;/p&gt;

&lt;p&gt;Use a scheduled warm-up when you want to kill the &lt;strong&gt;idle cold start&lt;/strong&gt; on a low-traffic function without paying to keep environments provisioned around the clock. Reach for provisioned concurrency when consistent latency at scale is a hard requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;p&gt;Lambda's pay-per-use model is great for cost — cold starts are the trade-off, and you can soften them cheaply:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a lightweight health path to your function.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Create a Runhooks account&lt;/a&gt; and schedule a &lt;code&gt;GET&lt;/code&gt; every 5 minutes.&lt;/li&gt;
&lt;li&gt;Callers hit a warm function, and you get alerted if it starts failing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Preview your schedule with the &lt;a href="https://runhooks.app/tools/cron-visualizer" rel="noopener noreferrer"&gt;cron expression visualizer&lt;/a&gt;, and &lt;a href="https://runhooks.app/pricing" rel="noopener noreferrer"&gt;compare plans&lt;/a&gt; when you need more jobs or longer log retention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does AWS Lambda have cold starts?
&lt;/h3&gt;

&lt;p&gt;When a Lambda function hasn't been invoked recently, AWS tears down its execution environment. The next invocation has to create a fresh environment — download your code, start the runtime, and run any initialization — before your handler runs. This adds latency, and it's worst for functions in a VPC, with large dependency bundles, or on JVM and .NET runtimes.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I keep a Lambda function warm?
&lt;/h3&gt;

&lt;p&gt;Invoke the function on a schedule so AWS keeps its execution environment alive between real requests. A warm-up invocation every 5 minutes is the common interval. You can trigger it by calling the function's API Gateway URL or Function URL on a schedule with an external scheduler like &lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt;, which also retries failed pings and alerts you when the function errors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a warm-up ping cheaper than provisioned concurrency?
&lt;/h3&gt;

&lt;p&gt;For low-traffic functions, yes. Provisioned concurrency bills you to keep a set number of environments initialized around the clock. A warm-up ping only pays for a short invocation every few minutes, which is far cheaper for functions that are idle most of the time. Provisioned concurrency is still the right tool when you need guaranteed zero cold starts at scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does pinging a Lambda eliminate all cold starts?
&lt;/h3&gt;

&lt;p&gt;It keeps one execution environment warm, which removes the cold start for the steady trickle of traffic that follows. It does not remove cold starts that happen when many requests arrive at once and Lambda spins up additional concurrent environments. For that, provisioned concurrency is the answer. For most low-traffic APIs and webhooks, a single warm environment covers it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm the founder of &lt;a href="https://runhooks.app" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt;, one of the tools mentioned in this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>lambda</category>
      <category>coldstart</category>
    </item>
    <item>
      <title>Worker Exceeded CPU Time Limit? Split the Job to Fix It</title>
      <dc:creator>Ronen Cypis</dc:creator>
      <pubDate>Sun, 27 Sep 2026 14:56:51 +0000</pubDate>
      <link>https://dev.to/ronency/worker-exceeded-cpu-time-limit-split-the-job-to-fix-it-1i7e</link>
      <guid>https://dev.to/ronency/worker-exceeded-cpu-time-limit-split-the-job-to-fix-it-1i7e</guid>
      <description>&lt;p&gt;Your Worker runs fine on small inputs. Then you point it at the full dataset and it dies mid-run with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error 1102: Worker exceeded CPU time limit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing's wrong with your logic — you've hit a &lt;strong&gt;per-invocation CPU ceiling&lt;/strong&gt;. And the fix isn't a bigger limit. It's a smaller job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Cloudflare Limits CPU Time
&lt;/h2&gt;

&lt;p&gt;Cloudflare Workers run on shared infrastructure at the edge, so each invocation gets a bounded slice of &lt;strong&gt;CPU time&lt;/strong&gt; — the time actually spent computing. Crucially, this is &lt;em&gt;not&lt;/em&gt; wall-clock time: a Worker can &lt;code&gt;await&lt;/code&gt; a slow subrequest for a while without penalty, because waiting on I/O doesn't burn CPU. What burns CPU is work — looping over tens of thousands of records, parsing a huge payload, hashing, transforming.&lt;/p&gt;

&lt;p&gt;The exact budget depends on your plan and changes over time (as of 2026, check Cloudflare's current docs for the numbers). The free plan gives a small CPU budget per request; paid plans give far more and let you raise the ceiling in configuration. But &lt;strong&gt;every plan has a per-invocation cap&lt;/strong&gt;, so any job whose CPU cost grows with your data will eventually cross it — a bigger plan just moves the wall further out.&lt;/p&gt;

&lt;p&gt;The common thread: the CPU limit protects Cloudflare's shared edge, not your batch job. For request-sized work it's invisible. For "process everything" work, it's a hard stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Split the Work Across Invocations
&lt;/h2&gt;

&lt;p&gt;If the work is &lt;strong&gt;divisible&lt;/strong&gt; — a bulk import, a mailing, a nightly sync, reindexing 100k rows — you don't need one long invocation. You need many short ones, each processing a batch that finishes comfortably under the CPU limit.&lt;/p&gt;

&lt;p&gt;The pattern is a cursor plus repeated triggering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BATCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cursor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BATCH&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// fetch a small batch&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// stays under CPU limit&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;BATCH&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;processed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each call does a bounded amount of CPU work and returns where to resume. Something on the outside just has to keep calling it — advancing the cursor — until &lt;code&gt;done&lt;/code&gt; is true. That "something" is a scheduler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a DIY Loop Isn't Enough
&lt;/h2&gt;

&lt;p&gt;The obvious approach is a script that calls the Worker in a loop, or a laptop cron job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Requires an always-on machine.&lt;/strong&gt; A local loop dies when your laptop sleeps; a VPS means running a server to drive an edge Worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fails silently.&lt;/strong&gt; If a batch errors or times out, a naive loop either stops (leaving the job half-done) or barrels past the failure with no record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No execution history.&lt;/strong&gt; When the backlog stalls at row 45,000, you have no log of which batch failed or why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No retries.&lt;/strong&gt; A transient subrequest error kills a batch, and nothing retries it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You'd end up rebuilding retries, logging, and alerting around the loop — a scheduler, minus the guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Runhooks Drives the Batches
&lt;/h2&gt;

&lt;p&gt;Runhooks is a scheduled HTTP execution service. Draining a large job is a two-minute setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a job&lt;/strong&gt; — name it "Reindex batch."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the URL&lt;/strong&gt; — your Worker endpoint, e.g. &lt;code&gt;https://your-worker.workers.dev/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the schedule&lt;/strong&gt; — frequently enough to drain the backlog, e.g. &lt;code&gt;*/2 * * * *&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable retries&lt;/strong&gt; — a failed batch retries instead of stalling the run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What you get that a DIY loop doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reliable cadence&lt;/strong&gt; — batches fire on schedule until the work is done, without a machine of your own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic retries&lt;/strong&gt; — a transient failure retries at 1s → 2s → 4s instead of dropping a batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution logs&lt;/strong&gt; — every batch recorded with status, response, and duration, so you can watch the cursor advance and spot where it stalled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure alerts&lt;/strong&gt; — if batches start failing, you're notified immediately rather than discovering a half-processed dataset later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep your batch logic small enough to stay under the CPU limit, and let Runhooks handle the "run it again until it's done" part.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a Scheduler Can't Help
&lt;/h2&gt;

&lt;p&gt;Be honest with yourself about the workload. Splitting only works when the job is &lt;strong&gt;divisible&lt;/strong&gt;. If a single unit of work is itself too CPU-heavy — one massive cryptographic operation, an in-memory transform that can't be chunked, a computation that must complete in one pass — no amount of scheduling helps, because you can't make one invocation cheaper by calling it more often.&lt;/p&gt;

&lt;p&gt;For genuinely indivisible heavy compute, reach for &lt;strong&gt;Cloudflare Queues&lt;/strong&gt; or &lt;strong&gt;Durable Objects&lt;/strong&gt; to restructure the work, or move that step to a runtime built for long-running jobs. A scheduler is the right tool for &lt;em&gt;orchestrating divisible batches&lt;/em&gt; — not for extending a single expensive computation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;p&gt;The CPU limit isn't a wall you upgrade past — it's a nudge to size your jobs to the request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rewrite the heavy job to process one bounded batch per invocation, with a cursor.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Create a Runhooks account&lt;/a&gt; and trigger it on a schedule with retries and logs until the backlog drains.&lt;/li&gt;
&lt;li&gt;Build and preview your cron expression with the &lt;a href="https://runhooks.app/tools/cron-visualizer" rel="noopener noreferrer"&gt;cron visualizer&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What does "Worker exceeded CPU time limit" mean?
&lt;/h3&gt;

&lt;p&gt;It means your Worker used more CPU time in a single invocation than its plan allows, so Cloudflare terminated it (Error 1102). CPU time is the time actually spent computing — not the wall-clock time spent waiting on network or subrequests. A Worker can wait a long time on I/O without a problem, but a tight loop over a large dataset burns CPU time fast and trips the limit.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much CPU time does a Cloudflare Worker get?
&lt;/h3&gt;

&lt;p&gt;It depends on your plan, and the limits change over time — check Cloudflare's current docs for exact numbers. The free plan allows only a small CPU budget per invocation, while paid plans allow far more and let you raise the ceiling via configuration. The key point is that every plan has a per-invocation CPU cap, so a single job that grows without bound will eventually hit it regardless of plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I process a large job without hitting the CPU limit?
&lt;/h3&gt;

&lt;p&gt;Split the work so each invocation processes a small batch that finishes well under the CPU limit, then run the Worker repeatedly until the whole dataset is done. Track progress with a cursor (an offset, an ID, or a queue) so each run picks up where the last left off. An external scheduler like &lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt; triggers the Worker on a schedule, retries a failed batch, and logs each run so you can watch the backlog drain.&lt;/p&gt;

&lt;h3&gt;
  
  
  When can't a scheduler help with the CPU limit?
&lt;/h3&gt;

&lt;p&gt;When the work is a single indivisible computation — one large cryptographic operation, an in-memory transform that can't be chunked, or anything that must complete in one pass. Splitting only helps divisible workloads like bulk imports, mailings, and syncs. For indivisible heavy compute, use Cloudflare Queues, Durable Objects, or move that step to a runtime built for long-running work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm the founder of &lt;a href="https://runhooks.app" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt;, one of the tools mentioned in this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>serverless</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cron Job Monitoring Tools Compared: From DIY to Fully Managed</title>
      <dc:creator>Ronen Cypis</dc:creator>
      <pubDate>Tue, 09 Jun 2026 12:20:00 +0000</pubDate>
      <link>https://dev.to/ronency/cron-job-monitoring-tools-compared-from-diy-to-fully-managed-3ma1</link>
      <guid>https://dev.to/ronency/cron-job-monitoring-tools-compared-from-diy-to-fully-managed-3ma1</guid>
      <description>&lt;p&gt;Cron's biggest problem isn't scheduling — it's silence. A cron job can fail every night for a month, and unless you're manually checking logs on the server, you won't know. No alert, no dashboard, no audit trail. Just a backup that doesn't exist when you need it, or a data sync that quietly stopped three weeks ago.&lt;/p&gt;

&lt;p&gt;Monitoring fixes this. But "cron job monitoring" means different things depending on the tool. Some watch for missing heartbeats. Some track full execution history. Some just page you when something breaks. This article compares six approaches — from writing your own monitoring scripts to using a fully managed scheduler with built-in observability — so you can pick the right one for your workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Heartbeat Monitoring vs. Execution Monitoring
&lt;/h2&gt;

&lt;p&gt;Before comparing tools, understand the two fundamentally different approaches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heartbeat monitoring (dead man's switch)&lt;/strong&gt; is passive. Your cron job pings a monitoring URL after each run. If the ping doesn't arrive on schedule, you get an alert. This tells you &lt;em&gt;whether&lt;/em&gt; a job ran — but not &lt;em&gt;what happened&lt;/em&gt;. If the job runs but returns bad data, the ping still fires and the monitor stays green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Execution monitoring&lt;/strong&gt; is active. The scheduler fires the job, captures the response, records the outcome, and alerts on failure. You get the full picture: status code, response body, duration, retry count, and a timeline of every execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use each:&lt;/strong&gt; Heartbeat monitoring makes sense when you're stuck with system cron. Execution monitoring makes sense when you're choosing a scheduler — you get monitoring, retries, and logging as part of the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Alerts&lt;/th&gt;
&lt;th&gt;Execution Logs&lt;/th&gt;
&lt;th&gt;Retries&lt;/th&gt;
&lt;th&gt;Free Tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DIY scripts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;⚠️ Whatever you build&lt;/td&gt;
&lt;td&gt;⚠️ Whatever you build&lt;/td&gt;
&lt;td&gt;⚠️ Whatever you build&lt;/td&gt;
&lt;td&gt;✅ Free (your time)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Healthchecks.io&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Heartbeat&lt;/td&gt;
&lt;td&gt;✅ Email, Slack, webhooks&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;✅ 20 checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cronitor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Heartbeat + telemetry&lt;/td&gt;
&lt;td&gt;✅ Email, Slack, PagerDuty&lt;/td&gt;
&lt;td&gt;⚠️ Basic (duration, exit code)&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;⚠️ 5 monitors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Better Stack&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Uptime + heartbeat&lt;/td&gt;
&lt;td&gt;✅ Email, Slack, PagerDuty&lt;/td&gt;
&lt;td&gt;⚠️ Basic (heartbeat log)&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;⚠️ 5 monitors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PagerDuty / Opsgenie&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Incident mgmt&lt;/td&gt;
&lt;td&gt;✅ Full escalation&lt;/td&gt;
&lt;td&gt;❌ Requires integration&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;⚠️ Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runhooks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Execution (scheduler)&lt;/td&gt;
&lt;td&gt;✅ Email, webhook&lt;/td&gt;
&lt;td&gt;✅ Full (status, body, duration)&lt;/td&gt;
&lt;td&gt;✅ Exponential backoff&lt;/td&gt;
&lt;td&gt;✅ 3 jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  DIY Monitoring
&lt;/h2&gt;

&lt;p&gt;The simplest approach: wrap each cron job in a script that checks exit codes and sends alerts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
/scripts/nightly-backup.sh &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/backup.log 2&amp;gt;&amp;amp;1
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://hooks.slack.com/services/T00/B00/xxx &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"text": "Backup job failed on prod-1"}'&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free in dollars, expensive in time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; You're building monitoring for every job. No dashboard, no execution history, no retries unless you write them. When the server dies, the monitoring dies with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Solo developers with a handful of non-critical jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Healthchecks.io
&lt;/h2&gt;

&lt;p&gt;A dedicated heartbeat monitoring service. Create a check, get a ping URL, append it to your cron job. If the ping doesn't arrive on schedule, you get an alert. Supports start/success/fail signals so you can detect jobs that start but never finish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier includes 20 checks with unlimited alert integrations. Paid plans start at $20/month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; Doesn't execute your jobs — only watches for pings. No execution logs, no retries, no scheduling. If the job runs but produces bad results, the heartbeat still fires and the monitor stays green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams using system cron or platform-native schedulers who need basic "is it running?" monitoring without changing their scheduler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cronitor
&lt;/h2&gt;

&lt;p&gt;Heartbeat monitoring with added telemetry. Integrate via ping URLs or install the Cronitor CLI to automatically capture duration, exit code, and output size from your crontab entries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier includes 5 monitors. Paid from $12.50/month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; A monitoring layer, not a scheduler. Doesn't capture HTTP response bodies or provide retries. The CLI agent requires server installation — if the server dies, the agent dies with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that want duration trends and exit code tracking beyond raw heartbeat pings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Better Stack (formerly Better Uptime)
&lt;/h2&gt;

&lt;p&gt;Primarily an uptime monitoring and incident management platform that includes heartbeat monitoring as a secondary feature. Your job pings a URL, Better Stack alerts if the ping is late — routed through on-call schedules and escalation policies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier includes 5 monitors. Paid plans start at $24/month per user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; Designed around uptime monitoring, not cron. No execution logs, retries, or scheduling. If you're only using it for cron monitoring, you're paying for functionality you don't need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams already using Better Stack for uptime monitoring who want to add heartbeat checks without another tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  PagerDuty / Opsgenie
&lt;/h2&gt;

&lt;p&gt;Enterprise incident management platforms that don't monitor cron jobs directly — they're where cron alerts &lt;em&gt;end up&lt;/em&gt;. Your monitoring tool fires an event on failure, and PagerDuty/Opsgenie handles routing, escalation, and acknowledgment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; PagerDuty free tier covers 5 users. Paid from $21/user/month. Opsgenie starts at $9.45/user/month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; These are alert &lt;em&gt;destinations&lt;/em&gt;, not monitoring &lt;em&gt;sources&lt;/em&gt;. You still need something to detect the failure. Adding PagerDuty is justified for enterprise on-call workflows but overkill for most cron monitoring needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Organizations with established on-call rotations that need cron alerts routed through existing incident management.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runhooks Built-In Monitoring
&lt;/h2&gt;

&lt;p&gt;Runhooks combines scheduling and monitoring into a single layer. The scheduler fires the HTTP request, inspects the response, logs the result, retries on failure, and alerts when retries are exhausted. There's no separate ping step — the scheduler &lt;em&gt;is&lt;/em&gt; the monitor.&lt;/p&gt;

&lt;p&gt;Every execution is recorded: HTTP status code, response body (up to 64 KB), duration in milliseconds, attempt number, and error details. Failed jobs enter a dead-letter state and trigger alerts via email or webhook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free plan includes 5 jobs with retries and alerts. Paid plans add more jobs, longer log retention (up to 30 days), and higher retry limits. See &lt;a href="https://runhooks.app/pricing" rel="noopener noreferrer"&gt;full pricing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; HTTP-only — if your job isn't callable via HTTP, you'd need to wrap it in an endpoint first. Natural fit for web services and serverless functions, less suited for legacy shell scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams who want scheduling, retries, logging, and alerting in one tool without stitching together a monitoring stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the Right Approach
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Locked into system cron?&lt;/strong&gt; Add heartbeat monitoring. Healthchecks.io is the strongest option — generous free tier, simple integration, wide alert support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Already use an observability platform?&lt;/strong&gt; Check if it supports heartbeat checks (Better Stack, Datadog). No need for another tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Need to know &lt;em&gt;why&lt;/em&gt; a job failed?&lt;/strong&gt; You need execution-level monitoring — either extensive logging in every script (fragile) or a scheduler that captures execution details natively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduling HTTP endpoints?&lt;/strong&gt; A managed HTTP scheduler like Runhooks gives you scheduling, retries, logs, and alerts in one layer. No heartbeat URLs, no wrapper scripts, no separate monitoring tool.&lt;/p&gt;

&lt;p&gt;The monitoring gap in most cron setups isn't technical — it's organizational. Pick the approach that requires the least ongoing effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;p&gt;If you're running scheduled HTTP jobs and want monitoring that's built into the scheduler rather than bolted on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Create a Runhooks account&lt;/a&gt; — the free plan includes 5 jobs with retries and alerts&lt;/li&gt;
&lt;li&gt;Test your cron expressions with the &lt;a href="https://runhooks.app/tools/cron-visualizer" rel="noopener noreferrer"&gt;cron expression visualizer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://runhooks.app/pricing" rel="noopener noreferrer"&gt;Compare plans&lt;/a&gt; when you're ready to scale&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I know if my cron job stopped running?
&lt;/h3&gt;

&lt;p&gt;You need external monitoring. Cron has no built-in failure detection. The two main approaches are heartbeat monitoring (your job pings a URL after each run — if the ping stops, you get an alert) and execution monitoring (the scheduler itself tracks every run and alerts on failures). Heartbeat tools like Healthchecks.io catch missing runs. Execution-aware schedulers like Runhooks catch missing runs, HTTP errors, timeouts, and retry exhaustion.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between heartbeat monitoring and execution monitoring?
&lt;/h3&gt;

&lt;p&gt;Heartbeat monitoring is passive — your cron job pings a URL when it completes, and the monitor alerts you if the ping is late or missing. Execution monitoring is active — the scheduler fires the job, inspects the response, and logs the outcome. Heartbeat monitoring tells you a job didn't run. Execution monitoring tells you why it failed, how long it took, and what the response was.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the best free cron job monitoring tool?
&lt;/h3&gt;

&lt;p&gt;Healthchecks.io offers a generous free tier with 20 checks, and it handles basic heartbeat monitoring well. For execution-level monitoring with logs and retries, Runhooks includes a free plan with 5 jobs, automatic retries, and failure alerts. The best choice depends on whether you need passive heartbeat checks or active execution tracking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need cron monitoring if I already have uptime monitoring?
&lt;/h3&gt;

&lt;p&gt;Yes. Uptime monitoring checks that your server responds to HTTP requests. Cron monitoring checks that scheduled tasks actually execute on time and succeed. A server can have 100% uptime while every cron job on it is silently failing — wrong permissions, expired credentials, full disk, or a killed cron daemon. These are different failure modes that require different monitoring.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm the founder of &lt;a href="https://runhooks.app" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt;, one of the tools compared in this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cron</category>
      <category>monitoring</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Best External Cron Job Services Compared (2026)</title>
      <dc:creator>Ronen Cypis</dc:creator>
      <pubDate>Tue, 09 Jun 2026 12:10:00 +0000</pubDate>
      <link>https://dev.to/ronency/best-external-cron-job-services-compared-2026-8a3</link>
      <guid>https://dev.to/ronency/best-external-cron-job-services-compared-2026-8a3</guid>
      <description>&lt;p&gt;You have an API endpoint that needs to run on a schedule. Maybe it syncs data from a third party, sends a digest email, or cleans up expired records. You don't want to manage a cron server. You want something external to call your endpoint on time, retry if it fails, and tell you when something goes wrong.&lt;/p&gt;

&lt;p&gt;That's what external cron job services do. But the options range from free community tools to enterprise cloud products, and they make very different tradeoffs. This article compares six of them honestly — what they're good at, where they fall short, and who should use each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Pricing&lt;/th&gt;
&lt;th&gt;Retries&lt;/th&gt;
&lt;th&gt;Execution Logs&lt;/th&gt;
&lt;th&gt;Failure Alerts&lt;/th&gt;
&lt;th&gt;Vendor Lock-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cron-job.org&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;⚠️ Limited&lt;/td&gt;
&lt;td&gt;⚠️ 1 day history&lt;/td&gt;
&lt;td&gt;⚠️ Email only&lt;/td&gt;
&lt;td&gt;✅ None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;EasyCron&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;From $12/mo&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Email, webhook&lt;/td&gt;
&lt;td&gt;✅ None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cronhooks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free + paid&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;⚠️ Email only&lt;/td&gt;
&lt;td&gt;✅ None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Google Cloud Scheduler&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.10/job/mo&lt;/td&gt;
&lt;td&gt;✅ Via Pub/Sub&lt;/td&gt;
&lt;td&gt;⚠️ Cloud Logging&lt;/td&gt;
&lt;td&gt;⚠️ Cloud Monitoring&lt;/td&gt;
&lt;td&gt;❌ GCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AWS EventBridge&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$1/M invocations&lt;/td&gt;
&lt;td&gt;⚠️ Via destinations&lt;/td&gt;
&lt;td&gt;⚠️ CloudWatch&lt;/td&gt;
&lt;td&gt;⚠️ CloudWatch&lt;/td&gt;
&lt;td&gt;❌ AWS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runhooks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free + paid&lt;/td&gt;
&lt;td&gt;✅ Exponential backoff&lt;/td&gt;
&lt;td&gt;✅ Full response&lt;/td&gt;
&lt;td&gt;✅ Email, webhook&lt;/td&gt;
&lt;td&gt;✅ None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Cron-job.org
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; A free, community-run service that sends HTTP requests to URLs on cron schedules. One of the most well-known options for basic cron pinging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Completely free. Funded by donations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key features:&lt;/strong&gt; Unlimited cron jobs, standard cron expressions, basic execution history (last 24 hours), email notifications on failure, timezone support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; No SLA or uptime guarantees — it's a community project. Retry behavior is basic with no exponential backoff. No webhook alerting, no API for programmatic management, no response body capture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Hobby projects and non-critical tasks where a missed execution won't cause business impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  EasyCron
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; One of the longest-running paid cron job services. HTTP-based scheduling with more features and reliability than free alternatives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; From $12/month. Scales by job count, minimum interval, and execution timeout. No free tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key features:&lt;/strong&gt; Configurable retries, execution logging with response details, email and webhook notifications, REST API, custom headers and bodies, IP whitelisting, cron expression builder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; No free tier. The interface is dated. Pricing can escalate for teams with many jobs or short intervals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that need a proven, reliable paid service. EasyCron's long track record matters if you've been burned by free tools disappearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cronhooks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; HTTP-based cron scheduling focused on webhooks. Supports both recurring and one-time scheduled requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier with limited jobs, plus paid plans for higher volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key features:&lt;/strong&gt; Recurring and one-time scheduled HTTP requests, retry on failure, execution history, email notifications, REST API, timezone support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; Smaller user base than EasyCron or cloud providers. Documentation is limited. Webhook alerting and configurable retry strategies are less mature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Developers who need a straightforward HTTP scheduler with a free tier. A reasonable middle ground between Cron-job.org and enterprise solutions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Google Cloud Scheduler
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Google's fully managed cron service within GCP. Triggers HTTP endpoints, Pub/Sub topics, and App Engine routes on cron schedules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; $0.10/job/month (first three free). Downstream costs (Cloud Functions, Pub/Sub, Cloud Run) add up depending on what jobs trigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key features:&lt;/strong&gt; Tight GCP integration (Pub/Sub, Cloud Functions, Cloud Run), configurable retry policies with exponential backoff, IAM-based auth, logs via Cloud Logging, alerting via Cloud Monitoring, enterprise SLA (99.5%+).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; Requires a GCP project and billing account even for basic HTTP scheduling. IAM roles, service accounts, and Pub/Sub topics add configuration overhead. Vendor lock-in. Overkill if you're not already on GCP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams already on GCP who want scheduling integrated with their IAM, logging, and monitoring stack. If your backend is Cloud Run or Cloud Functions, this is the natural choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  AWS EventBridge Scheduler
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; AWS's managed scheduling service. Invokes over 270 AWS services on a schedule, including Lambda, Step Functions, SQS, and HTTP endpoints (via API Destinations).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; $1/million invocations (first 14 million/month free). Extremely cheap at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key features:&lt;/strong&gt; Deep AWS integration, one-time and recurring schedules, cron and rate expressions, retry policies with dead-letter queues, IAM security, massive scale (millions of schedules per account).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt; External HTTP endpoints require API Destinations setup (connection resources, IAM roles, OAuth). The AWS console is complex for simple HTTP scheduling. Monitoring is split across CloudWatch and EventBridge consoles — no unified execution dashboard. Full vendor lock-in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams already on AWS who need to trigger Lambda, Step Functions, or other AWS services. For simple "hit this URL every hour" outside AWS, the setup overhead isn't worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runhooks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Runhooks is a managed HTTP job scheduler built specifically for developers who need to fire HTTP requests on cron schedules. It focuses on the core workflow: define a URL, set a schedule, get retries and alerts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier available, with paid plans for more jobs and longer log retention. See &lt;a href="https://runhooks.app/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for current details.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP-first design — every job is a URL + method + headers + body + cron expression&lt;/li&gt;
&lt;li&gt;Automatic retries with exponential backoff (configurable attempts)&lt;/li&gt;
&lt;li&gt;Full execution logging: HTTP status, response body, duration in milliseconds, attempt number&lt;/li&gt;
&lt;li&gt;Failure alerts via email and webhook when retries are exhausted&lt;/li&gt;
&lt;li&gt;Timezone support with &lt;a href="https://runhooks.app/tools/cron-visualizer" rel="noopener noreferrer"&gt;visual cron expression preview&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Clean, developer-focused dashboard&lt;/li&gt;
&lt;li&gt;No cloud vendor coupling — works with any endpoint, anywhere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP-only — no native integration with AWS Lambda invocations, GCP Pub/Sub, or database-level triggers&lt;/li&gt;
&lt;li&gt;Newer service compared to EasyCron or the cloud schedulers&lt;/li&gt;
&lt;li&gt;Feature set is focused on HTTP scheduling — if you need to orchestrate multi-step workflows or fan-out to queues, a cloud-native tool may be a better fit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Developers and small teams who need a straightforward HTTP scheduler without cloud provider complexity. If your scheduled tasks are API endpoints, Runhooks does exactly what you need with less configuration overhead than cloud-native alternatives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;p&gt;If you're evaluating cron job services, here's the fastest way to test Runhooks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Create an account&lt;/a&gt; — free tier available, no credit card required&lt;/li&gt;
&lt;li&gt;Add a job with your endpoint URL and cron expression&lt;/li&gt;
&lt;li&gt;Watch the execution log to verify it works&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Test your cron expressions with the &lt;a href="https://runhooks.app/tools/cron-visualizer" rel="noopener noreferrer"&gt;cron expression visualizer&lt;/a&gt; before creating jobs, and check the &lt;a href="https://runhooks.app/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; when you need to scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is an external cron job service?
&lt;/h3&gt;

&lt;p&gt;An external cron job service sends HTTP requests to your API endpoints on a schedule you define — typically using cron expressions. Instead of managing your own cron server, the service handles scheduling, retries, and monitoring. Your application logic stays in your backend as regular HTTP endpoints.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is cron-job.org reliable enough for production?
&lt;/h3&gt;

&lt;p&gt;Cron-job.org is community-run and free, which makes it suitable for hobby projects and non-critical tasks. However, it offers no SLA, limited retry logic, and minimal execution history. For production workloads where missed executions have business impact, a paid service with retries, alerting, and guaranteed uptime is a safer choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the best EasyCron alternative?
&lt;/h3&gt;

&lt;p&gt;It depends on your priorities. If you want a developer-focused HTTP scheduler with automatic retries, execution logs, and failure alerts, &lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt; is the closest alternative. If you need deep cloud integration, Google Cloud Scheduler or AWS EventBridge Scheduler fit better. If you need free, Cron-job.org is the main option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a cron job service if I use AWS or GCP?
&lt;/h3&gt;

&lt;p&gt;AWS EventBridge Scheduler and Google Cloud Scheduler are solid options if your infrastructure is already on those platforms. However, they require cloud-specific configuration (IAM roles, service accounts, Pub/Sub topics) that adds complexity. A standalone cron job service is simpler if you just need to hit an HTTP endpoint on a schedule without cloud vendor coupling.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm the founder of &lt;a href="https://runhooks.app" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt;, one of the services compared in this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cron</category>
      <category>devops</category>
      <category>webdev</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Scheduling Cloudflare Workers Beyond Cron Triggers</title>
      <dc:creator>Ronen Cypis</dc:creator>
      <pubDate>Tue, 09 Jun 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ronency/scheduling-cloudflare-workers-beyond-cron-triggers-1gd7</link>
      <guid>https://dev.to/ronency/scheduling-cloudflare-workers-beyond-cron-triggers-1gd7</guid>
      <description>&lt;p&gt;Cloudflare Workers Cron Triggers let you run code on a schedule without managing infrastructure. You define a cron expression in &lt;code&gt;wrangler.toml&lt;/code&gt;, write a &lt;code&gt;scheduled&lt;/code&gt; event handler, deploy, and Cloudflare runs it. For simple, low-frequency tasks inside a single worker, this works well.&lt;/p&gt;

&lt;p&gt;Then your project grows. You need a sixth scheduled task, or your nightly data sync fails silently and nobody notices for three days, or you want to schedule calls across multiple workers. That's where Cron Triggers start hitting walls.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Cloudflare Cron Triggers Work
&lt;/h2&gt;

&lt;p&gt;Cron Triggers are configured in your &lt;code&gt;wrangler.toml&lt;/code&gt; file and handled by a &lt;code&gt;scheduled&lt;/code&gt; event in your worker code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# wrangler.toml&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"my-worker"&lt;/span&gt;
&lt;span class="py"&gt;main&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"src/index.ts"&lt;/span&gt;
&lt;span class="py"&gt;compatibility_date&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2024-01-01"&lt;/span&gt;

&lt;span class="nn"&gt;[triggers]&lt;/span&gt;
&lt;span class="py"&gt;crons&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"0 */6 * * *"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"0 0 * * MON"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/index.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;scheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ScheduledEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 */6 * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;syncExternalData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 0 * * MON&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateWeeklyReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;satisfies&lt;/span&gt; &lt;span class="nx"&gt;ExportedHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare evaluates the cron expressions and invokes your worker's &lt;code&gt;scheduled&lt;/code&gt; handler at the specified times. You can use the &lt;code&gt;event.cron&lt;/code&gt; property to differentiate between triggers when a single worker has multiple schedules.&lt;/p&gt;

&lt;p&gt;This is straightforward for one or two tasks. The problems surface as you add more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Limitations of Cron Triggers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Trigger limits per worker
&lt;/h3&gt;

&lt;p&gt;The free plan allows &lt;strong&gt;3 cron triggers per worker&lt;/strong&gt;. The paid Workers plan ($5/month) increases this to &lt;strong&gt;5 cron triggers per worker&lt;/strong&gt;. If your project needs 8 scheduled tasks, you must split them across multiple workers — each with its own &lt;code&gt;wrangler.toml&lt;/code&gt;, deployment, and codebase. This adds operational overhead for what should be a scheduling concern, not an architecture concern.&lt;/p&gt;

&lt;h3&gt;
  
  
  No retries on failure
&lt;/h3&gt;

&lt;p&gt;If your &lt;code&gt;scheduled&lt;/code&gt; handler throws an error, exceeds the CPU time limit, or fails for any reason, Cloudflare does not retry the execution. The failed run is gone. The next attempt happens at the next scheduled tick — which could be hours or days away depending on the cron expression.&lt;/p&gt;

&lt;p&gt;For a task that cleans up temporary files, a missed run is tolerable. For a task that reconciles billing data or sends time-sensitive notifications, a silent failure can have real consequences.&lt;/p&gt;

&lt;h3&gt;
  
  
  Limited execution visibility
&lt;/h3&gt;

&lt;p&gt;Cron Triggers provide basic invocation metrics through Workers Analytics — you can see that a trigger fired and whether the worker returned successfully. But there's no structured log of what happened inside the execution: how many records were processed, what the response payload was, or how long individual operations took. Getting that level of detail requires wiring up a separate logging pipeline to a service like Logpush or a third-party provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  No failure alerts
&lt;/h3&gt;

&lt;p&gt;When a cron trigger fails, Cloudflare doesn't send you an email or a webhook. You find out when you check the dashboard, or when a downstream system breaks because the scheduled task stopped running. There's no built-in way to get notified when a scheduled handler starts failing consistently.&lt;/p&gt;

&lt;h3&gt;
  
  
  CPU time constraints
&lt;/h3&gt;

&lt;p&gt;CPU time limits vary by plan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free plan:&lt;/strong&gt; 10ms CPU time per execution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundled (paid):&lt;/strong&gt; 50ms CPU time per execution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standard/Unbound (paid):&lt;/strong&gt; 30 seconds CPU time per execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the free plan, 10ms of CPU time is tight for anything beyond trivial operations. Even on the paid Bundled plan, 50ms limits what you can do in a single scheduled execution. The Standard usage model gives 30 seconds, which covers most workloads, but you need to be on the paid plan to access it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Cron Triggers Are Enough
&lt;/h2&gt;

&lt;p&gt;Cron Triggers work well when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have &lt;strong&gt;5 or fewer&lt;/strong&gt; scheduled tasks within a single worker&lt;/li&gt;
&lt;li&gt;Tasks are &lt;strong&gt;simple and fast&lt;/strong&gt; — cache purges, KV store updates, lightweight API calls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missed runs are tolerable&lt;/strong&gt; — the next scheduled tick will catch up&lt;/li&gt;
&lt;li&gt;You don't need &lt;strong&gt;per-execution logs&lt;/strong&gt; or &lt;strong&gt;failure notifications&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Your workload fits within the &lt;strong&gt;CPU time limits&lt;/strong&gt; of your plan&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your scheduled tasks check these boxes, Cron Triggers are the simplest solution. No external dependencies, no extra services, no additional cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  When You Need More
&lt;/h2&gt;

&lt;p&gt;The moment you need any of the following, Cron Triggers become insufficient:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More than 5 scheduled tasks&lt;/strong&gt; without splitting across workers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic retries&lt;/strong&gt; when a scheduled handler fails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution logs&lt;/strong&gt; with HTTP status, response body, and duration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure alerts&lt;/strong&gt; via email or webhook&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduling across multiple workers&lt;/strong&gt; or mixing Workers with other services (APIs on Railway, Cloud Functions, Vercel routes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex scheduling&lt;/strong&gt; with timezone awareness and DST handling&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The External Scheduler Approach
&lt;/h2&gt;

&lt;p&gt;Every Cloudflare Worker with a &lt;code&gt;fetch&lt;/code&gt; handler is an HTTP endpoint. Instead of relying on the &lt;code&gt;scheduled&lt;/code&gt; handler, you can expose your task logic through &lt;code&gt;fetch&lt;/code&gt; and trigger it externally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/index.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Verify the request is from your scheduler&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authHeader&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CRON_SECRET&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tasks/sync&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;syncExternalData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sync&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tasks/report&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateWeeklyReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;report&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;satisfies&lt;/span&gt; &lt;span class="nx"&gt;ExportedHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This changes the architecture: your worker becomes an HTTP API with multiple task endpoints, and an external scheduler handles the timing. You get separate URLs for separate tasks, structured JSON responses, and standard HTTP status codes that an external system can act on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding authorization
&lt;/h3&gt;

&lt;p&gt;Protect your worker from unauthorized requests using a secret stored in Workers secrets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler secret put CRON_SECRET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fetch&lt;/code&gt; handler checks the &lt;code&gt;Authorization&lt;/code&gt; header against this secret (shown above). Configure the same &lt;code&gt;Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header in your external scheduler. For additional security, restrict requests by IP using Cloudflare Access or validate a signed HMAC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-Step Setup With Runhooks
&lt;/h2&gt;

&lt;p&gt;Runhooks is a scheduled HTTP execution service. It calls your endpoints on a schedule with retries, logging, and failure alerts. Here's how to schedule a Cloudflare Worker:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Deploy your worker with a fetch handler&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use the &lt;code&gt;fetch&lt;/code&gt; handler pattern shown above. Each task gets its own path (&lt;code&gt;/tasks/sync&lt;/code&gt;, &lt;code&gt;/tasks/report&lt;/code&gt;, etc.). Deploy with &lt;code&gt;npx wrangler deploy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Set a secret for authorization&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler secret put CRON_SECRET
&lt;span class="c"&gt;# Enter a strong random string when prompted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Create a job in Runhooks&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name:&lt;/strong&gt; "Sync external data"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;code&gt;https://my-worker.your-subdomain.workers.dev/tasks/sync&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Method:&lt;/strong&gt; &lt;code&gt;GET&lt;/code&gt; or &lt;code&gt;POST&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headers:&lt;/strong&gt; &lt;code&gt;Authorization: Bearer &amp;lt;your-cron-secret&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule:&lt;/strong&gt; &lt;code&gt;0 */6 * * *&lt;/code&gt; (every 6 hours)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries:&lt;/strong&gt; 3 attempts with exponential backoff&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;4. Create a second job for the weekly report&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name:&lt;/strong&gt; "Weekly report"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;code&gt;https://my-worker.your-subdomain.workers.dev/tasks/report&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule:&lt;/strong&gt; &lt;code&gt;0 0 * * MON&lt;/code&gt; (every Monday at midnight)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timezone:&lt;/strong&gt; your preferred timezone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries:&lt;/strong&gt; 3 attempts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each job runs independently with its own schedule, retry policy, and alerting. No cron trigger limits. No splitting tasks across multiple workers.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you gain over Cron Triggers
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No trigger limits&lt;/strong&gt; — schedule 20 tasks against one worker, or spread them across 10 workers. Each is a separate HTTP job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic retries&lt;/strong&gt; — if a cold start, network blip, or transient error causes a failure, the next attempt fires within seconds instead of waiting for the next cron tick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution logs&lt;/strong&gt; — every invocation is recorded with HTTP status code, response body, and duration in milliseconds. When your sync processed 2,400 records in 1.8 seconds, you see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure alerts&lt;/strong&gt; — email or webhook notifications when a job starts failing. You find out in minutes, not days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timezone-aware scheduling&lt;/strong&gt; — set a job to run at 9 AM &lt;code&gt;America/New_York&lt;/code&gt; and it stays at 9 AM through DST transitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-service scheduling&lt;/strong&gt; — schedule a Cloudflare Worker, a Vercel API route, and a Railway endpoint from the same dashboard. Cron Triggers only work within Cloudflare.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keeping the Scheduled Handler as a Fallback
&lt;/h2&gt;

&lt;p&gt;You don't have to remove your &lt;code&gt;scheduled&lt;/code&gt; handler when switching to external scheduling. A worker can have both — the external scheduler handles the primary schedule with retries and logging, while a cron trigger fires as a lower-frequency fallback (say, once daily) to catch anything missed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// External scheduler entry point&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authHeader&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CRON_SECRET&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;syncExternalData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;scheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ScheduledEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fallback: runs even if the external scheduler is down&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;syncExternalData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;satisfies&lt;/span&gt; &lt;span class="nx"&gt;ExportedHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your task logic lives in a shared function, so there's no code duplication.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;p&gt;Cloudflare Workers are fast and cheap to run — the cron trigger limitations are the main constraint for scheduled workloads, and they're straightforward to work around:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Expose your task logic through a &lt;code&gt;fetch&lt;/code&gt; handler with authorization&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Try Runhooks&lt;/a&gt; and schedule your workers at any frequency&lt;/li&gt;
&lt;li&gt;Get retries, execution logs, and failure alerts that Cron Triggers don't provide&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Preview your cron expressions with the &lt;a href="https://runhooks.app/tools/cron-visualizer" rel="noopener noreferrer"&gt;cron expression visualizer&lt;/a&gt;, and &lt;a href="https://runhooks.app/pricing" rel="noopener noreferrer"&gt;compare plans&lt;/a&gt; when you need more jobs or longer log retention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How many cron triggers can a Cloudflare Worker have?
&lt;/h3&gt;

&lt;p&gt;On the free plan, each Cloudflare Worker can have up to 3 cron triggers. On the paid Workers plan ($5/month), the limit increases to 5 cron triggers per worker. If you need more scheduled tasks, you must split them across multiple workers or use an external scheduler to trigger your workers via HTTP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do Cloudflare Cron Triggers have built-in retries?
&lt;/h3&gt;

&lt;p&gt;No. If a scheduled handler throws an error or times out, Cloudflare does not retry the execution. The failed run is lost and the next attempt happens at the next scheduled tick. For tasks where missed runs are costly — data syncs, billing reconciliation, alert checks — you need an external scheduler that retries on failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I trigger a Cloudflare Worker on a cron schedule without using Cron Triggers?
&lt;/h3&gt;

&lt;p&gt;Yes. Every Cloudflare Worker with a fetch handler is a standard HTTP endpoint. You can use an external HTTP scheduler like &lt;a href="https://runhooks.app/register" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt; to send a request to your Worker's URL on any cron schedule. This bypasses the cron trigger limit entirely and adds retries, logging, and failure alerts.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the CPU time limits for Cloudflare Workers Cron Triggers?
&lt;/h3&gt;

&lt;p&gt;On the free plan, each cron trigger execution is limited to 10ms of CPU time. On the paid Bundled plan, the limit is 50ms. If you use the Standard (unbound) usage model, CPU time extends to 30 seconds per execution. These limits apply equally to both scheduled and fetch handlers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm the founder of &lt;a href="https://runhooks.app" rel="noopener noreferrer"&gt;Runhooks&lt;/a&gt;, one of the tools mentioned in this article.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>serverless</category>
      <category>cron</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
