All Articles
Infrastructure··8 min read

Railway Cron Jobs Fail Silently: How to Catch Skipped and Hung Runs

Railway skips a cron run if the previous one is still executing, with no alert and no log entry. Here's how to catch skipped and hung runs with heartbeat monitoring, tell them apart with job chains, and validate that each run actually did work.

You found this page the way most people do: something downstream went stale. A report stopped updating, invoices stopped syncing, a cache never refreshed. You opened Railway, and the cron service looks fine. No crash, no red banner, no error in the logs. And yet the job hasn't actually run in days.

This is not a bug in your code. It's a documented scheduling behavior that Railway's dashboard gives you no way to see, and this article covers the fix: an external heartbeat that expects a ping from every run and alerts when one goes missing.

How Railway schedules crons, and when it silently skips

On Railway, a cron job is a service with a schedule attached. At each scheduled time, Railway starts the service, lets it run to completion, and expects the process to exit. The cron jobs documentation spells out the rule that bites people: if the previous execution is still running when the next run is due, Railway skips the new run.

Skipped means skipped completely. There is no alert, no error, no log entry for the run that never started. The scheduler simply moves on to the next scheduled time and applies the same rule again. So one hung execution doesn't cost you one run. It costs you every run until the hung process dies or someone notices.

The failure chain usually looks like this: a job that normally takes 40 seconds hits a slow external API, or a lock it can't acquire, or a connection that never times out. The process stays alive, quietly doing nothing. Every subsequent scheduled run gets skipped. From the outside, a hung cron and a skipped cron look identical: silence. Nothing runs, nothing logs, nothing errors.

Why the logs won't save you

The instinct after discovering this is to grep the logs. It won't work, for a structural reason: logs record things that happened, and a skipped run is a thing that didn't happen. There is no "run skipped at 02:00" line to search for. The last log entry is from the last successful (or hung) execution, and nothing after it looks wrong. It just looks quiet.

This is the general problem with monitoring scheduled work from the inside: every internal signal (logs, exit codes, error trackers) requires the job to run in order to emit anything. The failure mode where the job doesn't run emits nothing, by definition. You find out from stale data, days later, from a customer or a dashboard. We catalogued the whole family of these failures in cron job failure modes, and they all point at the same requirement: something outside Railway has to hold an expectation about when the job should run, and complain when reality doesn't match.

Heartbeat monitoring: expect the ping, alert on the silence

A heartbeat monitor (also called a dead man's switch) inverts the direction of monitoring. Instead of you probing the job, the job reports in: it sends an HTTP request to a unique URL every time it completes. upti.my knows the expected schedule, so when a ping fails to arrive within the expected period plus a grace window, an incident opens and you get alerted. A skipped run, a hung run, and a crashed run all produce the same observable fact (no ping), and all three now page you instead of rotting silently.

Two rules make the ping trustworthy. First, send it as the final act of the job. Second, send it only after success, so a run that throws halfway through never reports as healthy.

sync-invoices.js
async function main() {
  const processed = await syncInvoices();

  // Last act, only on success: report in
  await fetch("https://heartbeats.upti.my/v1/monitors/<your-token>", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ records_processed: processed }),
  });
}

main().catch((err) => {
  console.error(err);
  process.exit(1); // no ping on failure: that's the point
});

The same shape in Python:

sync_invoices.py
import sys
import requests

def main():
    processed = sync_invoices()

    # Last act, only on success: report in
    requests.post(
        "https://heartbeats.upti.my/v1/monitors/<your-token>",
        json={"records_processed": processed},
        timeout=10,
    )

if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(exc, file=sys.stderr)
        sys.exit(1)  # no ping on failure: that's the point

In upti.my, set the heartbeat's expected period to match the Railway cron schedule (a job on 0 * * * * gets a 60-minute period). Then size the grace period relative to how long the job actually takes, not how long it usually takes. A job that averages 2 minutes but has hit 9 during a big backfill needs a grace window comfortably above 9, or you'll get paged for slow runs that finish fine. The heartbeat docs cover period and grace configuration in detail, and how to monitor cron jobs properly walks through sizing for jobs with variable runtimes.

Skipped vs hung: tell them apart with a job chain

A single end-of-job heartbeat tells you that something is wrong, but not which something. When the alert fires, you still don't know whether Railway skipped the run (nothing ever started) or the run started and hung (which also means the next runs will be skipped). The remediation differs: a skip means go kill the stuck previous execution; a hang means go find out what it's stuck on.

Job chains answer this with two pings against the same monitor, distinguished by step keys:

sync_invoices.py (chained)
BASE = "https://heartbeats.upti.my/v1/monitors/<your-token>"

