The standard GCP cron job is a Cloud Scheduler job with a five-field cron expression and one of three targets: an HTTP endpoint (often a Cloud Run service), a Pub/Sub topic, or — increasingly — the Cloud Run Jobs API, to start a container that runs to completion. It's a good scheduler: real time zones with daylight-saving handling, retry policies, per-attempt logging. It has the same blind spot as EventBridge, GitHub Actions, and Vercel crons: it records that the hand-off happened. Nothing records that the work got done — and for the Cloud Run Jobs pattern specifically, the hand-off finishes before your code starts.

This guide covers all three target types, because they fail differently at the hand-off and identically after it. The fix is the same: a heartbeat sent from inside the job after the work completes, so every failure below collapses into one observable signal.

Where "attempt succeeded" and "job ran" diverge

Cloud Scheduler considers an attempt successful when the target returns a 2xx (HTTP), when the message is published (Pub/Sub), or when the App Engine handler returns. Consider what that means per target:

  • HTTP → Cloud Run service. Success means your handler returned 2xx within the attempt deadline (default three minutes for HTTP targets, configurable up to thirty — as of 2026, check current docs). If your handler does the work synchronously and returns 500 on failure, Scheduler does see failures. Most handlers don't; they return 200 after kicking off work or after swallowing an exception into a log line.
  • HTTP → Cloud Run Jobs API. The target is …/jobs/NAME:run. The API creates an execution and returns immediately. Scheduler records success. Your container then starts, runs, and possibly fails — and that outcome lives in Cloud Run's execution history, which Scheduler never reads. This is the most common pattern for batch work, and it's the one where Scheduler's green history means the least.
  • Pub/Sub. Success means the message landed on the topic. Whether anything is subscribed, whether the subscriber is running, whether it acked or the message expired after the retention period — none of that is Scheduler's business.

That's the baseline. Now the ways the attempt itself stops happening, or happens wrong.

The five silent failures

1. The job is paused

Paused is a valid state. Someone pauses a job during an incident and forgets; Terraform applies paused = true from a branch meant to be temporary; a project cleanup removes a job that a different team depended on. No error, no log entry after the pause, nothing in Cloud Monitoring — just a schedule that stopped producing output. This is config drift, and it is the single most common way scheduled work dies on any platform.

2. The service account lost a permission

Scheduler authenticates to Cloud Run with an OIDC or OAuth token minted for a service account. That account needs run.invoker on a service or the jobs-run permission on a job. Recreate the service, tighten IAM, rotate accounts, move the job to a new project — and the target starts returning 401 or 403. Scheduler does record these as failed attempts, and retries per your retry policy, and then… writes a log line. Unless an alerting policy watches for it, a permanently-403ing job is indistinguishable from a paused one.

3. The attempt deadline fires while the job is still running

An HTTP target that does its work synchronously and takes four minutes hits the three-minute default attempt deadline. Scheduler marks the attempt failed and, if retries are configured, fires the target again while the first run is still going. Now you have two overlapping runs of a job that was written assuming one, plus a "failed" attempt in the logs for work that actually succeeded. The fix is structural — return early and do the work asynchronously, or use a Cloud Run job whose task timeout is independent of the scheduler — and the heartbeat below is what tells you the fix worked.

4. The Pub/Sub message arrives to nobody

The subscriber is a Cloud Run service scaled to zero that fails to start, a push endpoint whose URL changed, or a pull worker that was decommissioned. The message sits in the subscription until the retention window (seven days by default) expires it. Scheduler: success. Topic: fine. Subscription backlog: growing, in a graph nobody has open. This is the same shape as a dead-letter queue nobody drains, and monitoring queue depth is the in-platform answer; the heartbeat from the subscriber is the external one.

5. The whole project went quiet

Billing account disabled or exhausted, an org policy change, a quota exhausted by a different workload, a region incident. Every schedule in the project stops at once, and the alerting policies you built in that project stop with them. This is the structural argument for having at least one observer outside the project — the same argument that applies to a Cloudflare account or an AWS account, applied to GCP.

What Cloud Monitoring gets you (and where it stops)

You can build real in-platform alerting, and some of it is worth doing regardless:

  • A log-based alert on Scheduler attempt failures (status not OK) — catches failure mode 2 and deadline failures.
  • An alert on Cloud Run job executions with a failed result — catches containers that exit non-zero, which Scheduler never sees.
  • A metric-absence policy on Scheduler attempts or completed executions per job — the in-platform silence detector. It works, but it's one policy per job, the absence window must be tuned to each schedule, and it lives in the project it watches.
  • Subscription backlog alerts for Pub/Sub targets — oldest unacked message age above a threshold.

The limit is fate-sharing: policies, jobs, and executions all sit inside one project, one billing account, and one IAM boundary. Failure mode 5 takes out the watcher with the watched. That doesn't make Cloud Monitoring bad; it makes it insufficient as the only observer.

The heartbeat pattern

One external heartbeat monitor per schedule, expecting a ping at the schedule's cadence plus grace. The ping comes from inside the work, after it completes. In a Cloud Run job written in Python:

