---
title: "Timers and replay"
url: https://services.dev/docs/service/timers
captured: [time-three-ways.txt, time-tick.txt, time-timers.txt]
---
# Timers and replay

URL: https://services.dev/docs/service/timers

> Every kind of time compiles to one timer on the record, keyed so a replay arms it again and no move of a date rewrites it. It is armed, fired, superseded or held, re-armed only by a closed list of events, committed once however often it is woken, and pinned with the bundle it was armed under.

A timer answers one question: what is owed, and has it run? Every kind of time on [Keeping time](https://services.dev/docs/service/time.md) compiles to one, `tmr_…`, on the record. Each fires once for each time it is owed, in [the tick's](https://services.dev/docs/service/time.md#the-tick) order, under the version it was armed with, and the same way on a test clock as on the brand's own.

## Every timer is on the record

| A timer | Holds |
|---|---|
| `key` | what it is, by [the grammar below](#the-key): `clk_8Tx1#US:rung:notice 90d:2027@0` |
| `status` | `armed`, `fired`, `superseded` or `held`: [States](#states) |
| `place` | its kind's place in the tick, 1 to 8 |
| `owed_at`, `armed_at`, `fired_at`, `late_by` | when it was due, armed and run, and the gap |
| `attempt`, `wakes` | how many times it proposed, and how many times it was delivered |
| `version` | the bundle it was armed under: [Across versions](#across-versions) |
| `commit` | the commit it made under its key; every later wake answers `replayed: true` with that commit |

Every job, clock and subscription carries `next_timer`. `GET /timers?on=&status=`, `timers list --on`, `list_timers` and `timers.list()` read them. Acme's 2027 return, the day after it is minted:

```bash
npx services.dev timers list --on job_4Tx9 --test-clock tc_7Hq4
```

```txt title="captured/time-timers.txt · magic values: none"
job_4Tx9 · annual-return · period 2027 · on 2027-06 · at 2028-02-02 14:00Z · UTC · 4 fired · 1 held · 48 armed, the next 6 below
  id        status  owed               fired              place  key
  tmr_9Tq1  fired   2028-02-01 14:00Z  2028-02-01 14:00Z  2      sbn_7Hq2:schedule:annual:2027
  tmr_9Tq3  fired   2028-01-19 14:00Z  2028-02-01 14:00Z  6      clk_8Tx1#US-CA:rung:notice 90d:2027@0
  tmr_9Tq5  fired   2028-01-19 14:00Z  2028-02-01 14:00Z  6      clk_8Tx1#US-NY:rung:notice 90d:2027@0
  tmr_9Tq2  fired   2028-01-19 14:00Z  2028-02-01 14:00Z  6      clk_8Tx1#US:rung:notice 90d:2027@0
  tmr_9Tq6  held    —                  —                  5      job_4Tx9:deadline:at_risk:2027 · held since 2028-02-01 14:00Z by ask_9Ts4 · 54d 10h 30m remaining
  tmr_9Tq4  armed   2028-02-15 14:00Z  —                  6      clk_8Tx1#US-NJ:rung:notice 90d:2027@0
  tmr_9Tq7  armed   2028-02-15 14:00Z  —                  6      clk_8Tx1#US-TX:rung:notice 90d:2027@0
  tmr_9Tq9  armed   2028-03-19 13:00Z  —                  6      clk_8Tx1#US-CA:rung:notice 30d:2027@0
  tmr_9Tqb  armed   2028-03-19 13:00Z  —                  6      clk_8Tx1#US-NY:rung:notice 30d:2027@0
  tmr_9Tq8  armed   2028-03-19 13:00Z  —                  6      clk_8Tx1#US:rung:notice 30d:2027@0
  tmr_9Tqc  armed   2028-04-04 13:00Z  —                  6      clk_8Tx2:rung:notice 14d:2027@0
  next_timer  tmr_9Tq4 · 2028-02-15 14:00Z · the New Jersey notice, with Texas's at the same instant
```

One timer, whole: woken twice after a restart, committed once:

```txt
→ GET /timers/tmr_9Tq2
```

```json title="captured/time-timers.txt · 200 · tmr_9Tq2 · fired · magic values: none"
{
  "id": "tmr_9Tq2",
  "object": "timer",
  "key": "clk_8Tx1#US:rung:notice 90d:2027@0",
  "on": "clk_8Tx1#US",
  "kind": "clock",
  "place": 6,
  "status": "fired",
  "version": "2027-06",
  "owed_at": "2028-01-19T14:00:00.000Z",
  "armed_at": "2028-02-01T14:00:00.000Z",
  "fired_at": "2028-02-01T14:00:00.000Z",
  "late_by": null,
  "attempt": 1,
  "wakes": 2,
  "commit": "evt_9Tr1",
  "superseded_by": null,
  "livemode": false
}
```

## The key

A key is `<owner>:<kind>:<name>:<occurrence>[@<move>]`. The occurrence is named by itself, never by its current date, so a date that moves or rolls never changes a key, and a replay arms the same keys:

| Part | Is | apis.tax's |
|---|---|---|
| owner | the job, the clock occurrence or the subscription the timer belongs to | `job_4Tx9`, `clk_8Tx1#US`, `sbn_7Hq2` |
| kind | `cycle`, `schedule`, `ask`, `task`, `deadline`, `rung`, `due`, `window` or `money` | `rung` |
| name | the rung, the deadline's point, the schedule or the step | `notice 90d`, `at_risk`, `annual` |
| occurrence | the period, the item or the cycle, by name | `2027`, `US-NY`, `#3` |
| `@<move>` | how many times the clock has moved, on a notice, escalate, ack or due timer | `@0`, then `@1` after the extension |

A `run` rung leaves out `@<move>`, so a move can never arm a second extension. Keys compare byte by byte, which settles the tick's last tie.

## States

| Status | Holds | The runtime |
|---|---|---|
| `armed` | `owed_at` | fires at its owed time, in its place |
| `fired` | `fired_at`, `commit` | done: every later wake answers `replayed: true` with the same commit |
| `superseded` | `superseded_by`, `by` | replaced before it fired: a move, a repin or a policy change armed the successor `superseded_by` names |
| `held` | `owed_at: null`, `held_since`, `held_by`, `remaining` | paused while the job waits on a party: the time left is kept, not spent |

A held timer resumes under its own key at `resumed_at + remaining`. A date the world sets never waits: clock rungs are never held, and a deadline that follows a clock never moves its `late_at`. Only its at-risk timer is held, and it re-arms at the earlier of `resumed_at + remaining` and `late_at`; one whose time passed while it was held fires at resume, in place. Acme's at-risk timer, held from the mint by the organizer and resumed when the books arrive:

```txt
→ GET /timers/tmr_9Tq6
```

```json title="captured/time-timers.txt · 200 · tmr_9Tq6 · armed · magic values: none"
{
  "id": "tmr_9Tq6",
  "object": "timer",
  "key": "job_4Tx9:deadline:at_risk:2027",
  "kind": "deadline",
  "place": 5,
  "status": "armed",
  "held_since": "2028-02-01T14:00:00.000Z",
  "held_by": "ask_9Ts4",
  "resumed_at": "2028-02-05T14:00:00.000Z",
  "remaining": "54d 10h 30m",
  "owed_at": "2028-03-31T00:30:00.000Z",
  "late_at": "2028-04-14T04:00:00.000Z"
}
```

A [guarantee](https://services.dev/docs/service/clocks.md#price-a-missed-date) with `excludes: 'held'` reads the same state: time held on the client is never the brand's to pay for. A [deadline](https://services.dev/docs/service/within.md) pauses the same way.

## What re-arms a timer

An armed timer re-arms only on one of these, recorded with what caused it:

| On | The runtime | For example |
|---|---|---|
| `clock.moved` | supersedes each notice, escalate, ack and due timer still ahead and arms its successor at `@n+1`; the `run` timer keeps its key | the extension moves the federal date to 2028-10-16, and `notice 30d:2027@1` rings on 2028-09-16 |
| `policy.set` | supersedes a `silent` timer and re-arms it from the last activity, with the new span | api.forsale's agent moves `stall_days` from 14 to 7: a deal whose account last replied on 3 November goes `gone_dark` on 10 November, not 17 |
| a hold, and its resume | holds and resumes as [States](#states) says | the organizer waits on the books |
| `calendar.postpone` | moves each clock it names, which re-arms as `clock.moved` | a disaster relief notice |
| an edit to the calendar's `closed` days | re-rolls every date it touches: the calendar is never pinned | a closed day the IRS declares in March |
| a commit that changes what a `due` read | runs `due` again; a new answer is `clock.moved { by: 'due' }` | a signed Form 872 sets `statute_extended_to`, and the assessment and refund dates move to it |
| `clock.repinned` | re-arms the clock's timers under the newer bundle, superseding with `by: 'repin'` | a 2027-06 return joins Acme's subject |
| `subscription.update` | re-arms the next mint with the new input; jobs already minted keep theirs | Acme adds Texas in March 2027 |

Nothing else re-arms a timer; a deploy never does.

## After a gap

After an outage, a deploy or a year's advance, every timer owed in the gap fires in the tick's order, each kind by its rule:

| Kind | After a gap, the runtime |
|---|---|
| a schedule | mints each missed occurrence as its own period, and `period_taken` keeps each one once; [`catch_up`](https://services.dev/docs/service/subscriptions.md#after-a-gap) can mint only the latest |
| clock rungs | sends only the latest owed notice or escalate rung, and records the earlier ones `superseded`; a `run` rung runs once |
| `ack daily` | records each missed day |
| a deadline | records at risk, then late, in order |
| money | collects every owed line, in order |

A timer more than 5 minutes late lists on the owner console with its `late_by`. One armed after its day fires at once and is never late.

## On a test clock

A test clock, `tc_…`, is frozen at a time and moves only forward:

| Call | Answers |
|---|---|
| `POST /test_clocks/:id/advance { to }` | 202 `advancing`, then `test_clock.ready` on the feed once every timer owed by then has fired |
| `advance { to, redeliver: true }` | the same, with every wake delivered twice: each second one answers `replayed: true`, and nothing outside is sent twice |
| an advance backward | 409 `test_clock_backward`, with the call that creates a clock there |
| `DELETE /test_clocks/:id` | voids every job on it |
| `tick 1y`, `tick --to <time>` | the CLI's shorthand; `--trace` prints each pass, what fired and what it armed, `--compare` runs the span every way, and `--redeliver` adds a run with every wake twice |

A week of one brand's time:

```bash
npx services.dev tick --to 2026-12-01T09:00Z --test-clock tc_7Hq3 --trace
```

```txt title="captured/time-tick.txt · magic values: client.email = silent@example.test, metadata.sandbox.ai = down"
tc_7Hq3 · 2026-11-24 09:00:00Z → 2026-12-01 09:00:00Z · UTC · 10 fired · 0 held · each instant in place order
  pass  owed                  fired                 place  kind      on        what
  1     2026-11-24 14:02:11Z  2026-11-24 14:02:11Z  3      ask       job_7Hq7  glossary · reminded 1d before expiry
  1     2026-11-25 14:02:11Z  2026-11-25 14:02:11Z  1      cycle     sbn_7Hq2  renewed · cycle 2 · $149.00 posted as owed · allowances reset, 0 of 4
  1     2026-11-25 14:02:11Z  2026-11-25 14:02:11Z  3      ask       job_7Hq7  glossary · expired · opens once more, 7d
  1     2026-11-25 14:02:11Z  2026-11-25 14:02:11Z  8      money     sbn_7Hq2  cycle 2 · $149.00 collected
  1     2026-11-26 00:00:00Z  2026-11-26 00:00:00Z  6      clock     clk_7Hq2  file · run extend 3d → extend committed · moved 2026-11-29 → 2026-12-13
  1     2026-11-28 09:00:00Z  2026-11-28 09:00:00Z  5      deadline  job_7Hq8  at risk · translate escalates to reviewer
  1     2026-11-28 15:00:00Z  2026-11-28 15:00:00Z  5      deadline  job_7Hq8  late · 20% off: the charge falls to $63.20
  1     2026-12-01 00:00:00Z  2026-12-01 00:00:00Z  2      schedule  sbn_7Hq2  monthly → job_7Hq9 · $0.00 · 1 of 4
  1     2026-12-01 00:00:00Z  2026-12-01 00:00:00Z  7      window    job_7Hq6  final · 7d after delivery · $49.00 final
  1     2026-12-01 00:00:00Z  2026-12-01 00:00:00Z  8      money     job_7Hq6  take settled · you $48.16
```

Advancing to where it already is fires nothing. Live, the same timers fire on the brand's clock, each event with `owed`, `fired` and `late_by`.

## Delivered at least once

A wake comes at least once, so the runtime makes a second one harmless. The gate commits each key once: a second wake of a fired timer answers `replayed: true` with the first commit, and counts in `wakes`. A step that sends to an outside system carries one submission id per attempt, and one whose send crashed before its receipt was recorded asks the system before it sends again: [a send in doubt](https://services.dev/docs/service/submit.md#a-send-in-doubt).

## The acceptance test

`tick 1y --compare --redeliver` runs the Tax Plan's year four ways: one advance, 365 daily ones, wall time, and one advance with every wake twice. The sha256 is over the record in order, each event's type, subject, owed time, key and data. Fired time, `late_by`, `attempt` and `wakes` are left out, since they differ by way. All four agree, and the run states its three invariants:

```bash
npx services.dev tick 1y --compare --redeliver   # the Tax Plan's year, 2027-01-20 14:00Z to 2028-01-20 14:00Z, run four ways: the hash is over each event's type, subject, owed time, key and data, in order
```

```txt title="captured/time-three-ways.txt · magic values: owner.email = signs@example.test, metadata.sandbox.asks = {"organizer.books":"after 160d","organizer.assets":"waive","organizer #2":"after 2d"}, metadata.sandbox.outside = {"reject":{"file.US-NY":["ein_mismatch"]}}, metadata.sandbox.events = { notice.received: { at: 2027-11-14T15:00:00Z, data: { notice, reply_by } } }"
  one advance of 1y on a test clock                           295 events · sha256:f6dbf921ab734154…
  365 advances of 1d on a test clock                          295 events · sha256:f6dbf921ab734154…
  a year of wall time, the brand's own clock, one alarm wake  295 events · sha256:f6dbf921ab734154…
  one advance, every wake delivered twice                     295 events · sha256:f6dbf921ab734154… · 214 replayed: true · 0 submissions sent twice

  identical: the same seeds and clocks give the same record, event for event
  0 clock events early · 0 keys committed twice · 0 armed timers lost
```

## Across versions

A version is a bundle: the Service file and every module it imports, `lib/tax.ts`, `rules.ts`, the Pool and the Services it orders with it, printed `2027-06 · sha256:c71e44b2…`. What a version pins, what counts as breaking, and moving jobs in flight are on [Versions](https://services.dev/docs/operate/versions.md); this section is what each timer does across them.

| What | Runs on |
|---|---|
| a job, its clocks and its armed timers | the bundle it opened under; a deploy never rewrites an armed timer |
| a minted job | the bundle live at that mint |
| a clock with `lives_on: 'subject'` | its job's bundle, until a newer job of the Service joins the subject: it repins, recording `clock.repinned` |
| a version with armed timers | stays runnable until its last timer closes; `GET /versions?pinned=true` lists each one |
| a rollback | arms nothing and rewrites nothing: each timer keeps the version it was armed under |
| a repin you choose | `POST /owner/jobs/:id/repin { to, dry_run }` prints the diff, timer by timer, then supersedes each changed timer `by: 'repin'` |

In June 2027 the tax brand adds a 90-day notice, shortens an ack and changes `lateFiling` in `lib/tax.ts`. The 2026 return, opened in February, keeps its rungs and its lib:

```bash
npx services.dev check --against live services/annual-return.ts
```

```txt title="magic values: none"
services/annual-return.ts · against live 2027-01 · sha256:3f9a0c1d… · deploys as 2027-06 · sha256:c71e44b2…
  bundle  the Service file and every module it imports: lib/tax.ts, rules.ts, pools/preparers.ts, services/amended-return.ts, services/carryforward-watch.ts
  rungs   clocks.file: + notice 90d · ack daily 7d → ack daily 5d
  lib     lib/tax.ts: lateFiling changed · fileDue, extendedDue, payDue and refundCloses unchanged
  armed   job_4Tx3 · 38 armed timers on 2027-01 · they keep it, rungs and lib/tax.ts both
  pinned  2027-01 stays runnable until its last timer closes · a rollback to it arms nothing and rewrites nothing
  next    jobs opened and minted from the deploy run 2027-06 · a subject clock repins when a 2027-06 job joins its subject
```

A repin the owner chooses, tried first:

```txt
→ POST /owner/jobs/job_4Tx3/repin { "to": "2027-06", "dry_run": true }
```

```txt title="captured/time-timers.txt · 200 · dry run · job_4Tx3 · 2027-01 → 2027-06 · 12 timers differ · nothing committed · magic values: none"
  arm        clk_7Hq9#US:rung:notice 90d:2026@1     2027-07-17 13:00Z
  arm        clk_7Hq9#US-NY:rung:notice 90d:2026@1  2027-07-17 13:00Z
  arm        clk_7Hq9#US-CA:rung:notice 90d:2026@1  2027-08-17 13:00Z
  arm        clk_7Hq9#US-NJ:rung:notice 90d:2026@1  2027-08-17 13:00Z
  supersede  8 timers · ack daily, the 7th and 6th days out, on each jurisdiction · by: repin
  keep       26 timers · the same key and owed time on both versions
  next       POST /owner/jobs/job_4Tx3/repin { "to": "2027-06" } · records clock.repinned on each clock
```

Every version still running a timer:

```txt
→ GET /versions?pinned=true
```

```txt title="200 · 2 versions · each runnable until its last timer closes · magic values: none"
  2027-06  sha256:c71e44b2…  live · 1,412 armed timers on 61 subjects
  2026-01  sha256:9d2c7a50…  1 clock · expiring on sbj_7Hr3, to 2045-12-31 · its client left before a newer return joined
  retired  2027-01 on 2028-02-01, when Acme's 2027 return joined its subject and its last clocks repinned
```

---

- Up: [Service()](https://services.dev/docs/service.md)
- Previous: [Keeping time](https://services.dev/docs/service/time.md)
- Next: [Deadlines](https://services.dev/docs/service/within.md)