def main():
    # Step 1: the very first thing the job does
    requests.post(BASE, json={"step": "job-started"}, timeout=10)

    processed = sync_invoices()

    # Step 2: the very last thing, only on success
    requests.post(
        BASE,
        json={"step": "job-finished", "records_processed": processed},
        timeout=10,
    )

The alert now diagnoses itself:

  • No job-started ping: the run never began. On Railway, that almost always means it was skipped because the previous execution is still alive. Go find and kill it.
  • job-started arrived, job-finished never did: the run began and is hung or crashed mid-flight. This is also your early warning that the next run is about to be skipped.

This start-plus-finish pattern matters beyond Railway, and we've written up the full argument in why "cron job started" does not mean "finished". A start ping alone is close to worthless; it's the pair that carries the diagnosis.

Catch the run that ran but did nothing

There's a third failure mode that neither a skip nor a hang covers: the job runs, exits cleanly, pings the heartbeat, and processed zero records because a query silently matched nothing or an upstream export was empty. The green checkmark is a lie.

This is why the examples above put records_processed in the ping body. Each step in a heartbeat can carry validation rules against the payload, so you can attach a greater_than rule to the job-finished step: if records_processed isn't greater than 0 (or greater than whatever floor makes sense for your data), the ping arrives but the run is still marked failed and an incident opens. The job now has to prove it did work, not just that it exited. More patterns for this live on the cron job monitoring page.

Pick a floor, not just nonzero

If your nightly sync normally moves 500+ records, validate records_processed > 100 rather than > 0. A run that suddenly processes 3 records is usually just as broken as one that processes none.

What the native Railway integration adds (and what it can't)

upti.my has a native Railway integration: paste a project token and your services are discovered and imported as monitors in about a minute. For cron services it adds a signal heartbeats don't have. If the cron process crashes, Railway project webhooks pointed at upti.my turn railway.deployment.crashed into an incident immediately, even though a cron service has no public URL for an HTTP check to probe. You learn about the crash from the event, minutes before the missed heartbeat would have told you.

⚠️

A skipped run emits no event

Don't let the webhook lull you. When Railway skips a run because the previous one is still executing, nothing crashed and nothing deployed, so no deployment event fires at all. The heartbeat remains the only thing that catches skips. Use both: webhooks for crashes, heartbeats for silence.

The full integration setup (token scoping, service import, webhook URL, crash-loop detection) is covered in how to monitor a Railway app.

Route the alert, then prove the whole thing works

A missed-ping incident that lands nowhere is a diary entry. On the Free plan you get pre-built workflow templates, and the service-down template routes heartbeat incidents to Slack with deduplication, so a job that misses three consecutive runs produces one escalating thread instead of three pages. (Custom drag-and-drop workflows are a Growth feature at $39/mo with a 14-day trial; the template path here is fully Free.)

Then test it end to end, the push-based way. First confirm ingestion by sending a ping yourself:

terminal
curl -fsS -X POST \
  -H "Content-Type: application/json" \
  -d '{"step": "job-finished", "records_processed": 42}' \
  "https://heartbeats.upti.my/v1/monitors/<your-token>"

The heartbeat should flip to healthy and show your payload. Then test the failure path for real: pause the cron schedule in Railway (or temporarily comment out the ping), wait out the period plus grace, and watch the missed-ping alert land in Slack. If you've never seen your own alert fire, you don't have monitoring, you have hope. Free covers this comfortably: 10 checks, 5 heartbeats, and 1 status page.

Three mistakes that quietly defeat the setup

  1. Pinging at the start of the job. A start-only ping means a job that hangs or crashes at second two still reports healthy forever. Ping at the end, or better, use the two-step chain so you get both signals.
  2. Grace period shorter than the worst-case runtime. If the job can legitimately take 20 minutes during a heavy day and your grace is 5, you'll train yourself to ignore the alert, which is worse than not having one. Size grace off the slowest run you've ever seen, plus margin.
  3. One heartbeat shared by multiple jobs. If three crons ping the same URL, any one of them keeps the monitor green while the other two are dead. One job, one heartbeat, no exceptions. The Free plan's 5 heartbeats exist so you don't have to ration.

📌Key Takeaways

  • 1Railway skips a cron run if the previous execution is still going: no alert, no error, no log entry
  • 2Logs can't show a run that never happened; only an external watcher with an expectation can
  • 3Ping the heartbeat URL as the job's last act, only after success, with grace sized to worst-case runtime
  • 4A two-step job chain (job-started, job-finished) tells a skipped run apart from a hung one
  • 5Validate records_processed in the ping body to catch runs that finished but did nothing
  • 6Railway webhooks catch crashes even without a public URL, but a skipped run emits no event: heartbeats catch the skips

The stale data you found today was the cheap version of this lesson. The expensive version is the one a customer finds first. Set up a heartbeat on the free plan, and the next skipped run pages you within one grace period instead of surfacing in a report three days late.