How to Run Cron Every 30 Seconds (and When Not To)
Updated
Contents
Cron can't run a job every 30 seconds: its smallest unit is one minute, and a crontab line has no seconds field. The usual workaround is two crontab lines for the same command, one of them prefixed with sleep 30. A systemd timer with OnCalendar=*:*:0/30 and AccuracySec=1s, or a long-running worker with its own loop, does the same job with fewer surprises. Whichever you choose, decide what happens when one run takes longer than 30 seconds, because cron starts the next copy anyway.
This guide covers cronie (Fedora, RHEL and derivatives, Arch), Debian's cron (Debian, Ubuntu), BusyBox crond (Alpine), and systemd as documented in its current man pages (systemd 262, checked October 24, 2026). The five fields themselves are covered in cron expression syntax.
Cron Checks the Schedule Once a Minute#
All three daemons work in whole minutes. Cronie "checks each job to see if it needs to be run in the current minute" (cron(8)), Debian's cron "wakes up every minute" (cron(8), 3.0pl1-197), and BusyBox crond sleeps until the next minute boundary (crond.c). A schedule is five fields, minute through day of week, and the "sixth" field, "the rest of the line", is the command (crontab(5)).
So a six-field expression copied from Spring or Quartz isn't rejected. It's misread:
# Meant: every 30 seconds. Actually: minutes 0 and 30 of every hour, running "* /opt/app/poll.sh".
*/30 * * * * * /opt/app/poll.shThe leftover * becomes the first word of the command. The shell expands it to the file names in the user's home directory, where cronie starts every job (security.c), and treats the first one as the command, or fails if the directory is empty. Either way the line fires twice an hour and poll.sh is not the program it runs.
Cron's other gaps, from retries to timeouts, are covered in what a cron job is.
Five Ways to Run Work Every 30 Seconds#
The examples share one command, /opt/app/poll.sh, and keep lock files in /var/lib/app, which must be writable by the job's user.
Two Crontab Lines, One Delayed by sleep 30#
* * * * * /opt/app/poll.sh
* * * * * sleep 30; /opt/app/poll.shCron starts both lines at the top of every minute, and the second waits 30 seconds first. It works on every cron implementation and needs nothing installed. The cost: two entries that know nothing about each other, and no protection against overlap until you add the lock shown below. Every 15 seconds takes four lines, which is usually where people stop.
One Cron Job That Runs Twice#
One entry can run a script that does the work twice, timing the second run from when the first started rather than from when it finished:
#!/bin/sh
# /opt/app/poll-twice.sh: cron starts this every minute; it runs poll.sh twice, 30 seconds apart.
start=$(date +%s)
/opt/app/poll.sh
elapsed=$(( $(date +%s) - start ))
if [ "$elapsed" -lt 30 ]; then
sleep $(( 30 - elapsed ))
/opt/app/poll.sh
fi* * * * * flock -n /var/lib/app/queue-poll.lock /opt/app/poll-twice.shIf the first run takes 30 seconds or more, the script skips the second instead of starting it late. flock -n covers the rest: if the second run is still going when cron starts the next minute's copy, that copy exits at once with status 1 instead of running beside it (flock(1), util-linux).
A systemd Timer#
systemd timers accept seconds. A timer starts the service with the same name:
# /etc/systemd/system/queue-poll.timer
[Unit]
Description=Run queue-poll.service every 30 seconds
[Timer]
OnCalendar=*:*:0/30
AccuracySec=1s
[Install]
WantedBy=timers.target# /etc/systemd/system/queue-poll.service
[Unit]
Description=Poll the work queue
[Service]
Type=oneshot
User=app
ExecStart=/opt/app/poll.sh
TimeoutStartSec=25sEnable it with systemctl enable --now queue-poll.timer and check the next elapse with systemctl list-timers queue-poll.timer. *:*:0/30 means every hour and minute, at second 0 and every 30 seconds after it (systemd.time(7)). systemd-analyze calendar --iterations=4 '*:*:0/30' prints the next four times (--iterations needs systemd 242 or later; systemd-analyze(1)).
AccuracySec=1s is not optional. The default is one minute, and within that window systemd places each elapse at "a host-specific, randomized, but stable position that is synchronized between all local timer units" (systemd.timer(5)). The event loop does this by waking every timer it can at the same second of each minute, derived from the boot ID (sd-event.c), so a 30-second timer left at the default runs about once a minute. Set 1us for the best accuracy systemd offers.
A oneshot service has no time limit by default (systemd.service(5)). TimeoutStartSec=25s kills a run that hangs and marks the unit failed, so a stuck poll can't hold up the timer indefinitely.
OnUnitActiveSec=30s is the interval alternative: 30 seconds after the service last started, on the monotonic clock rather than at :00 and :30. On its own it never fires, because there is no last start until something starts the service (timer.c). The man page pairs it with OnBootSec=, which elapses at once if that time has already passed when the timer starts:
[Timer]
OnBootSec=30s
OnUnitActiveSec=30s
AccuracySec=1sEvery second is beyond what a timer should do. Start rate limiting applies to "all kinds of starts", timer-triggered ones included (systemd.unit(5)), and the default allows 5 starts in 10 seconds (systemd-system.conf(5)). A 1-second timer exceeds that, and systemd refuses the extra starts.
A Long-Running Worker#
For work that needs a steady sub-minute rhythm, skip the process start per run. Run one process that loops, and let systemd keep it alive:
#!/bin/sh
# /opt/app/worker.sh: run poll.sh at :00 and :30 of every minute until stopped.
while :; do
/opt/app/poll.sh
sleep $(( 30 - $(date +%s) % 30 ))
done# /etc/systemd/system/queue-worker.service
[Unit]
Description=Poll the work queue every 30 seconds
[Service]
User=app
ExecStart=/opt/app/worker.sh
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.targetThe sleep runs to the next 30-second boundary on the clock, so a 4-second poll is followed by a 26-second sleep and the rhythm never slides. Restart=always brings the worker back after a crash or an exit. Keep RestartSec=5s: the default is 100 ms, so a worker that crashes right after starting hits the 5-starts-in-10-seconds limit almost immediately, and systemd stops restarting it.
In an application, the same loop lives in your code, with a timeout on each unit of work. On Kubernetes, run it as a Deployment, because a CronJob can't go below a minute.
A Scheduler That Accepts Seconds#
Application schedulers often take a seconds field or a plain interval. Checked October 24, 2026:
| Scheduler | Every 30 seconds | When the previous run is still going |
|---|---|---|
| Quartz.NET 3.x | 0/30 * * * * ? (seconds first) |
Can overlap unless the job class has [DisallowConcurrentExecution] |
| Spring Framework 7.0 | @Scheduled(cron = "*/30 * * * * *") or fixedDelay = 30, timeUnit = TimeUnit.SECONDS |
A cron trigger computes the next run from the last completion, so "overlapping executions won't occur" (CronTrigger); fixedDelay waits 30 seconds after each run ends |
| Hangfire 1.8 | */30 * * * * * |
Enqueues each occurrence without checking the last one (RecurringJobScheduler.cs); it polls for due jobs every 15 seconds by default (DelayedJobScheduler.cs), so a run can be enqueued up to 15 seconds after its slot |
| node-cron 4.6.0 | cron.schedule('*/30 * * * * *', task) |
Overlaps unless you pass { noOverlap: true }, which skips the run |
| Celery 5.6 beat | 'schedule': 30.0 |
Tasks "may overlap if the first task doesn't complete before the next"; the docs suggest a lock |
Two common schedulers can't do it. A Kubernetes CronJob schedule has five fields with the minute first, and GitHub Actions says, as of the same date, "the shortest interval you can run scheduled workflows is once every 5 minutes", with runs that can be delayed or dropped under load.
What Happens When a Run Takes Longer Than 30 Seconds#
A sub-minute schedule leaves little slack. A poll that usually takes 3 seconds and occasionally waits 40 on a slow database runs into its next slot, and each approach handles that differently:
| Approach | Spacing between starts | When a run overruns its slot |
|---|---|---|
| Two crontab lines | :00 and :30, re-anchored by cron every minute | A new copy starts beside the running one. BusyBox won't restart a line that is still running, but the two lines still overlap each other |
poll-twice.sh with flock -n |
30 seconds after the first run's start | The second run is skipped, and the next minute's copy exits if the lock is held |
Timer with OnCalendar= |
:00 and :30 by the wall clock | No second copy. The missed slot runs as soon as the service finishes, unless DeferReactivation=yes (systemd 257+) makes it wait for the next slot |
Timer with OnUnitActiveSec= |
30 seconds after the last start | No second copy: a service that is still active is "simply left running" (systemd.timer(5)) |
| Worker loop | :00 and :30 by the wall clock | The next run waits for the following boundary, so runs can't overlap |
BusyBox's behavior is in crond.c, which logs "process already running" and skips the line.
Why the sleep 30 Trick Doubles Up Under Load#
Cronie and Debian's cron start every matching line each minute without checking whether its last copy finished (cronie job.c). With two lines, a slot comes up every 30 seconds, so any run longer than 30 seconds overlaps the next. Load makes it worse: the copies compete for the same CPU, database, and rows, each one runs slower, and two copies become three. Put both lines behind one lock so a slow run costs a slot instead:
* * * * * flock -n /var/lib/app/queue-poll.lock /opt/app/poll.sh
* * * * * sleep 30; flock -n /var/lib/app/queue-poll.lock /opt/app/poll.shKeep sleep 30 outside flock. Inside it, the sleeping copy would hold the lock for half a minute, and the :00 line would often find it taken.
Why Fixed Sleeps Drift#
sleep 30 counts from when the sleep starts, not from when the last run started. A loop such as while :; do /opt/app/poll.sh; sleep 30; done runs every 30 seconds plus however long the poll took: a 6-second poll means a run every 36 seconds, sliding further with every iteration. The two-line crontab doesn't accumulate this, because cron re-anchors both lines at the top of each minute. The fix is the one worker.sh uses: sleep to the next boundary with sleep $(( 30 - $(date +%s) % 30 )), so a slow run shortens the next sleep instead of pushing every later run back.
Locks and time limits apply to every scheduled job, not just fast ones; preventing errors in scheduled automation covers both.
When Every 30 Seconds Is the Wrong Tool#
A 30-second schedule usually means "check whether there's anything to do." When the check is for new work, the schedule adds up to 30 seconds of latency, and most runs find nothing. The system producing the work can usually tell you directly:
- Polling a queue: run a consumer. Amazon SQS long polling holds a
ReceiveMessagecall open for up to 20 seconds and answers as soon as a message is available (SQS docs). - Watching a database table: PostgreSQL's
NOTIFYdelivers an event to every session that ranLISTENon the channel (PostgreSQL 18), so a worker can wait for the writer to notify it, with a slow poll as a backstop. - Waiting for files: a systemd path unit starts a service when a directory gains a file (
DirectoryNotEmpty=) or a file is written and closed (PathChanged=) (systemd.path(5)). - Reacting to another service: if it can send a webhook, receive the webhook.
Sub-minute schedules fit work that really is driven by time: sampling a value, refreshing a cache with a fixed staleness budget, expiring sessions. Even then, ask whether once a minute would do. It gives you all of cron's tooling back.
Monitoring Work That Runs Every 30 Seconds#
CronRadar accepts standard five-field schedules only, so it can't be told to expect a run every 30 seconds. A check-in carrying */30 * * * * * is rejected with 400 INVALID_REQUEST (schedule syntax). A check-in written to ignore errors, as it should be, ignores that one too, so the monitor is never created and nothing tells you. The workable setup is per minute: schedule * * * * *, at most one heartbeat a minute after successful work, and a grace period.
There's no CronRadar SDK for cron or systemd and no auto-discovery of crontab entries or timers, so the job reports with its own HTTP check-in. This script sends at most one heartbeat per clock minute, however often it's called:
#!/bin/sh
# /opt/app/heartbeat.sh: report successful polling to CronRadar, at most once per clock minute.
# systemd sets the key from EnvironmentFile=; under cron, read it from a file only the job's account can read.
: "${CRONRADAR_API_KEY:=$(cat /etc/cronradar/api-key 2>/dev/null)}"
stamp=/var/lib/app/queue-poll.heartbeat
minute=$(( $(date +%s) / 60 ))
[ "$(cat "$stamp" 2>/dev/null)" = "$minute" ] && exit 0
echo "$minute" > "$stamp"
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=* * * * *" \
--data-urlencode "gracePeriod=120" \
"https://cron.life/api/v1/ping/queue-poll" || trueCall it after each successful run:
- Crontab lines,
poll-twice.sh, andworker.sh: change each/opt/app/poll.shto/opt/app/poll.sh && /opt/app/heartbeat.sh. Under cron, the script reads the key from/etc/cronradar/api-keyitself, so the key stays out of the crontab, where cronie and Debian's cron would copy it into the headers of any mail they send (cron "No MTA installed"). - systemd timer: add
ExecStart=/opt/app/heartbeat.shafter the poll line. A oneshot service runs itsExecStart=lines in order and stops at the first one that fails, so the heartbeat only follows a successful poll. - Both systemd units: load the key with
EnvironmentFile=from a file only root can read.
The first check-in creates the monitor, and because each one carries the schedule, a deleted monitor is recreated by the next. --max-time 5 and || true keep an unreachable CronRadar from stalling or failing the poll.
What this catches, and what it doesn't:
- Stopped work. Each heartbeat moves the expected time to the next minute. If none arrives, the monitor goes
warning, thencriticaland alerts once the 120-second grace period runs out. That covers a dead worker, a disabled timer, an overwritten crontab, a host that's down, and a poll that fails every time. The grace lets one bad minute pass without an alert; the minimum is 60 seconds and the default 90. - Not single slots. One skipped 30-second run is invisible at minute granularity. A heartbeat-only check-in also records no duration and can't flag a stuck run; a hung poll shows up as missing heartbeats, and
TimeoutStartSec=ortimeoutturns the hang into a failure sooner. - Not each failure. You could send
/failfrom a failed poll, and CronRadar alerts on afailimmediately. For work that tries again 30 seconds later, one failure is usually noise.
* * * * * matches every minute in every time zone, so CronRadar's rule that schedules are read in UTC changes nothing. A once-a-minute schedule puts the monitor in the Fast price class (pricing). Check-ins also count against a limit of 60 requests a minute per API key, which is another reason to send one heartbeat a minute instead of a start and complete for every 30-second run. The latter is four requests a minute for the same detection.
Hangfire and Quartz.NET jobs on seconds schedules need the same treatment. CronRadar's schedule docs say a framework integration logs a schedule that runs more than once a minute and leaves that job unmonitored, so report from inside the job, at most once a minute, with * * * * *.
Frequently Asked Questions#
Can cron run a job every 30 seconds?#
Not directly, because cron's smallest unit is one minute. Add a second crontab line that runs sleep 30 before the command, or have one entry run a script that does the work twice, 30 seconds apart. Put them behind the same flock -n lock so a slow run is skipped instead of overlapping the next one.
Can crontab use seconds?#
No. A crontab line has five time fields, minute through day of week, on cronie, Debian's cron, and BusyBox crond. A six-field expression from Spring or Quartz is read as five fields plus a command that starts with *, so it runs at the wrong times and runs the wrong thing. Seconds work only in schedulers built for them, such as systemd timers, Quartz, Spring, Hangfire, and node-cron.
How do I run a script every 10 seconds on Linux?#
Use a systemd timer with OnCalendar=*:*:0/10 and AccuracySec=1s, or a worker loop that ends each pass with sleep $(( 10 - $(date +%s) % 10 )). With cron, it takes six lines with staggered sleeps. For every second, use the worker: a timer that starts a service each second exceeds systemd's default limit of 5 starts in 10 seconds.
Is a systemd timer better than cron for sub-minute jobs?#
Usually. It takes seconds in OnCalendar=, never starts a second copy of a service that's still running, and can cap each run with TimeoutStartSec=. Lower AccuracySec= from its 1-minute default, or a 30-second timer runs about once a minute. For a fixed rhythm without a process start per run, a long-running worker is better still.
How do I monitor a job that runs every 30 seconds?#
Monitor it per minute. CronRadar accepts only five-field schedules, so send a heartbeat at most once a minute after successful work, with schedule * * * * * and a grace period such as 120 seconds. That alerts when the work stops for longer than the grace period, such as a dead worker, a disabled timer, or a host that's down, but not when a single 30-second run is skipped.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.