Updatable / Debounced Timer Pattern 
Overview
The Updatable / Debounced Timer pattern implements a sleep operation that can be interrupted and dynamically adjusted via Signals. It enables Workflows to wait for deadlines that can be extended or shortened based on external events, making it suitable for approval processes, SLA management, and time-sensitive business operations.
Problem
In business processes, you often need Workflows that wait for a deadline (approval timeout, SLA expiration, grace period), allow the deadline to be extended or shortened dynamically, react immediately when the deadline changes, and continue waiting with the new deadline without restarting.
Without an updatable timer, you must use fixed timeouts that cannot be adjusted, cancel and restart Workflows to change deadlines, poll frequently to check for deadline changes, or implement complex state machines to handle timing updates.
Solution
The Updatable / Debounced Timer uses a blocking wait with both a time limit and an update condition. When a Signal updates the wake-up time, the condition becomes true, the Workflow recalculates the sleep duration, and blocks again with the new deadline.
Each SDK provides a different mechanism for this:
- Java:
Workflow.await(Duration, condition)returnsfalsewhen the duration expires, ortruewhen the condition is met. - TypeScript:
wf.condition(fn, timeout)returnsfalsewhen the timeout expires, ortruewhen the function returnstrue. - Python:
workflow.wait_condition(fn, timeout=duration)returns normally when the condition is met, or raisesasyncio.TimeoutErroron timeout. - Go:
workflow.NewTimer()combined withworkflow.NewSelector()to race a timer against a Signal channel.
The following describes each step in the diagram:
- The client starts the Workflow with an initial deadline.
- The Workflow calls
sleepUntil(deadline), which blocks until the deadline. - The client sends a Signal to extend the deadline.
- The timer recalculates the remaining duration based on the new deadline and continues waiting.
- When the timer expires, the Workflow completes.
The core of the pattern is a reusable timer helper that loops on a blocking wait, recalculating the sleep duration each time the wake-up time is updated:
# updatable_timer.py
import asyncio
from datetime import timedelta
from temporalio import workflow
class UpdatableTimer:
def __init__(self, wake_up_time: float) -> None:
self._wake_up_time = wake_up_time
self._wake_up_time_updated = False
async def sleep_until(self, wake_up_time: float) -> None:
self._wake_up_time = wake_up_time
while True:
self._wake_up_time_updated = False
sleep_secs = self._wake_up_time - workflow.time()
try:
await workflow.wait_condition(
lambda: self._wake_up_time_updated,
timeout=timedelta(seconds=max(sleep_secs, 0)),
)
# Condition met: wake-up time was updated, loop to recalculate
except asyncio.TimeoutError:
break # Timer expired
def update_wake_up_time(self, wake_up_time: float) -> None:
self._wake_up_time = wake_up_time
self._wake_up_time_updated = True # Unblocks wait_condition
@property
def wake_up_time(self) -> float:
return self._wake_up_time// updatable_timer.go
func sleepUntil(ctx workflow.Context, wakeUpTime time.Time, wakeUpChannel workflow.ReceiveChannel) error {
for {
timerCtx, cancelTimer := workflow.WithCancel(ctx)
duration := wakeUpTime.Sub(workflow.Now(ctx))
if duration <= 0 {
cancelTimer()
break
}
timer := workflow.NewTimer(timerCtx, duration)
selector := workflow.NewSelector(ctx)
timerFired := false
selector.AddFuture(timer, func(f workflow.Future) {
timerFired = true
})
selector.AddReceive(wakeUpChannel, func(c workflow.ReceiveChannel, more bool) {
c.Receive(ctx, &wakeUpTime)
// Cancel the current timer so it can be recreated with the new deadline
cancelTimer()
})
selector.Select(ctx)
if timerFired {
break // Timer expired
}
// Signal received with new wakeUpTime, loop to recalculate
}
return nil
}// UpdatableTimer.java
public class UpdatableTimer {
private long wakeUpTime;
private boolean wakeUpTimeUpdated;
public void sleepUntil(long wakeUpTime) {
this.wakeUpTime = wakeUpTime;
while (true) {
wakeUpTimeUpdated = false;
Duration sleepInterval = Duration.ofMillis(this.wakeUpTime - Workflow.currentTimeMillis());
if (!Workflow.await(sleepInterval, () -> wakeUpTimeUpdated)) {
break; // Timer expired
}
// Timer was updated, loop to recalculate
}
}
public void updateWakeUpTime(long wakeUpTime) {
this.wakeUpTime = wakeUpTime;
this.wakeUpTimeUpdated = true; // Unblocks await
}
}// updatable-timer.ts
import * as wf from '@temporalio/workflow';
export class UpdatableTimer implements PromiseLike<void> {
deadlineUpdated = false;
#deadline: number;
constructor(deadline: number) {
this.#deadline = deadline;
}
private async run(): Promise<void> {
while (true) {
this.deadlineUpdated = false;
if (
!(await wf.condition(
() => this.deadlineUpdated,
this.#deadline - Date.now(),
))
) {
break; // Timer expired
}
// Timer was updated, loop to recalculate
}
}
then<TResult1 = void, TResult2 = never>(
onfulfilled?: (value: void) => TResult1 | PromiseLike<TResult1>,
onrejected?: (reason: any) => TResult2 | PromiseLike<TResult2>,
): PromiseLike<TResult1 | TResult2> {
return this.run().then(onfulfilled, onrejected);
}
set deadline(value: number) {
this.#deadline = value;
this.deadlineUpdated = true;
}
get deadline(): number {
return this.#deadline;
}
}In Java and TypeScript, the sleepUntil method calculates the sleep interval and calls a blocking wait with both a duration and a condition. If the duration expires first, the wait returns false (Java/TypeScript) or raises asyncio.TimeoutError (Python), and the timer completes. If the update flag is set via a Signal, the condition becomes true, the wait unblocks, and the loop recalculates the interval with the new deadline. In Go, a Selector races a Timer against a Signal channel; when the Signal arrives, the current timer is cancelled and a new one is created with the updated deadline.
Implementation
Basic approval Workflow
The following implementation combines the updatable timer with an approval flag. The Workflow waits for either an approval Signal or the deadline to expire:
# workflows.py
import asyncio
from datetime import timedelta
from temporalio import workflow
@workflow.defn
class ApprovalWorkflow:
def __init__(self) -> None:
self._approved = False
self._status = "PENDING"
@workflow.run
async def run(self, approval_deadline: float) -> None:
timeout_secs = approval_deadline - workflow.time()
try:
await workflow.wait_condition(
lambda: self._approved,
timeout=timedelta(seconds=max(timeout_secs, 0)),
)
self._status = "APPROVED"
except asyncio.TimeoutError:
self._status = "REJECTED"
@workflow.signal
def approve(self) -> None:
self._approved = True
@workflow.query
def get_status(self) -> str:
return self._status// workflow.go
func ApprovalWorkflow(ctx workflow.Context, approvalDeadline time.Time) (string, error) {
logger := workflow.GetLogger(ctx)
status := "PENDING"
approved := false
// Listen for the approve signal in a goroutine
workflow.Go(ctx, func(ctx workflow.Context) {
ch := workflow.GetSignalChannel(ctx, "approve")
ch.Receive(ctx, nil)
approved = true
})
// Wait for approval or timeout
duration := approvalDeadline.Sub(workflow.Now(ctx))
ok, _ := workflow.AwaitWithTimeout(ctx, duration, func() bool {
return approved
})
if ok {
status = "APPROVED"
} else {
status = "REJECTED"
}
logger.Info("Approval workflow completed", "status", status)
return status, nil
}// ApprovalWorkflowImpl.java
@WorkflowInterface
public interface ApprovalWorkflow {
@WorkflowMethod
void execute(long approvalDeadline);
@SignalMethod
void extendDeadline(long newDeadline);
@SignalMethod
void approve();
@QueryMethod
String getStatus();
}
public class ApprovalWorkflowImpl implements ApprovalWorkflow {
private UpdatableTimer timer = new UpdatableTimer();
private boolean approved = false;
private String status = "PENDING";
@Override
public void execute(long approvalDeadline) {
Workflow.await(
Duration.ofMillis(approvalDeadline - Workflow.currentTimeMillis()),
() -> approved);
if (approved) {
status = "APPROVED";
} else {
status = "REJECTED";
}
}
@Override
public void extendDeadline(long newDeadline) {
timer.updateWakeUpTime(newDeadline);
}
@Override
public void approve() {
approved = true;
}
@Override
public String getStatus() {
return status;
}
}// workflows.ts
import * as wf from '@temporalio/workflow';
export const extendDeadlineSignal = wf.defineSignal<[number]>('extendDeadline');
export const approveSignal = wf.defineSignal('approve');
export const getStatusQuery = wf.defineQuery<string>('getStatus');
export async function approvalWorkflow(approvalDeadline: number): Promise<void> {
let approved = false;
let status = 'PENDING';
wf.setHandler(approveSignal, () => {
approved = true;
});
wf.setHandler(getStatusQuery, () => status);
// Wait for approval or deadline expiration
const approvedBeforeDeadline = await wf.condition(
() => approved,
approvalDeadline - Date.now(),
);
status = approvedBeforeDeadline ? 'APPROVED' : 'REJECTED';
}The Workflow waits with both a deadline duration and a condition that checks the approved flag. If the approve Signal arrives before the deadline, the condition becomes true and the Workflow sets the status to APPROVED. If the deadline expires first, the Workflow sets the status to REJECTED.
Multiple deadline extensions
The following implementation uses the UpdatableTimer directly to support multiple deadline extensions. The Workflow blocks on the timer helper and checks the approval flag after the timer completes:
# workflows.py
from temporalio import workflow
from .updatable_timer import UpdatableTimer
@workflow.defn
class MultiExtensionApprovalWorkflow:
def __init__(self) -> None:
self._timer = UpdatableTimer(0)
self._approved = False
self._rejected = False
@workflow.run
async def run(self, initial_deadline: float) -> None:
await self._timer.sleep_until(initial_deadline)
if not self._approved:
self._rejected = True
@workflow.signal
def extend_deadline(self, new_deadline: float) -> None:
if not self._approved and not self._rejected:
self._timer.update_wake_up_time(new_deadline)
@workflow.signal
def approve(self) -> None:
self._approved = True// workflow.go
func MultiExtensionApprovalWorkflow(ctx workflow.Context, initialDeadline time.Time) (string, error) {
approved := false
rejected := false
wakeUpTime := initialDeadline
wakeUpChannel := workflow.NewChannel(ctx)
// Listen for approval signal
workflow.Go(ctx, func(ctx workflow.Context) {
ch := workflow.GetSignalChannel(ctx, "approve")
ch.Receive(ctx, nil)
approved = true
})
// Listen for deadline extension signals
workflow.Go(ctx, func(ctx workflow.Context) {
ch := workflow.GetSignalChannel(ctx, "extendDeadline")
for {
var newDeadline time.Time
ch.Receive(ctx, &newDeadline)
if !approved && !rejected {
wakeUpChannel.Send(ctx, newDeadline)
}
}
})
// Block on the updatable timer
_ = sleepUntil(ctx, wakeUpTime, wakeUpChannel)
if !approved {
rejected = true
}
if rejected {
return "REJECTED", nil
}
return "APPROVED", nil
}// MultiExtensionApprovalWorkflowImpl.java
public class MultiExtensionApprovalWorkflowImpl implements ApprovalWorkflow {
private UpdatableTimer timer = new UpdatableTimer();
private boolean approved = false;
private boolean rejected = false;
@Override
public void execute(long initialDeadline) {
timer.sleepUntil(initialDeadline);
if (!approved) {
rejected = true;
}
}
@Override
public void extendDeadline(long newDeadline) {
if (!approved && !rejected) {
timer.updateWakeUpTime(newDeadline);
}
}
@Override
public void approve() {
approved = true;
}
}// workflows.ts
import * as wf from '@temporalio/workflow';
import { UpdatableTimer } from './updatable-timer';
export const extendDeadlineSignal = wf.defineSignal<[number]>('extendDeadline');
export const approveSignal = wf.defineSignal('approve');
export async function multiExtensionApprovalWorkflow(
initialDeadline: number,
): Promise<void> {
let approved = false;
let rejected = false;
const timer = new UpdatableTimer(initialDeadline);
wf.setHandler(extendDeadlineSignal, (newDeadline: number) => {
if (!approved && !rejected) {
timer.deadline = newDeadline;
}
});
wf.setHandler(approveSignal, () => {
approved = true;
});
await timer; // Blocks until the timer expires
if (!approved) {
rejected = true;
}
}The extendDeadline Signal handler checks that the Workflow has not already been approved or rejected before updating the timer. Each update unblocks the timer loop, which recalculates the remaining duration and blocks again.
When to use
The Updatable Timer pattern is a good fit for approval Workflows with deadline extensions, SLA management with grace periods, time-based escalations that can be postponed, auction bidding with extended closing times, and payment grace periods that can be adjusted.
It is not a good fit for fixed timeouts that never change (use a simple sleep), immediate cancellation (use cancellation scopes), or complex scheduling (use Temporal Schedules).
Benefits and trade-offs
The pattern allows you to adjust deadlines without restarting Workflows. Changes take effect instantly. The timer helper is reusable across multiple Workflows. All timing is based on Workflow time, ensuring replay consistency. You can Query the current deadline at any time.
The trade-offs to consider are that the pattern requires an external process to send update Signals. Each timer instance manages one deadline. Previous deadlines are not tracked (add tracking if needed). You must calculate absolute timestamps rather than relative durations.
Comparison with alternatives
| Approach | Dynamic updates | Complexity | Use case |
|---|---|---|---|
| Updatable / Debounced Timer | Yes | Medium | Adjustable deadlines |
| Simple sleep | No | Low | Fixed delays |
| Cancellation Scope | Yes (cancel only) | Medium | Abort operations |
| Polling Loop | Yes | High | Frequent checks |
Best practices
- Use absolute timestamps. Store wake-up time as an absolute value (epoch millis in Java/TypeScript, epoch seconds in Python,
time.Timein Go), not relative durations. - Validate updates. Ensure new deadlines are in the future.
- Add Queries. Expose the current deadline via Query methods.
- Handle edge cases. Check if the timer already expired before updating.
- Consider max extensions. Limit how many times or how far deadlines can be extended.
- Log changes. Log each deadline update for observability.
- Reuse the timer helper. Extract to a helper class or function for use across Workflows.
- Combine with conditions. Use a blocking wait with both time and business conditions.
Common pitfalls
- Using time-based conditions without a duration. A wait without a timeout does not create a timer. The condition is only re-evaluated on state changes (Signals, Activity completions). Always provide a timeout for time-based waits.
- Expecting the wait to re-evaluate its duration. The timer duration is set once when the wait is called. Changing the duration variable afterward has no effect. This is why the timer helper loops and recalculates.
- Not validating new deadlines. Accepting a deadline in the past causes the timer to expire immediately. Always check that the new deadline is in the future before updating.
- Accumulating uncancelled timers in Java. In the Java SDK,
Workflow.await(Duration, condition)does not automatically cancel its internal timer when the condition is met. Repeated calls in a loop accumulate timers. Wrap in aCancellationScopeif this is a concern. - Not cancelling timers in Go. In the Go SDK, always cancel the previous timer (via
workflow.WithCancel) before creating a new one. Uncancelled timers wake up the Workflow unnecessarily, creating extra Worker load.
Related patterns
- Signal with Start: Receiving external events to modify behavior.
- Approval Pattern: Approval Workflows with adjustable deadlines.
Sample code
- Java -- Complete implementation with starter and updater.
- TypeScript -- Updatable timer with
conditionandUpdatableTimerclass. - Python -- Updatable timer with
wait_conditionand helper class. - Go -- Timer cancellation with Selector, Signal channel, and
WithCancel.