DEV Community

Cover image for Workflows that survive a closed tab: durable waits with checkpoints and chrome.alarms
Dannick Kwengang
Dannick Kwengang

Posted on Originally published at awflow.io

Workflows that survive a closed tab: durable waits with checkpoints and chrome.alarms

"Browser automation dies when you close the tab" is the standard objection to running workflows in an extension. For a while it was true for AWFlow too: a workflow with a "wait 3 days, then check again" step only worked if you left a tab open for three days.

This post is about how runs can now pause for up to 30 days, survive a closed tab, a browser restart or an extension update, and resume where they stopped. It's the same mechanism that lets a run pause for a human approval.

Flip it. Save the run, let the browser wake it.
Flip it. Save the run, let the browser wake it.

The constraint: MV3 has no long-lived process

In Manifest V3 the background is a service worker, and Chrome suspends it when it's idle. setTimeout(resume, 3 * DAY) is useless there: the worker is gone long before the timer fires. The page that runs the engine (the extension's app tab) can be closed by the user at any time.

Memory is the problem. The wait has to live somewhere else.
Memory is the problem. The wait has to live somewhere else.

So a long wait has to be two things:

  1. a serialized snapshot of the run, stored somewhere that survives restarts, and
  2. a wake-up call that the browser itself owns.

In an extension, those are chrome.storage.local and chrome.alarms. If you want the bigger picture of what a browser can and can't do compared with a server, browser vs server automation covers it.

How a run pauses

Let a node ask to pause

Normally a node's execute() returns output. A node that needs to wait returns a suspend request instead:

type SuspendRequest = {
  resumeKey: string;              // filled in by the engine
  kind: 'approval' | 'timer' | (string & {});
  prompt?: string;                // shown in the "waiting" row
  meta?: Record<string, unknown>; // e.g. { resumeAt: 1735689600000 }
};
Enter fullscreen mode Exit fullscreen mode

The engine spots it and throws a SuspendSignal to unwind the work queue back to the place where the queue and visited sets are in scope. That's where the checkpoint gets written. (One subtlety: the node runner's own catch must re-throw this signal instead of turning it into a node failure.)

Write a checkpoint that's actually resumable

My first version stored "which node paused, plus its input". It broke immediately on real workflows. A useful checkpoint needs everything the scheduler would need to continue as if nothing happened.

Peel it apart: five layers, each one needed to carry on.
Peel it apart: five layers, each one needed to carry on.

Here is the shape in code:

const checkpoint = {
  resumeKey, workflowId, pausedNodeId, kind, meta,
  pausedInput,
  queue: queue.map(n => n.id),     // what was about to run
  visited: [...visited],
  wasVisited: [...wasVisited],     // see below
  nodeOutputs,                     // so downstream $('Node') references still resolve
  runVariables: runVariables.snapshot(),
  nodeExecutionCtx,                // per-node scratchpads: loop cursors, batch offsets
  parentLink,                      // a paused sub-workflow must still resume its parent
  createdAt: new Date().toISOString(),
};
Enter fullscreen mode Exit fullscreen mode

Two bugs taught me the less obvious fields:

  • Loops. An iterating Loop node is moved out of visited on each pass. If you only save outputs of visited nodes, a pause inside a loop body resumes with the loop's current item gone. Save outputs from visited ∪ wasVisited.
  • Batch cursors. A "Split in Batches" node keeps its offset in a per-node scratchpad. Capture the scratchpad of every node, not just visited ones, because the loop node is exactly the one that's been un-marked when you pause.

Serialize defensively, fail loudly

Node outputs are user-controlled (a Code node can return anything), so a BigInt or a circular reference would crash the run at the moment it tries to pause. So the checkpoint goes through a few guards before it is stored.

Save it under the run's resume key.
Save it under the run's resume key.

If the checkpoint can't be saved, the run fails loudly at that node. A pause that silently didn't persist would be the worst outcome: the user thinks it's waiting and nothing ever resumes it.

How it wakes up

For a timer suspend, saving the checkpoint also arms a one-shot alarm named after the resume key:

if (cp.kind === 'timer' && typeof cp.meta?.resumeAt === 'number') {
  chrome.alarms.create(`resume:${cp.resumeKey}`, { when: cp.meta.resumeAt });
}
Enter fullscreen mode Exit fullscreen mode

