cronuru
Guide

Sub-Minute Scheduling

"Every 5 seconds" is the single most-searched cron expression that does not exist. Classic Unix crontab is minute-precision — it has no seconds field, so one minute is the hard floor. But *some* cron dialects added a seconds field, and some platforms have floors well above a minute. This page is the support matrix: which scheduler can actually run sub-minute, what its real minimum is, and the three escape hatches when it can't.

Updated

The short answer

  • Standard Unix cron cannot do seconds. Five fields, minute-precision, one-minute floor. This is not a limitation you can configure away — the format has nowhere to put the value.
  • Some dialects added a seconds field. Quartz, Spring’s @Scheduled, node-cron, APScheduler, tokio-cron-scheduler and the Rust cron crate all parse a leading seconds field and will happily fire every second.
  • Some platforms floor higher than a minute. GitHub Actions will not run a workflow more than once every 5 minutes, no matter what you write. Vercel Hobby caps at once per day.
  • If you’re on a minute-floor runtime, stop trying to express it in cron. Use a fixed-rate loop in a long-lived process. That’s the actual answer for the majority of people who search for “cron every 10 seconds”.

Support matrix

The question “can it do seconds?” has three different answers — a real seconds field, a non-cron interval mechanism, or a flat no — and the platform floors vary more than the dialects do.

RuntimeSeconds fieldReal minimumSub-minute expression
Unix cron / crontabNo — 5 fields1 minute
Kubernetes CronJobNo — 5 fields1 minute
GitHub ActionsNo — 5 fields5 minutes
AWS EventBridge SchedulerNo1 minute
Cloudflare Workers CronNo — 5 fields1 minute
Vercel CronNo1 min (Pro) / 1 day (Hobby)
QuartzYes — 6–7 fields1 second0/5 * * * * ?
Spring @ScheduledYes — 6 fields1 second*/5 * * * * *
node-cronYes — 6 fields1 second*/5 * * * * *
APSchedulerYessecond=1 secondCronTrigger(second="*/5")
tokio-cron-schedulerYes — 6 fields1 second*/5 * * * * *
Rust cron crateYes — 7 fields1 second*/5 * * * * * *
robfig/cron (Go)Optional — cron.WithSeconds()1 second*/5 * * * * *
systemd timersN/A — OnUnitActiveSecsub-secondOnUnitActiveSec=5s

Two rows deserve a second look:

GitHub Actions is the one that surprises people. GitHub’s documentation is blunt about it: “The shortest interval you can run scheduled workflows is once every 5 minutes.” An expression like * * * * * or */2 * * * * is still syntactically valid — the workflow file commits and validates cleanly, so there’s no error telling you anything is wrong. It simply will not run at that cadence. Treat */5 * * * * as the floor, and don’t write a schedule whose interval you aren’t actually going to get.

On top of that, scheduled workflows are queued on shared infrastructure and are explicitly not guaranteed to fire on time — delays during peak load are common, and runs can be dropped entirely. If a scheduled job must happen, have it verify its own last-success time rather than assuming every firing landed.

systemd looks like a win but has a trap. OnUnitActiveSec=10s is a legitimate sub-minute timer, but AccuracySec defaults to 1 minute — systemd coalesces wakeups to save power and places each firing at a randomized point inside that window. Your 10-second timer can drift by nearly a minute until you add AccuracySec=1s.

Why cron has no seconds

This isn’t an oversight anyone forgot to fix — it’s structural. The original Unix cron daemon woke up once a minute, compared the current time against every crontab line, and ran what matched. A seconds field would have meant waking 60× more often on hardware where that was a real cost, to serve a use case that barely existed: in 1975, nothing needed to run every five seconds.

The format that came out of that design has exactly five fields:

┌───────── minute       (0–59)
│ ┌─────── hour         (0–23)
│ │ ┌───── day of month (1–31)
│ │ │ ┌─── month        (1–12)
│ │ │ │ ┌─ day of week  (0–7)
│ │ │ │ │
* * * * *

There is no sixth position to put a seconds value in, and adding one would break every crontab on earth — a 5-field expression and a 6-field expression mean completely different things depending on which end the extra field goes on. That’s precisely why the dialects that did add seconds are mutually incompatible today.

The practical consequence: */5 * * * * means every 5 minutes, */5 * * * * * means every 5 seconds, and the only difference is a field count that’s easy to miscount. This is the single most common cron bug we see, and it fails silently — the job runs, just 60× less often than intended. Paste anything ambiguous into the cron parser and read the next fire times.

The seconds-aware dialects

They all put seconds first, and then disagree about everything else.

Quartz — 6 or 7 fields, second minute hour day-of-month month day-of-week [year]. Day-of-month and day-of-week are mutually exclusive, so one of them must be ?:

