Back to Blog
Custom Software

What Is a Cron Job? Running Scheduled Tasks Reliably

What is a cron job and how do you read a cron expression? Why scheduled tasks such as nightly reports, invoice runs and backups fail silently: double runs on several servers, time zones, missed runs and monitoring. Idempotency with a period key and how cron relates to queues.

Cron JobZamanlanmış GörevBackendÖzel Yazılım

A cron job is a task that runs by itself at a set time or at regular intervals: taking a backup every night at 02:00, emailing the sales report every morning, updating shipment statuses every hour, issuing subscription invoices on the first day of the month. It takes its name from the Unix cron program and defines the schedule with a five-field expression in the form “minute hour day month weekday”. Scheduling the task is easy; the hard part is making sure it does not run twice, that a missed run is noticed and that it does not break silently. This article covers the cron expression, the most common reasons scheduled tasks fail, and the safe setup we apply in our custom software projects.

How do you read a cron expression?

From left to right the five fields are minute (0-59), hour (0-23), day of month (1-31), month (1-12) and day of week (0-6, Sunday is 0). An asterisk means “every”, a comma gives a list, a hyphen a range and a slash a step. A few examples:

  • 0 2 * * *: every day at 02:00 (nightly backup, daily report).
  • */15 * * * *: every 15 minutes (shipping or payment status sync).
  • 0 9 * * 1-5: every weekday at 09:00 (morning summary).
  • 0 0 1 * *: midnight on the first day of every month (monthly invoices, subscription renewals).
  • 30 23 * * 0: every Sunday at 23:30 (weekly cleanup, archiving old records).

A cron expression is interpreted in a time zone, and that time zone is usually the server clock. If the server runs in UTC, “0 2 * * *” means 05:00 in Türkiye. So the scheduler’s time zone is written out explicitly, never left to the default; in countries with daylight saving time, jobs scheduled between 02:00 and 03:00 may not run at all, or run twice, on one day a year.

Where does a scheduled task run?

  • Server crontab: the classic single-server setup. Simple, but the job is forgotten when the server changes, and it is not versioned with the code.
  • In-app scheduler: libraries such as node-cron, Quartz or Hangfire. Lives with the code, but if the application runs several instances the job fires in every one of them.
  • Platform scheduler: Kubernetes CronJob, cloud providers’ scheduled triggers, Vercel Cron. The platform fires once, the work stays in your code.
  • Repeatable jobs in a queue: queues such as BullMQ or Sidekiq keep the recurring job definition themselves and provide retries and run history out of the box.

The most common bug: the job runs twice

When the application scales to two servers, the scheduler inside it scales to two as well, and the nightly report goes out twice. For a report that is annoying; for invoicing or payment orders it is a serious error. It is not enough for the job to be triggered once; the work itself must produce the same result when it runs twice. The most robust way to achieve this is to give every run a period key, such as “monthly invoices, 2026-10”. When the job starts it tries to create a record with that key; if the record already exists, another instance has taken the work and this one exits. The same table also records when the job started, when it finished and whether it failed. It is the same idea as the event ID in webhook receivers, which we covered in the webhook article.

// 1st of every month at 03:00, Türkiye time: '0 3 1 * *' (tz: Europe/Istanbul)
export async function monthlyInvoicesJob(now = new Date()) {
  const period = formatInTimeZone(now, 'Europe/Istanbul', 'yyyy-MM');
  const runKey = 'monthly-invoices:' + period;

  // Unique index on job_runs.run_key: a second instance cannot get in.
  const run = await db.jobRuns.insertIfAbsent({
    runKey,
    startedAt: new Date(),
    status: 'running',
  });
  if (!run) return; // this period is already done or in progress

  try {
    const subs = await db.subscriptions.dueForBilling(period);
    for (const sub of subs) {
      // Invoices are unique per period + subscription: a half-done run restarts safely.
      await billing.createInvoiceOnce(sub.id, period);
    }
    await db.jobRuns.finish(runKey, 'succeeded');
  } catch (err) {
    await db.jobRuns.finish(runKey, 'failed');
    throw err; // let it reach the error tracker
  }
}
The most dangerous state of a scheduled task is not failing but not running at all: a failure is logged and seen, while a job that never ran stays silent until someone notices. Every critical job needs monitoring that answers “when was the last successful run?”.

Missed runs and monitoring

If the server restarts exactly at the scheduled time, or the scheduler is down while a new release is deployed, that run is missed. Classic cron does not make up for missed runs. So critical jobs are protected in two ways. First, the job processes not just “now” but every unprocessed period: the monthly invoice job does the work for every period without invoices, not only the current month. Second, it is monitored from outside: each successful run sends a signal to a monitoring endpoint, and if no signal arrives within the expected window the team gets an alert. We described where errors and signals should go in error tracking and logging; for backup jobs this monitoring is mandatory, with details in data backup and disaster recovery.

Long-running jobs and queues

Cron is a trigger, not a worker. Invoicing ten thousand subscribers in a single loop leaves it unclear which invoices were issued when an error occurs halfway, and it can collide with the next trigger. The right design is for cron only to put the work on a queue, and for queue workers to process each item separately with retries. That way an error on one subscriber does not stop the others, and the load spreads across the day. The same rule applies to jobs that depend on an external service such as e-invoicing: when the other side slows down the queue waits instead of the job timing out; we covered this in e-invoice integration. Whether jobs live in a separate service or the same application is an architectural decision; see microservices or monolith.

Pre-launch checklist

  • Is the time zone set explicitly, and are hours that fall on a daylight saving change avoided?
  • When several instances of the application run, is the job still triggered only once?
  • Does the work give the same result if it runs twice (period key, unique index)?
  • Is a missed period made up on the next run?
  • Is the last successful run monitored, raising an alert when it is overdue?
  • Is the job definition versioned with the code and tested in the CI/CD pipeline?

Conclusion

A cron job is a small part that does the software’s “must not be forgotten” work instead of a person; but being small does not justify leaving it unchecked. A safe scheduled task is defined with an explicit time zone, runs on a single instance, does no harm when it runs twice, makes up missed periods and tells someone when it does not run. If you want your invoicing, reporting, sync and backup jobs built on these principles, have a look at our custom software service or request a quote directly.

Let's Build Your Project

Get a free consultation for your website, mobile app, or corporate software project.

Get a Free QuoteExplore our Custom Software service