Workflows

Durable reminders with Temporal

How a “tell me when the match starts” feature becomes one small, crash-proof workflow instead of a cron job, a table and a pile of retry code.

Mayank Raj·Oct 2026·3 min read

A proactive assistant has to remember things for you: tell me when the match starts, remind me after this song, nudge me tomorrow morning. The obvious build is a table of pending watches, a cron job that polls it, a lock so two pollers don't fire the same reminder, and retry bookkeeping for when the push service is down. Every piece is simple. Together they are where bugs live.

When I built a watcher service for an assistant platform, we ran it on a workflow service built on Temporal instead. This post explains how that works and why it fits.

Workflows, activities and workers

Temporal splits your code into two kinds:

Your own services run as workers. They poll a task queue on the Temporal server, run your code and report back. The server never runs your code; it stores what happened.

The trick: event history and replay

Every step a workflow takes is recorded as an event: activity scheduled, activity completed, timer started, timer fired, signal received. If a worker crashes halfway, another worker picks the workflow up and runs the code again from the top. Steps that already happened are answered from the history instead of being repeated: a finished activity returns its recorded result, and a timer that already fired returns at once.

The code ends up exactly where it stopped, local variables and all. That is why workflow code must be deterministic: the replay has to make the same decisions as the first run.

Event sourcesignal: match started Durable timercheck again in 1h Watch workflowone per watch ID send_reminderactivity, retried
Either a signal or a timer wakes the workflow; the side effect lives in a retried activity.

One workflow per watch

A watch maps neatly onto one workflow, started with a stable ID such as watch-{user}-{topic}:

@workflow.defn
class WatchWorkflow:
    def __init__(self):
        self.event = None

    @workflow.signal
    def on_event(self, event):
        self.event = event

    @workflow.run
    async def run(self, watch):
        while True:
            await workflow.wait_condition(
                lambda: self.event is not None, timeout=watch.check_every)
            if self.event and watch.matches(self.event):
                await workflow.execute_activity(
                    send_reminder, watch,
                    start_to_close_timeout=timedelta(seconds=30),
                    retry_policy=RetryPolicy(maximum_attempts=5))
                return
            self.event = None

That loop gets a lot for free:

What it costs

Temporal is not free of trade-offs. You run a server and its database, or pay for Temporal Cloud. Changing workflow code needs care, because running workflows replay the new code; you mark changes with versioning so old and new runs each take the right path. Long-lived loops should continue as new now and then so their history stays small. And it is not an event bus: Kafka still moves the firehose of events, while Temporal runs the durable, stateful process that waits for the few that matter.

For reminders, the trade is worth it. The polling, locking, retry and recovery code I would otherwise write and debug simply isn't there.

More writing

Issuing a policy without losing a stepPayment succeeded, the insurer's API timed out. How a chain of small, idempotent queue steps keeps a purchase from getting stuck or happening twice. A model can’t grade its own homeworkWhat I learned building kmare-code, a coding-agent harness from scratch: the model is the brain, but the harness decides whether the work is actually done.