IAnswerable

Stop Cron Jobs from Overlapping

02 Mar 2026

A cron job scheduled every 5 minutes that occasionally takes 6 minutes will eventually run twice at once. Then you have two processes writing to the same rows, and the bug that shows up looks nothing like “cron job took too long” — it looks like corrupted data or duplicate charges.

The overlap problem

*/5 * * * * /usr/bin/node /app/sync-inventory.js

Cron doesn’t check if the last invocation finished. It fires on schedule regardless. If sync-inventory.js normally takes 2 minutes but occasionally hits a slow API and takes 7, you get two instances running concurrently for 2 of those minutes — both reading and writing the same inventory rows.

Fix it with flock, not application logic

flock is a Linux utility that holds a lock on a file for the duration of a command, and refuses to run a second time if the lock is already held. It’s already on your system — no library, no new table:

*/5 * * * * /usr/bin/flock -n /tmp/sync-inventory.lock /usr/bin/node /app/sync-inventory.js

-n (non-blocking) makes the second invocation exit immediately instead of waiting for the lock — which is what you want for cron. A blocking wait would just queue up overlapping runs instead of skipping them, and you’d get a pile-up the moment one run runs long.

Verify it’s actually working

# run this and open another terminal to try starting it again — the second should exit instantly
flock -n /tmp/test.lock sleep 30; echo "exit code: $?"

If the second invocation prints a nonzero exit code immediately instead of waiting 30 seconds, the lock is doing its job.

What flock doesn’t fix

flock only prevents overlap on the same machine. If you’re running cron on multiple servers — common once you add a second app instance for redundancy — each server has its own /tmp, and both will happily run the job at the same time.

For that case, move the lock into the database, which every server can see:

-- Postgres advisory lock, released automatically when the session ends
SELECT pg_try_advisory_lock(hashtext('sync-inventory'));
async function runWithLock(lockName, fn) {
  const { rows } = await db.query('SELECT pg_try_advisory_lock(hashtext($1)) as got', [lockName])
  if (!rows[0].got) {
    console.log('another instance holds the lock, skipping')
    return
  }
  try {
    await fn()
  } finally {
    await db.query('SELECT pg_advisory_unlock(hashtext($1))', [lockName])
  }
}

The failure mode with locks: a stuck lock

A process holding a flock that gets killed with SIGKILL (not a clean exit) releases the lock automatically — the kernel does this when the file descriptor closes. This is one reason to prefer flock over a manual “write a PID file, check for it” scheme: hand-rolled PID-file locks don’t get cleaned up on a hard crash, and you end up with a permanently stuck job until someone notices and deletes the file by hand.

Postgres advisory locks have the same property: tied to the session, released when the connection drops, even on a crash.

What to skip

Skip a distributed lock service (Redis Redlock, ZooKeeper) for this. flock covers single-server. Postgres advisory locks cover multi-server, for free, using a database you already have running. Reach for a dedicated lock service only if you’re coordinating something more complex than “don’t run this job twice.”