0/5 * * * * ?      every 5 seconds
0/30 * * * * ?     every 30 seconds
* * * * * ?        every second

Note Quartz idiom prefers 0/5 (“starting at 0, every 5”) over */5. Both parse, but 0/5 is what you’ll see in the wild.

Spring @Scheduled — 6 fields, no year, no ? requirement:

@Scheduled(cron = "*/5 * * * * *")   // every 5 seconds
@Scheduled(cron = "0/30 * * * * *")  // every 30 seconds

// Usually better — no cron parser involved at all:
@Scheduled(fixedRate = 5000)         // start-to-start
@Scheduled(fixedDelay = 5000)        // end-to-start (no overlap)

node-cron — 6 fields, seconds-first. A 5-field expression still works and is treated as “at second 0” of the matching minute, which makes it easy to paste a Unix expression in and get the behaviour you expected:

import cron from "node-cron";
cron.schedule("*/5 * * * * *", () => poll());

APScheduler — keyword fields rather than a positional string, which sidesteps the miscounting problem entirely:

from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger

CronTrigger(second="*/5")        # every 5 seconds
IntervalTrigger(seconds=5)       # usually what you actually want

tokio-cron-scheduler (6 fields) and the Rust cron crate (7 fields — seconds and a trailing year) are covered in depth in the Rust cron guide. The field-count difference between them is a live porting bug: */5 * * * * * is every 5 seconds in one and invalid in the other.

Go’s robfig/cron is opt-in. By default it parses 5 fields; you get seconds only if you construct the scheduler with cron.New(cron.WithSeconds()). Forgetting that turns “every 5 seconds” into “every 5 minutes” with no error.

Three ways out

If your runtime is on the “no” side of the matrix, you have three options, roughly in order of how much we’d recommend them.

1. A fixed-rate loop in a long-lived process. This is the right answer far more often than a sub-minute cron expression is. You aren’t scheduling against the wall clock, you’re polling — so express it as polling:

while True:
    do_work()
    time.sleep(5)
let mut ticker = tokio::time::interval(Duration::from_secs(5));
loop {
    ticker.tick().await;
    do_work().await;
}

Run it under systemd, supervisor, or as a Kubernetes Deployment. No cron parser, no field-count bug, and restart semantics you control. On Kubernetes specifically this is strictly better than a CronJob would be even if CronJob could do seconds — spawning a pod every five seconds costs far more than the work inside it.

2. A seconds-aware library inside your app. If you genuinely need clock alignment — “on second 0, 15, 30, 45” rather than “every 15 seconds from whenever we started” — use the dialect for your language from the section above. Alignment matters when multiple services must act in the same window, or when a downstream system buckets by wall-clock time.

3. The sleep-offset hack, if you’re stuck on plain crontab. Covered below, with its caveats.

The sleep-offset hack

The classic crontab workaround: schedule the job every minute N times, offsetting each copy with sleep.

# Every 30 seconds
* * * * * /usr/local/bin/job
* * * * * sleep 30; /usr/local/bin/job

# Every 15 seconds
* * * * * /usr/local/bin/job
* * * * * sleep 15; /usr/local/bin/job
* * * * * sleep 30; /usr/local/bin/job
* * * * * sleep 45; /usr/local/bin/job

It works, and for a low-stakes 30-second poll on a box you don’t control it’s a reasonable pragmatic answer. Know what you’re accepting:

  • The offset is approximate. sleep 30 starts when cron forks the job, not at the top of the minute. Under load, the second copy drifts.
  • Copies can overlap. Each entry is independent — nothing stops the sleep 30 run from starting while the on-the-minute run is still going. Add flock if that matters: * * * * * flock -n /tmp/job.lock /usr/local/bin/job.
  • It scales badly. Every-5-seconds means twelve crontab lines, eleven of which are sleeping processes. At that point you have written a loop, just distributed across a config file.
  • Logging gets confusing. Twelve entries produce twelve mail/syslog streams for one logical job.

If you find yourself writing more than two offset lines, switch to option 1.

Why only some intervals work

Even once you’re on a seconds-aware dialect, not every “every N seconds” is expressible in one line — and the rule for which ones are is pure arithmetic. A single */N expression only gives a steady interval when N divides 60 evenly.

The step operator */N in a 0–59 field means “start at 0, keep adding N while still in range.” So */45 in the seconds field produces 0, 45, then 90 — out of range — and stops. Next minute it resets to 0 and repeats. The result is firings at :00 and :45 with gaps that alternate 45s, 15s, 45s, 15s: not an interval, a stutter. Cron matches fields against the wall clock and keeps no memory of when it last ran, so it cannot carry a leftover offset across the minute boundary.

The values of N that do work are exactly the divisors of 60:

