Skip to content
FC
← All Posts
node.jsautomationscriptingbackend

Scheduled Node.js Jobs: Patterns That Hold Up in Production

7 min read

The Problem With Unattended Scripts

Scheduled jobs are easy to start and easy to forget. A cron entry, a setInterval, a launchd plist — the job runs, time passes, and somewhere between "working fine" and "broken for weeks" there's a gap filled entirely by silence.

I hit this directly working on the automated blog post generator for this site. It runs weekly on a schedule, calls an API, writes a file, and exits. Simple on paper. Getting it to a state where I'd trust it to fail loudly — rather than quietly produce nothing and exit 0 — took a few deliberate choices. Here's what I landed on.

Why setInterval Is the Wrong Abstraction

The path of least resistance is to put a scheduled job inside your existing Express server:

// tempting, but wrong for most scheduled work
setInterval(async () => {
  try {
    await runWeeklyJob()
  } catch (err) {
    console.error('job failed:', err)
  }
}, 7 * 24 * 60 * 60 * 1000)

Three problems compound quietly here.

The timer resets on every restart. Deploys, crashes, OOM kills — each one resets your setInterval to zero. A job scheduled for 2am Monday runs at 2am-plus-however-long-since-last-deploy.

Error handling is a false choice. Either catch the error and swallow it (silent failure), or let it propagate and risk crashing the server serving requests. Neither is the behavior you want from a background task.

The job and the server share a failure domain. A misbehaving job can leak memory or exhaust file handles inside the same process handling your API traffic.

Treat the Job as a One-Shot Script

The cleaner model: a scheduled job is a program that starts, does its work, and exits. The OS handles the schedule; your script handles the work.

On Linux this is systemd timers or cron. On macOS it's launchd. The scheduler wakes your script, the script runs, exits with a status code — 0 for success, non-zero for failure — and the scheduler logs the result. No long-lived process keeping a timer, no shared failure domain. Each run gets a fresh heap.

This also means OS-level alerting on failure without any custom infrastructure, and the ability to test your job by just running node script.js in a terminal.

The Core Pattern

Here's a minimal but production-honest example:

// scripts/weekly-report.js
import { writeFileSync, renameSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'

const OUTPUT_DIR = resolve(process.env.OUTPUT_DIR ?? './output')
const weekLabel = getISOWeekLabel(new Date())
const outputPath = resolve(OUTPUT_DIR, `report-${weekLabel}.json`)
const tmpPath = `${outputPath}.tmp`

async function main() {
  // Idempotency: safe to re-run if the scheduler fires twice in one window
  if (existsSync(outputPath)) {
    log(`${weekLabel} already exists — skipping`)
    return
  }

  const data = await fetchWeeklyData()
  const report = buildReport(data)

  if (!report.entries?.length) {
    fatal('report produced no entries — upstream data may be missing')
  }

  // Atomic write: .tmp first, then rename into place
  writeFileSync(tmpPath, JSON.stringify(report, null, 2), 'utf8')
  renameSync(tmpPath, outputPath)

  log(`wrote ${outputPath}`)
}

function getISOWeekLabel(date) {
  const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
  d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7))
  const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
  const week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
  return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`
}

const log = (msg) => console.log(`[${new Date().toISOString()}] ${msg}`)
const fatal = (msg, err) => {
  console.error(`[${new Date().toISOString()}] FATAL: ${msg}`, err?.message ?? '')
  process.exit(1)
}

main().catch((err) => fatal('uncaught error', err))

Three things are doing real work here.

Exit codes. process.exit(1) on failure. Launchd, systemd, and cron all read this. A non-zero exit can trigger monitoring alerts or block a downstream job — but only if you set it. Swallowing errors and exiting 0 is the most common way scheduled jobs hide failures indefinitely.

Atomic writes. Writing to .tmp then calling renameSync (which wraps the POSIX rename(2) syscall) is atomic on the same filesystem. A reader either sees the old complete file or the new complete file — never a partial write. If the script crashes mid-write, only the .tmp is corrupted; the last good output stays untouched.

Idempotency. The script checks for existing output before doing any work. If the scheduler fires twice in a window — the machine wakes from sleep at an odd time, you trigger a manual run during debugging — the second invocation exits cleanly without duplicating work.

Structured Logging

Scripts that run unattended need logs you can read hours later. The ISO timestamp prefix in the example is a minimum — every log line has one so you can reconstruct exactly when something happened and correlate across runs.

On macOS with launchd, stdout goes to the StandardOutPath you configure in the plist. On Linux with systemd, journalctl -u your-service captures it. With cron, you redirect stdout in the crontab entry. In all three cases the OS handles retention; you just write to stdout.

Handling the Real Failure Modes

One-shot scripts fail in three distinct ways worth planning for.

The job throws. The main().catch() at the bottom of the script handles this. Every unhandled rejection becomes a fatal() call and a non-zero exit.

The job hangs. The scheduler will not kill a hanging Node.js process for you. Add a hard timeout:

const TIMEOUT_MS = 5 * 60 * 1000

const timeout = setTimeout(() => {
  fatal('job exceeded timeout')
}, TIMEOUT_MS)
timeout.unref() // don't prevent clean exit if main() resolves first

main()
  .then(() => clearTimeout(timeout))
  .catch((err) => fatal('uncaught error', err))

timeout.unref() tells Node.js not to keep the event loop alive just for this timer — if main() resolves normally and there's no other pending work, the process exits cleanly without waiting for the timeout to fire.

The job completes but produces wrong output. This is the hardest to catch because the process exits 0. Guard with an explicit validation step before writing — as shown in the main example — and fail loudly rather than writing an empty or malformed file.

Launchd Plist Reference (macOS)

A minimal agent plist that runs the script every Monday at 06:00:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>dev.fahdscode.weekly-report</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/node</string>
    <string>/Users/fahd/scripts/weekly-report.js</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Weekday</key>
    <integer>1</integer>
    <key>Hour</key>
    <integer>6</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>
  <key>StandardOutPath</key>
  <string>/Users/fahd/logs/weekly-report.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/fahd/logs/weekly-report-err.log</string>
</dict>
</plist>

Load with launchctl load ~/Library/LaunchAgents/dev.fahdscode.weekly-report.plist. One launchd behavior worth knowing: unlike raw cron, launchd fires a missed StartCalendarInterval job on the next machine wake. If your Mac was sleeping at 6am Monday, the job runs when you open the lid. For most jobs this is what you want; for time-sensitive ones, add a guard inside the script that checks elapsed time since the target schedule and exits early if too much time has passed.

Why This Pattern Comes Up in MERN Work

Background jobs are everywhere in real backends. In OrderX, syncing order state from an external POS system is exactly this shape: a script that runs on a schedule, checks what's new, writes to MongoDB, exits. The same three properties apply — idempotency to avoid double-importing the same orders, atomic state transitions, loud failure when the upstream source returns nothing.

The pattern is language-agnostic, but Node.js makes it easy to reach for. The node:fs primitives for atomic writes are right there, exit codes are one line, and async/await keeps the control flow readable.

Takeaway

The gap between a script that works and one that holds up is usually exit codes, atomic writes, and idempotency. None of them are complex individually. Together they give you a scheduled job that fails loudly when something goes wrong, is safe to re-run when you need to debug it, and never leaves a reader holding a half-written file. That's the floor for any script you're not watching.

← Back to Blog