import urllib.request

def main():
    run_nightly_export()

    # Success signal -- after the work, unreachable if it raised.
    urllib.request.urlopen(
        "https://cronalert.com/api/heartbeat/YOUR_TOKEN", timeout=10
    )

if __name__ == "__main__":
    main()  # non-zero exit on exception -> Cloud Run marks the execution failed too

And in a Node Cloud Run service handling an HTTP-target schedule (do the work before responding, or ping from wherever the asynchronous work actually finishes):

app.post("/tasks/nightly", async (req, res) => {
  await runNightlyExport();

  // After the work, awaited. If runNightlyExport throws, this never runs
  // and the 500 below also gives Cloud Scheduler a real failure to log.
  await fetch("https://cronalert.com/api/heartbeat/YOUR_TOKEN");
  res.status(204).end();
});

Three placement rules, all load-bearing:

  • After the work. A ping at the start asserts "I began," which is what Scheduler already told you. Assert completion.
  • Unreachable on failure. If you catch exceptions to log them, re-raise or return early so the ping is skipped. A ping that fires regardless makes the monitor lie.
  • From the place the work finishes. For Pub/Sub targets, that's the subscriber, not the publisher. For the Jobs API pattern, it's the container, not the HTTP hand-off.

Set the expected interval to the schedule and the grace to cover variance — including retries: a job with three retries at one-minute backoff can legitimately complete several minutes late. Hourly job, ten-minute grace is a reasonable start. For executions that run long, the start/finish pair from our batch-job guide separates "never started" from "died mid-run."

Now walk the five failures: paused, revoked permission, deadline-and-retry chaos, orphaned message, dark project. Every one ends the same way — the pings stop — and the heartbeat notices from outside the project within one interval plus grace, without knowing which of the five it was. You lose the diagnosis (Cloud Logging still has it) and gain a detector that can't be taken down by the thing it detects.

A zero-code canary for the scheduler itself

Cloud Scheduler's HTTP target can point at anything — including a heartbeat URL. Create one more job, */5 * * * *, HTTP target GET https://cronalert.com/api/heartbeat/CANARY_TOKEN, no auth header needed. It costs nothing meaningful and it answers the question the per-job heartbeats can't: when three jobs go quiet at once, is it them, or is Cloud Scheduler not firing in this project? A silent canary means look at the platform or the project; a live canary next to a silent job means look at the job. It's the only scheduler we've covered where the canary needs no code at all.

Testing it

  • The failure path. Deploy a revision that raises before the ping, use "Force run" in the Scheduler console, wait one interval plus grace, confirm the alert. Fix it, force-run again, confirm the incident resolves. A heartbeat you've never seen fail is a hope, not a system.
  • The drift path. Pause the job in the console. Nothing in GCP tells you; the heartbeat should, after grace. Resume it. Do this once so you believe it.
  • The overlap path. Temporarily set a job's attempt deadline below its runtime with retries enabled and watch two executions overlap in the Cloud Run history. If your job isn't idempotent, fix that before the schedule matters.

Set it up in ten minutes

  • Create a CronAlert account — heartbeat monitors are on Pro at $5/mo (100 monitors); HTTP monitors for the Cloud Run services themselves fit on the free plan.
  • One heartbeat monitor per schedule, interval = schedule, grace = runtime variance plus retry backoff.
  • Add the ping after the work: in the container for Jobs, in the handler for HTTP targets, in the subscriber for Pub/Sub.
  • Add the five-minute HTTP-target canary job pointing straight at a second heartbeat URL.
  • Optional: log-based alerts on attempt failures and failed executions for in-project diagnosis.

Frequently asked questions

Does Cloud Scheduler alert on failures?

Not by default — attempts are logged, and alerting policies are yours to build. For Jobs-API and Pub/Sub targets, Scheduler doesn't even see the failure.

Why does Scheduler show success when the job failed?

It observes the hand-off: the API accepted the run request, or the message was published. The container's exit code lives in Cloud Run's execution history.

Can Cloud Monitoring detect a stopped schedule?

Yes — metric-absence policies, one per job, tuned per schedule, living in the same project. Use them alongside an external heartbeat, not instead.

Monitoring with no code changes?

A second Scheduler job with an HTTP target pointing at a heartbeat URL proves the scheduler fires. Proving the work completed still needs the ping inside the job.

What grace period?

Schedule interval plus the worst case of runtime, attempt deadline, and retry backoff. Ten minutes on an hourly job is a sane start; tighten after a week of data.

Trust the beat, not the attempt log

Cloud Scheduler is a fine scheduler and an honest one — it just describes hand-offs, and for its most common targets a hand-off says nothing about outcomes. Put the ping after the work, from wherever the work finishes, set the grace to cover retries, keep the watcher outside the project, and add the free canary. Related reading: monitoring EventBridge scheduled Lambdas (the AWS twin of this post), monitoring serverless functions, monitoring message queues, and the heartbeat guide.