Tuesday, 09:41. A customer replies to your invoice reminder with four words: "Got this already — twice." Then a second reply, then a third. By the time you open the send log, the arithmetic is unambiguous: the nightly reminder job sent 412 messages to your 206 subscribers — illustrative numbers, but the shape is exactly what founders describe. Nothing is on fire. The dashboard is green, error rates are zero, and every monitor you own says the world is fine. Yet you have just emailed your entire customer base proof that something behind the scenes is not.
Key Facts
What a Cron Job Actually Promises: A cron job — or its modern cousins, the scheduler libraries embedded in web frameworks and the recurring-task features inside job queues — makes exactly one promise: at the time you specified, it will attempt to start the command you specified.
Why Cron Trouble Hits Small SaaS Suddenly: For months, nothing happens.
The Five Cron Failure Patterns: Every pattern below is a composite of stories founders tell publicly and privately — deliberately no specific company, because the details change while the shapes repeat.
The Cron Job Best Practices SaaS Founders Actually Need: Five habits prevent every pattern in the catalog.
This is the other kind of SaaS failure — the kind where the web app is perfect and the invisible machinery around it misfires. Cron jobs, background schedulers, and recurring tasks are the plumbing of every small SaaS: reminders, retries, cleanups, reports, renewals. They run quietly for months, which is precisely why they stay untested, and then one ordinary change — a second app instance, a table that grew, a deploy at 2 a.m. — turns the quiet job into a customer-facing incident that none of your monitoring saw.
This guide collects the cron job best practices SaaS founders usually learn the hard way: what a scheduler actually promises (and what it does not), why cron trouble arrives suddenly, the five failure patterns behind nearly every cron incident, the habits and health log that prevent them, what to monitor when silence is the enemy, and a full walkthrough of the double-send above — detection, fix, prevention. Most of it costs an afternoon, not a platform team.
What a Cron Job Actually Promises
A cron job — or its modern cousins, the scheduler libraries embedded in web frameworks and the recurring-task features inside job queues — makes exactly one promise: at the time you specified, it will attempt to start the command you specified. That is the entire contract. Three consequences follow, and every failure pattern later in this article is a direct corollary of one of them.
It triggers on wall-clock time. Nothing else. The clock reaches the pattern; the command starts. Cron is not a queue, not a workflow engine, and not a retry system. It has no concept of "the previous run," no deduplication, and no opinion about whether running again right now is wise.
It has no memory of the last run. The scheduler does not know whether the previous execution succeeded, is still running, or ever happened at all. If that knowledge matters — and for invoicing, billing, and cleanup jobs it always does — it has to live in your application or your database, written by your own code.
It leaves one line of evidence. Classic cron records that the command was handed to the shell, usually one line per fire, and captures nothing else unless you arrange it. Exit codes, stack traces, and row counts vanish into a syslog nobody reads, or simply nowhere.
Read those three facts together and the catalog below stops looking like bad luck. Overlaps, double runs, silent failures, and drift are not anomalies; they are the default behavior of a device with no memory, no state, and no voice. Your job is to add all three.
Why Cron Trouble Hits Small SaaS Suddenly
For months, nothing happens. That is the trap. The job runs nightly, sends its reminders, cleans its sessions, and quietly earns "boring" status — the highest compliment in operations. Then one ordinary change lands and the boring job becomes a customer-facing incident. Three structural changes arrive with growth, and each one converts a harmless schedule into a hazard:
A second app instance is a second scheduler. Many small SaaS products run their scheduler inside the web app — a background-job library with a recurring-task feature, booted alongside the app. The day you scale from one instance to two for daytime traffic, you also scale your cron from one clock to two. That is not "more capacity." It is two independent devices firing the same command at the same wall-clock moment, and nothing in your code ever told them to coordinate.
Long jobs grow into their next slot. A job scheduled every five minutes that finishes in four is fine — until the table it scans doubles. Now it takes six, and every fire overlaps the previous run. "It worked when I wrote it" is the most common sentence in cron post-mortems, because schedules are fixed while data is not.
Deploys restart things mid-run. A deploy restarts processes. If a job is halfway through a batch when its process is recycled, the work stops mid-flight — and if the deploy also applies migrations, the job may come back up against a schema that is half its old shape and half its new one.
None of these announce themselves as errors. They surface as duplicates, gaps, and half-done work — which is exactly why they need their own catalog.
The Five Cron Failure Patterns
Every pattern below is a composite of stories founders tell publicly and privately — deliberately no specific company, because the details change while the shapes repeat. For each: the pattern, what it looks like from the outside, the fix on the day, and the prevention that makes the day never come.
1. Overlapping runs
The pattern: A job's duration exceeds its schedule gap, so the next fire starts while the previous run is still working. Two copies of the same logic now read and write the same tables concurrently.
What it looks like: The dashboard stays green while duplicates accumulate: two identical rows, two reminder emails, an adjustment applied twice. Logs, if you keep them, show two starts five minutes apart for a job that takes six. Deadlocks that appear at the same minute every hour are this pattern wearing a disguise.
The fix: Stop the overlap first — let the longer run finish, kill the stray, deduplicate the data both copies touched. Then shorten the job or widen the interval so duration and schedule fit again.
Prevention: A per-job timeout shorter than the schedule gap, a lock so a second start becomes a clean skip, and duration recorded in a health log so the trend is visible months before the collision.
2. Double execution across instances
The pattern: The scheduler boots inside every app instance, so scaling from one instance to two executes every job twice. Three instances, three times. No single line of code is wrong — every instance is doing precisely what it was told.
What it looks like: Output doubles with eerie precision. In the story that opens this article, 206 subscribers received 412 reminders — not 300, not 350, exactly double — because two healthy schedulers each did their job correctly. Both instances' logs show successful runs, because both genuinely ran.
The fix: Turn the scheduler off on all but one designated instance, then deduplicate what the double run produced. Which rows count as "real" is a product decision; make it deliberately, from the send log, not from memory.
Prevention: A database-level lock per job, so any future second scheduler becomes a harmless skip, and idempotent job design, so even a double run changes nothing.
3. Silent failure with the exit code swallowed
The pattern: The job fails — an exception, a non-zero exit, a connection refused — and the evidence goes into a log nobody reads, or gets swallowed entirely because the runner never captured output. The scheduler has nothing to report; monitoring that watches HTTP stays green.
What it looks like: Days or weeks of nothing. Then someone notices the weekly report is stale, failed charges were never retried, or the cleanup never ran and the table has grown enormous. The error that explains everything has been sitting in a file since the night it happened.
The fix: Find the swallowed signal — full logs first, then re-run the job manually with output captured — and fix the underlying cause before re-running, or you will simply produce a second silent failure.
Prevention: Wrap every job in a runner that records start, end, and result to your own health log, and alert on two things: a non-zero exit, and a run that never started. The second alert is the one that catches silence.
4. Timezone and DST drift
The pattern: The schedule is defined in server-local time. The server lives in UTC, the founder lives a few time zones away from what that schedule means, and twice a year daylight saving time moves the wall clock under a fixed schedule — or a move to a new host changes the server's zone entirely.
What it looks like: A job that always fired at 02:00 starts firing at 03:00 after a deploy to a new region. In spring, a run is skipped entirely when its hour does not exist; in autumn, it fires twice. The job did not break — the meaning of the clock changed underneath it.
The fix: Establish which clock the scheduler is actually using — server zone, UTC, or a configured zone — and whether it follows wall-clock time or fixed offsets, then run the missed work manually once, carefully.
Prevention: Schedule in UTC and say so, in a comment next to the schedule itself. A first line like # all schedules UTC in your crontab or scheduler config costs nothing and deletes the whole category. Record start times in the health log so drift shows up as data, not folklore.
5. The job that ran during a deploy
The pattern: The cron window and the deploy window are the same time of night. The job starts, the deploy restarts its process or applies a migration, and the job either dies mid-run or keeps running against a half-migrated schema — columns it expects are gone, or not there yet.
What it looks like: A nightly job that fails "randomly," but only on nights you shipped. Errors reference a column that does not exist yet, or that existed yesterday. Partial work is left behind: half a cleanup, half a batch of state changes, no error anywhere else.
The fix: Let the deploy finish, confirm the schema is at its final state, and re-run the job — which is only safe if the job is idempotent. If it is not, clean the partial rows by hand first, and treat that as a lesson about habit number two below.
Prevention: Stagger schedules away from deploy windows, keep migrations in their own window, and use health-gated deploys that verify a release before traffic switches — so deploys are short, deliberate events you can schedule around, not hour-long coincidences.
Read as a set, the patterns share one property: each one is invisible to default tooling, and each one becomes visible the moment a job writes its own record.
Pattern
Earliest clue
Core defense
Overlapping runs
Duration creeping toward the schedule gap
Per-job timeout plus a lock
Double execution
Output doubles after an instance change
Database-level lock and idempotency
Silent failure
Stale outputs nobody can explain
Health log plus a did-not-run alert
Timezone/DST drift
Start times shift after deploys or DST
UTC everywhere, stated explicitly
Deploy collision
Nightly failures only on ship nights
Staggered windows and safe re-runs
The Cron Job Best Practices SaaS Founders Actually Need
Five habits prevent every pattern in the catalog. None requires a platform team or a new hire; all five live in your application, which is precisely why they are yours to build.
1. Give every job a database-level lock. An advisory lock or a dedicated lock row, keyed to the job's name. The database is the only place every instance can see — an in-memory lock inside one app process protects you from exactly nothing across processes. If the lock is not acquired, exit as a clean, logged skip: a skip is success, not an error.
# pseudocode — the lock id and job name are illustrativelock_id = 741002113 # a constant unique to the nightly-reminder jobif not db.try_advisory_lock(lock_id): write_health_log(job = "nightly-reminder", result = "skipped: lock held") exit 0 # a clean skip, not an errormark_run_started("nightly-reminder")do_the_work() # idempotent: a re-run sends nothing twicemark_run_finished(result = "ok")db.release_advisory_lock(lock_id)
2. Design every job to be idempotent. Idempotent means running twice produces the same effect as running once: check sent_at before sending, upsert instead of insert, key each unit of work on a stable identifier so a repeat collapses into a no-op. This is the deepest defense on the list. It converts every other failure — a double fire, a post-deploy re-run, a manual restart — from a customer-facing incident into nothing at all.
3. Stagger schedules away from deploy windows. If deploys tend to happen around 02:00, do not schedule the heavy job at 02:00. Move it to 02:10, and keep migrations in their own window too. Ten minutes of calendar discipline removes the deploy-collision pattern outright.
# crontab — all schedules UTC10 2 * * * /app/bin/nightly-reminder --timeout 900 # fictional path and values
4. Give every job a timeout. A timeout converts "overlapping forever, silently" into "failed loudly at twelve minutes." Size it against the schedule gap, not against the job's best day — and treat a timeout the job regularly hits as information, not noise.
5. Alert on non-zero exit and on did-not-run. Two different alerts. The failure alert catches runs that started and went wrong. The freshness alert — "this job has not reported a run within one interval of its schedule" — catches runs that never started, which is the failure mode default tooling misses entirely. Silence and success look identical to an HTTP monitor; only the second alert tells them apart.
The Cron Health Log: Five Fields Per Run
The health log is the cheapest tool in this article: one row per run, written by the job itself into a table in your own database. Five fields turn every pattern in the catalog from a mystery into arithmetic:
Field
Example (illustrative)
Why it matters
Start time (UTC)
2026-01-14 02:10:03
Anchors missed-run math; makes timezone drift visible
End time and duration
02:14:41 — 4m 38s
Duration versus schedule gap predicts overlaps before they happen
Rows touched
312 reminders sent
Doubled output — the double-execution signature — becomes arithmetic
Result
ok (exit 0)
Distinguishes "ran and succeeded" from silence, which look identical everywhere else
Lock decision
acquired; 0 skips this week
Proves the lock exists and is not skipping runs silently
Two of those rows deserve emphasis. Duration against the schedule gap is your early-warning radar: a job scheduled every five minutes that now takes four minutes and thirty-eight seconds is a collision booked weeks in advance, and the trend line tells you which week. And rows touched against an expected range is the cheapest double-execution detector you will ever build — you do not need anomaly-detection software to notice that 412 reminders went to 206 subscribers. A per-job sanity check ("rows touched must fall in this range; alert outside it") is one if-statement.
Monitoring Cron Jobs: Alert on Failure and on Silence
Most monitoring watches HTTP endpoints. Cron jobs have no endpoints — they run on a schedule, touch data, and exit. That is why cron needs its own small monitoring story, built on three checks.
Missed-run detection. The freshness alert from the habits section, made real. Either a tiny checker job that reads the health log — "for each job, has a row appeared within one interval of its schedule?" — or an external heartbeat, where the job pings a URL at the end of each run and a monitoring service alerts when the ping stops. Either works; the point is that "the job did not run" must become a machine-detected event, not a discovery a customer makes on your behalf.
Duration trend. The question is not only "is it failing" but "is it slowing." A job that lengthens ten percent every month is writing you a postcard about the day it starts overlapping its next slot. The health log gives you the data; a weekly glance at the trend gives you the defense.
Why silence is the enemy. A web app that breaks produces errors: 500s, exceptions, a red dashboard. A cron job that breaks usually produces nothing at all — no errors, no logs, no signal — and silence is indistinguishable from success on every default tool you own. The double-send story at the top of this article was detected by customers, which is the most expensive monitoring arrangement that exists: your customers become your alerting system, and every alert arrives with their trust attached. Everything in this section exists to move detection from their inbox to yours.
A Worked Example: The Invoice Reminder That Sent Twice
To make the catalog concrete, here is the full anatomy of the story this article opened with. It is fictional, assembled from the three most common ingredients of real double-send incidents, and every number in it is illustrative.
Detection
The customers detected it. Replies arrived within an hour of the morning run: "Got this already — twice." The send log confirmed the arithmetic — 412 reminders to 206 subscribers — and the timestamps told the deeper story: every reminder was sent at 02:10:03 and again at 02:10:41. One job cannot run twice within the same minute on a nightly schedule; two schedulers starting the same job thirty-eight seconds apart can. A health log would have caught it first — two start rows for one job, rows touched doubled. The lock would have prevented it entirely. Instead, detection arrived by reply email, which is to say: last.
The root cause
Two weeks earlier, after a good traffic month, a second app instance was added to share daytime load. The background-job library booted its recurring-task scheduler alongside the app on every instance — so both instances fired the nightly reminder at 02:10 UTC. No lock existed, because a single instance had never needed one. The send function was not idempotent, because until that morning nothing had ever asked it to be. Nothing was broken. Two correct schedulers had simply never met.
The fix
Four moves, in order. Disable the scheduler on all but one designated instance, so the duplicate clock stops ticking. Add the database-level advisory lock, so any future second scheduler becomes a logged skip instead of a second send. Make the send idempotent by recording sent_at on each reminder and skipping rows where it is already set. Then clean up deliberately: confirm from the send log exactly which subscribers received the reminder twice, so support could answer precisely instead of guessing. A short, plain apology went to the affected customers the same day. Duplicates are embarrassing; pretending they did not happen is worse.
The prevention
The durable defense is three of the five habits: the lock, the idempotency, and a rows-touched sanity check — for this job, "reminders sent must be less than or equal to active subscribers; alert otherwise." Add a startup check that refuses to boot the scheduler on instances not designated to run it, and the whole class of incident is closed. Total build time for a founder with AI assistance: an afternoon. Total cost of skipping it: one morning of replies reading "got this already — twice."
Where Deployxa Fits — and Where It Doesn't
Everything above is deliberately platform-agnostic; the five habits live in your code and travel with you. That said, the platform underneath the cron changes several parts of the math, and it is worth being precise about which. On Deployxa, scheduled and recurring jobs and long-running workloads are supported workload types — the platform's product suite treats a background scheduler as a first-class resident rather than something you smuggle onto a web tier — so the nightly job and the web app can each live where they belong.
Three more platform facts matter to this catalog. Deployments are health-gated: a broken release fails its checks before traffic switches, which keeps deploys short and deliberate — the kind of event you can stagger a cron window around instead of bracing for it. Per-deployment logs are visible from the dashboard, so "what was running when the deploy hit" is a filter away, not an SSH archaeology project. And the prior release stays warm for fast rollback, so if a deploy does collide with a job, returning to the last known-good state takes seconds rather than a rebuild.
Now the honest limits, because they are the same limits as the five habits: locking, idempotency, and alerting are application code. Deployxa cannot make your reminder job idempotent, cannot hold an advisory lock on your behalf, and cannot know that 412 reminders to 206 subscribers is wrong — those judgments live in your code, and the docs describe the platform pieces, not your job's correctness. What the platform removes is infrastructure friction around the cron. What it cannot remove is your responsibility for the five habits.
Your Cron Safety Checklist
The whole article, compressed to ten lines:
- Every job takes a database-level lock (advisory lock or lock row) keyed to that job's name
- A failed lock acquisition exits as a clean, logged skip — never a second concurrent run
- Every job is idempotent: running it twice produces the same effect as running it once
- Every job has a timeout shorter than its schedule gap
- Schedules are staggered away from deploy and migration windows
- All schedules are defined in UTC, with a comment stating so next to the schedule
- A health-log row is written per run: start, end, duration, rows touched, result
- A failure alert fires on non-zero exit or exception
- A freshness alert fires when a job misses its expected run
- A rows-touched sanity check exists per job, alerting outside the expected range
Prove It Before You Need It
Pick your most dangerous job — the one that sends email, charges money, or moves customer state — and run the double-run drill on a staging copy this week. Fire it twice in a row. Then, if you can, fire it from two instances at once. If the second run double-sends or double-charges, you have just found the collision in the one place it costs nothing. Add the lock, make the job idempotent, and run the drill again until firing it twice is a harmless no-op — then write the health log in and leave it running. One afternoon of deliberate collision on staging buys the only cron guarantee worth having: that when production eventually fires the same job twice — and one day it will — nothing happens at all.