In practice the app page asks the service worker to do it, because the worker owns the alarms API and the onAlarm listener. Here is the full round, from pause to resume.

The run carries on. Queue, visited sets, outputs and variables are rebuilt, and the next node runs.
The run carries on. Queue, visited sets, outputs and variables are rebuilt, and the next node runs.

The run saves itself

The engine writes the checkpoint before anything else happens.

It asks for a wake-up call

The app page messages the service worker, which owns the alarms.

The browser holds the alarm

A one-shot alarm named resume:<key> is set for the deadline.

Days later, it fires

The worker's onAlarm sees the resume: prefix: this is a parked run, not a scheduled workflow.

The worker opens the app

The engine lives in the app page, so the worker finds or opens that tab and passes the resume key.

The page loads the checkpoint

Everything the scheduler needs is read back from storage.

The run carries on

Queue, visited sets, outputs and variables are rebuilt, and the next node runs.

Re-arm the alarms you lost

Alarms don't survive everything. An extension update, for example, can clear them. So on startup the worker reconciles: list the timer checkpoints, list the existing alarm names, and create the missing ones.

function computeMissingResumeAlarms(checkpoints, existingAlarmNames) {
  const existing = new Set(existingAlarmNames);
  return checkpoints
    .filter(cp => cp.kind === 'timer' && typeof cp.meta?.resumeAt === 'number')
    .filter(cp => !existing.has(`resume:${cp.resumeKey}`))
    .map(cp => ({ name: `resume:${cp.resumeKey}`, when: cp.meta.resumeAt }));
}
Enter fullscreen mode Exit fullscreen mode

It's a pure function, so it's trivially unit-tested, and it's the piece that makes the design trustworthy rather than "usually works". A deadline already in the past fires right away, so a laptop that was asleep through the deadline resumes as soon as the browser is back.

Missing alarms come back. Past deadlines fire right away.
Missing alarms come back. Past deadlines fire right away.

Where the thresholds come from

  • Under 1 minute stays in memory. chrome.alarms doesn't fire reliably below about a minute, and a 5-second wait isn't worth a storage round-trip. Short waits stay plain timers.
  • Up to 30 days. An upper bound, so nobody schedules an alarm for 2031 by typo.
  • One pause per input, not per item. A Wait node pauses once for the whole batch. Per-item pauses can't be checkpointed cleanly. If you want per-item waits, wrap the step in a Loop.

Thirty days, with the tab closed and the browser restarted.
Thirty days, with the tab closed and the browser restarted.

One mechanism, many kinds

Once the plumbing existed, other kinds came almost for free:

  • approval pauses until a person clicks approve. Because it's waiting on a human, the extension signals it: a badge, a sound, and an OS notification when the app isn't focused. It can even render the approval card on the web page you're on.
  • "Poll until" resumes on a timer, checks a condition, and if it's not met yet, suspends again. A resume that is allowed to re-suspend turned out to be the key primitive.
  • Sub-workflows pause with a parentLink, so the parent run continues once the child finishes.

The approval kind is the easiest to see in a real workflow. This docs example fills a web form from saved values, but stops before touching the page until you approve:

Run it. Every step shows what it did.
Run it. Every step shows what it did.

If page watching is your use case rather than forms, monitoring competitor prices shows the other side: a trigger that reacts when a page changes.

The honest limit

This is durable, not always-on. The alarm fires when the browser is running. If your laptop is off at the deadline, the run resumes when you next open Chrome, not at 3:00:00 sharp.

But it needs the browser running. Laptop off means it resumes later.
But it needs the browser running. Laptop off means it resumes later.

For "every 30 seconds, 24/7" you still want a server. For "wait until Thursday, check the price, ask me before buying", it's enough, and there's nothing to host.

Call to action. Viewers scan the code, readers click the link.
Call to action. Viewers scan the code, readers click the link.

Try it in AWFlow →

If you're building something similar in your own extension, here is the short version.

Never pretend to wait. A failed save fails the run.
Never pretend to wait. A failed save fails the run.

AWFlow is free to install. I'm happy to answer questions, especially from anyone who's done this with IndexedDB instead of storage.local.

Interactive version with animations: https://awflow.io/blog/workflows-that-survive-a-closed-tab

Try it

Agentic Workflow (AWFlow) runs in your browser, on the model you choose, free and with no account needed to start. Install it from the Chrome Web Store here.

Top comments (0)