Temporal Entity Workflows

Your Timers Don't Survive ContinueAsNew

  ·  5 min read

When a Temporal workflow run ends via ContinueAsNew, the new run starts with a fresh event history and no carry-over from the previous run. That includes timers. Any timer that was pending in the old run is cancelled. The new run has no knowledge it existed.

For a short workflow, this is rarely a problem. For an entity workflow that tracks time-sensitive business state, it can silently break logic that relies on duration across run boundaries.

The full implementation is at rikdc/temporal-entity-workflow-demo.

The Problem #

The rewards workflow resets a customer’s points to zero after 365 days of inactivity. The implementation uses a durable timer: if no add-points signal arrives within the window, the timer fires and points are cleared. Every time a signal does arrive, the timer is cancelled and a new one is started from zero.

The original code initialised the timer once at the start of the run:

timerCtx, cancelTimer := workflow.WithCancel(ctx)
inactivityTimer := workflow.NewTimer(timerCtx, 365 * 24 * time.Hour)

This works correctly within a single run. The problem is ContinueAsNew. When the event count hits 200 and the current run ends, all pending timers are cancelled, including this one. The new run reaches this line again and starts a fresh 365-day clock, with no knowledge of how much time had already elapsed.

Consider the failure case: a customer is inactive for 364 days. Early in their membership they had a burst of activity, accumulating 200 events. When the workflow eventually calls ContinueAsNew on that 364th day of inactivity, the timer is cancelled and a new one starts. The customer gets another full year before their points expire. Their points never expire on schedule.

The Fix #

The solution is to persist enough information in the workflow state to reconstruct the correct timer duration when the new run starts. LastActivityAt is stored in RewardsState and carried through every ContinueAsNew. On each run startup, the remaining duration is computed from that timestamp:

func createInactivityTimer(ctx workflow.Context, timerCtx workflow.Context, state RewardsState) workflow.Future {
    if state.LastActivityAt.IsZero() {
        return workflow.NewTimer(timerCtx, InactivityTimeout)
    }

    elapsed := workflow.Now(ctx).Sub(state.LastActivityAt)
    remaining := max(InactivityTimeout-elapsed, 0)

    return workflow.NewTimer(timerCtx, remaining)
}

There are three cases this handles:

  1. Zero value: LastActivityAt is unset, which means this is the first run and the customer has never earned points. Start the full 365-day timer.
  2. Remaining > 0: The timeout hasn’t passed yet. Start a timer for the remaining duration, picking up exactly where the previous run left off.
  3. Remaining = 0: The timeout already passed during the gap between runs, before the new run had a chance to start. Pass zero to workflow.NewTimer, which fires immediately on the next Select.

That third case is worth dwelling on. There’s a window between when ContinueAsNew fires and when the new run’s first Select executes. If the inactivity timeout expired in that gap, the old timer is already cancelled and the new run needs to handle the expiry immediately rather than waiting for a zero-duration timer to fire on some future iteration. A zero-duration timer handles this cleanly without any special branching.

The workflow.Now Requirement #

The elapsed time calculation uses workflow.Now(ctx) rather than time.Now(). This is not optional. The Temporal Go SDK explicitly lists workflow.Now() as the required replacement for time.Now() inside workflow code1, because workflow code must be deterministic across replays.

During a replay, time.Now() returns the actual current wall-clock time, which differs from the original execution time. That difference produces a different elapsed value, a different timer duration, and a non-determinism error. workflow.Now(ctx) returns the time associated with the current position in the event history, which is stable across both the original execution and any replay. It is the only correct choice here.

The State Design Rule #

The underlying principle is straightforward: any value that workflow logic depends on across a run boundary must live in the state struct passed to ContinueAsNew. A timer duration computed from ephemeral in-run state doesn’t survive. A timestamp stored in RewardsState does.

This applies beyond timers. Any time you find yourself computing something at the start of a run that depends on what happened in a previous run, ask whether the inputs to that computation are in the state struct. If they’re not, the new run is making that computation against either a zero value or stale data.

sequenceDiagram
    participant Run1 as Run 1
    participant CAN as ContinueAsNew
    participant Run2 as Run 2

    Run1->>Run1: Timer started (365 days)
    Note over Run1: 364 days of inactivity
    Run1->>CAN: EventCount hits 200
    CAN-->>Run1: Timer cancelled
    CAN->>Run2: State passed as input (LastActivityAt carried)
    Run2->>Run2: Compute remaining = 1 day
    Run2->>Run2: New timer started (1 day)

Without LastActivityAt in state, the bottom of that diagram reads: “New timer started (365 days).”

What to Take From This #

  • Any timer created in a workflow run is cancelled when that run ends via ContinueAsNew. It doesn’t transfer.
  • State that workflow logic depends on across run boundaries must be in the input struct. If it’s not there, the new run doesn’t have it.
  • Use workflow.Now(ctx) in place of time.Now() in all workflow code. The Go SDK documents this explicitly; using time.Now() breaks determinism during replay1.
  • A zero-duration timer fires immediately on the next Select. It’s the correct tool for the already-expired case without needing conditional branching to handle it separately.

Next: a deduplication map that’s perfectly reasonable at month one becomes an unbounded data structure by year three, and a Go refactor quietly broke its serialisation in the meantime.


  1. Temporal Go SDK documentation: Core applicationworkflow.Now() is listed as the required replacement for time.Now() to ensure deterministic workflow execution. ↩︎ ↩︎