1  2  3  4  5  6  10  12  15  20  30  60

Every one of those tiles the minute cleanly — */15 gives 0, 15, 30, 45 and wraps with a perfect 15-second gap. Everything else (7, 8, 9, 11, 13, 14, 16, 25, 35, 45, 50 …) leaves a remainder and stutters.

For a non-divisor you have two honest options:

  • Enumerate the cycle. The schedule “closes” over the least common multiple of N and 60. For 45 seconds that’s 180 seconds — a 3-minute cycle spread across four schedule entries. The worked example is on the every 45 seconds page; the same trap one unit up is every 45 minutes.
  • Use an interval timer. fixedDelay, tokio::time::interval, setInterval — none of them care whether N divides 60, because they count elapsed time rather than matching clock fields. This is almost always the better answer for a non-divisor interval, and it sidesteps the divisor rule entirely.

The takeaway: if the interval you want divides 60, a one-line */N is fine. If it doesn’t, reach for a timer before you reach for a multi-entry cron cycle.

The overlap problem

Sub-minute scheduling makes a latent bug urgent. At a daily cadence, a job that occasionally takes 90 seconds is fine. At a 5-second cadence, a job that occasionally takes 6 seconds means runs start stacking — and because each one is usually holding a connection or a lock, the failure mode is a slow pile-up rather than a clean error.

Every seconds-aware scheduler gives you a way to say “don’t start the next run until this one finishes,” and the names differ:

RuntimeNon-overlapping option
Spring@Scheduled(fixedDelay = 5000) — waits for completion
APSchedulermax_instances=1 (the default) plus coalesce=True
node-cronGuard with an in-flight flag; there is no built-in lock
Quartz@DisallowConcurrentExecution on the job class
tokio-cron-schedulerGuard with a Mutex or tokio::sync::Semaphore
Plain crontabflock -n /tmp/job.lock

The general rule: at sub-minute cadence prefer fixedDelay semantics (gap measured from the end of the last run) over fixedRate (gap measured from the start). fixedRate is what a cron expression gives you, and it’s the one that stacks. That’s one more reason the loop in option 1 — where the sleep naturally comes after the work — is usually the better shape for this kind of job.

Frequently asked questions

Can cron run every 5 seconds?
Not standard Unix cron. The classic 5-field crontab format has no seconds field, so its smallest interval is one minute (`* * * * *`). Sub-minute scheduling needs a dialect that added a seconds field — Quartz (`0/5 * * * * ?`), Spring's `@Scheduled` (`*/5 * * * * *`), node-cron, APScheduler, or tokio-cron-scheduler. On plain crontab you either use a fixed-rate loop in your application or the sleep-offset trick.
What is the minimum cron interval?
It depends entirely on the runtime, and the differences are larger than most people expect. Unix cron, Kubernetes CronJob, AWS EventBridge Scheduler and Cloudflare Workers all floor at **1 minute**. GitHub Actions floors at **5 minutes**. Vercel is **1 minute on Pro but once per day on Hobby**. Seconds-aware libraries — Quartz, Spring, node-cron, APScheduler, tokio-cron-scheduler — go down to **1 second**.
How do I run a script every 30 seconds with crontab?
The standard workaround is to schedule two entries every minute and delay the second one by 30 seconds: `* * * * * /path/to/job` and `* * * * * sleep 30; /path/to/job`. It works, but it is a hack — the offset drifts if the job is slow, the two entries can overlap, and you get no shared locking. For anything you care about, run a `while true; do job; sleep 30; done` loop under systemd or supervisor instead.
Does Kubernetes CronJob support seconds?
No. Kubernetes CronJob uses the standard 5-field format and inherits cron's one-minute floor — there is no seconds field and no sub-minute option. If you need faster than one minute in Kubernetes, run a long-lived Deployment with an internal ticker rather than a CronJob. Spawning a pod every few seconds would also be far more scheduling overhead than the work itself.
Why does my systemd timer with OnUnitActiveSec=10s fire late?
Because `AccuracySec` defaults to **1 minute**. systemd deliberately coalesces timer wakeups to save power, placing each firing at a randomized point inside that accuracy window — so a 10-second timer can drift by up to a minute. Set `AccuracySec=1s` (or `1us` for the tightest scheduling) in the `[Timer]` section to get the precision you asked for. This catches almost everyone the first time.
Should I use a cron expression or a fixed-rate loop for sub-minute work?
A loop, almost always. Cron expressions describe wall-clock alignment — "at second 0, 5, 10…" — which matters for a nightly report and rarely matters for a 5-second poll. A `fixedRate`/`interval` loop is simpler, has no parser to misread, and lets you choose whether the delay starts before or after the work runs. Reach for a sub-minute cron expression only when you genuinely need the firings aligned to the clock.