Skip to content
HackHourHQ

HH-0012 · Rev A ·

Make a systemd timer catch up on runs it missed while the machine was off (Persistent=true)

Kip CroninPublished ~4 min read

Add Persistent=true to the [Timer] section. When the timer starts at the next boot, systemd runs the job once straight away if a scheduled run fell due while the machine was off. It only works with OnCalendar= timers. Our laptop server was switched off for two hours, missed nine slots of a 15-minute timer, and caught up with one run as soon as the timer started at boot.

This is part of turning an old laptop into a 24/7 Ubuntu server, where the box will sometimes be off when a job is due.

The fix

This is the test timer on our laptop, a user timer that runs a one-line job every 15 minutes, and the service it starts:

# daily-job.timer
[Unit]
Description=Run daily-job every 15 minutes

[Timer]
OnCalendar=*:0/15
Persistent=true

[Install]
WantedBy=timers.target
# daily-job.service
[Unit]
Description=Daily job

[Service]
Type=oneshot
ExecStart=/bin/echo "daily-job ran"

Put both in ~/.config/systemd/user/, then:

systemctl --user daemon-reload
systemctl --user enable --now daily-job.timer

For a system timer it's the same [Timer] section, in /etc/systemd/system/, with sudo systemctl. The man page says that with Persistent=true "the time when the service unit was last triggered is stored on disk", and the service is "triggered immediately if it would have been triggered at least once during the time when the timer was inactive" (systemd.timer(5)). For a user timer, the stamp file lives in ~/.local/share/systemd/timers/. Ours was last touched by the catch-up run below:

-rw-r--r-- 1 x1 x1 0 2026-10-14 22:50:05.077683000 +0300 /home/x1/.local/share/systemd/timers/stamp-daily-job.timer

What happened: off for two hours

The timer's last normal run was at 20:30 on 14 October. The laptop shut down at 20:41 and its next boot started at 22:49:57. That's nine 15-minute slots with the machine off, from 20:45 to 22:45. Here's the journal across that gap:

journalctl --user -u daily-job.timer -u daily-job.service -o short-iso
2026-10-14T20:30:06+03:00 x1 systemd[1651]: Starting daily-job.service - Daily job...
2026-10-14T20:30:06+03:00 x1 echo[26649]: daily-job ran
2026-10-14T20:30:06+03:00 x1 systemd[1651]: Finished daily-job.service - Daily job.
2026-10-14T20:41:27+03:00 x1 systemd[1651]: Stopped daily-job.timer - Run daily-job every 15 minutes.
-- Boot 55bb0af46fa54ec881f2777779a77f6c --
2026-10-14T22:50:05+03:00 x1 systemd[1650]: Started daily-job.timer - Run daily-job every 15 minutes.
2026-10-14T22:50:05+03:00 x1 systemd[1650]: Starting daily-job.service - Daily job...
2026-10-14T22:50:05+03:00 x1 systemd[1650]: Finished daily-job.service - Daily job.
2026-10-14T22:50:07+03:00 x1 echo[1748]: daily-job ran

One catch-up run for nine missed slots, in the same second the timer started, which is what the man page promises. After that the timer goes back to its calendar, where the next slot is 23:00.

We didn't run a twin timer without Persistent= alongside it. Going by the man page, that one would simply have waited for 23:00.

Catch-up isn't success

The catch-up run happens as early as the timer can start, and that can be before the network is up. On this boot the job ran at 22:50:05 and the Wi-Fi connected three seconds later:

2026-10-14T22:50:08+03:00 x1 NetworkManager[1082]: <info>  [1792007408.8464] device (wlp0s20f3): state change: secondaries -> activated (reason 'none', sys-iface-state: 'managed')

An echo doesn't care. A job that calls a web API does. Earlier that day, after a five-day power-off, another daily job on this laptop made its catch-up run at 14:52:57 and failed. Its log ends with a DNS error:

requests.exceptions.ConnectionError: HTTPSConnectionPool(host='<api host>', port=443): Max retries exceeded with url: /v5/oauth/token (Caused by NameResolutionError("<url

The Wi-Fi only connected at 14:53:12:

2026-10-14T14:53:12+03:00 x1 NetworkManager[1102]: <info>  [1791978792.4864] device (wlp0s20f3): state change: secondaries -> activated (reason 'none', sys-iface-state: 'managed')

That job's service file has Wants= and After=network-online.target (the linger hack shows them), but that does nothing in a user service. The user manager has no such unit:

systemctl --user list-units --all network-online.target
● network-online.target not-found inactive dead network-online.target

systemd.special(7) lists network-online.target among the system manager's units, not the user manager's. If your catch-up job needs the network, either run it as a system service with User= and order it after network-online.target, or make the job itself retry until the network is up. We haven't tested either fix on this machine yet.

A wrong clock at boot changes the timing

An older boot of the same laptop, on 8 October, shows something else. That daily job runs at 09:00, and the laptop had been off since 19 May. At boot its clock was wrong, so systemd-timesyncd set it back to the last time it had recorded, the 19 May shutdown. Monotonic timestamps (seconds since boot) show the order:

[    7.863972] x1 systemd-timesyncd[718]: System clock time unset or jumped backwards, restored from recorded timestamp: Tue 2026-05-19 21:10:55 EEST
[   20.156197] x1 systemd[1661]: Started <our daily job>.timer.
[   49.195364] x1 systemd-timesyncd[718]: Initial clock synchronization to Thu 2026-10-08 21:48:23.404886 EEST.
[   49.199204] x1 systemd[1661]: Starting <our daily job>.service...

Unit name redacted; the rest is verbatim.

When the timer started, the clock said 21:11 on 19 May, and the last run had been at 09:00 that morning, so nothing looked missed. The job ran 4 milliseconds after the clock jumped to 8 October. So on a machine with a bad clock, the catch-up happens when the clock syncs, not at boot.

Suspend is a different case

Persistent= is about time the timer wasn't running, which in practice means power-off. For suspend, the man page says: "When a calendar timer elapses while the system is sleeping it will not be acted on immediately, but once the system is later resumed it will catch up". That happens with or without Persistent=, and again only once however many slots were missed. To make the timer wake the machine instead, there's WakeSystem=true, which only works for system timers and only if the hardware supports timed wake-up. Our laptop server has suspend masked, so we haven't tested this part.

Check yours

systemctl --user list-timers --all          # or sudo systemctl list-timers --all
journalctl --user -u myjob.service -o short-iso

LAST in list-timers shows when the job last ran. If you remove a persistent timer for good, run systemctl clean --what=state on it to delete the stored timestamp, as the man page recommends.

A timer that fires before anyone logs in only works if the user manager is running. That's the linger fix.

HackHour Weekly, every Wednesday

One email a week: the week's hack and what changed. Unsubscribe anytime.

What's in an issue

Sources

Platform facts in this guide link to these pages. Last checked .

  1. systemd.timer(5), Ubuntu Manpages (noble)
  2. systemd.special(7), Ubuntu Manpages (noble)

Revision history

RevDateChange
AFirst published.

Errata

No corrections so far. Spotted one? Tell us through the site chat.