Tony GorbProduct Designer & Builder

Cancel has to be checked after every await

In Gradients Lab you could cancel a bulk download and still get the save dialog a moment later. The abort check was in the right loop. The steps after the loop never asked.

In this story4 sections
  1. The loop stopped, the job did not
  2. One check per await
  3. When the job writes into someone else's file
  4. The checklist

Gradients Lab lets you select saved gradients and download them as a zip. With a long selection there is a progress bar and a Cancel button. Cancel worked, in the sense that the bar went away. Then the browser's save dialog opened anyway, with the zip you had just cancelled.

The job had an abort signal. It was checked. It was just checked in the wrong number of places.

The loop stopped, the job did not

The download runs in three stages: render each gradient to a PNG, put them into a zip, save the zip. Only the first stage knew about cancelling.

The render loop, processInChunks, checks the signal before each chunk and before each item, and throws an AbortError if it is set. That covers most of the time the job runs. It does not cover two cases:

  1. Cancel arrives while the last item is rendering. There is no next item to check, so the loop resolves normally.
  2. Cancel arrives during zipping. The loop has already finished.

In both cases the function carried on to zip.generateAsync and then saveAs, and neither asked whether the user still wanted the file. The loop's check was correct and complete. The function around it was not.

One check per await

The fix, on 31 March 2026, added a check at each boundary after the loop:

const processed = await processInChunks(items, 1, render, onProgress, signal);if (signal.aborted) return; // before zippingconst content = await zip.generateAsync({ type: "blob" }, onZipProgress);if (signal.aborted) return; // before savingsaveAs(content, fileName);

The real code reads the signal from a ref, and the zip progress callback also checks it, so the bar stops moving. That check does not stop JSZip: returning from a progress callback does nothing to the work. The check that matters is the last one, right before the side effect the user can see.

The general rule I took from it: an await is a point where time passes and the world can change. Every await in a cancellable job needs an abort check after it, and the one directly before an irreversible step is not optional.

When the job writes into someone else's file

Gradients Lab only had to not save a file. Tailwind Tokens has the harder version of the problem. It generates Figma variables and styles straight into the user's document, hundreds at a time. Stopping is not enough there. By the time the user cancels, the plugin may have created half a palette in a file it does not own.

The abort work in May 2025 added two things.

An abort check inside every loop. A shared controller exposes checkAbortSignal(), which throws when the user has cancelled. Each generator calls it before it starts and before each token. There are 63 calls across the plugin today.

A record of what this run created. A creation tracker stores the ids of every variable, style and collection made in the current session. The plugin records an item only when it creates it, not when it updates one that already existed:

let effectStyle = (await figma.getLocalEffectStylesAsync()).find((s) => s.name === styleName);if (!effectStyle) {  effectStyle = figma.createEffectStyle();  effectStyle.name = styleName;  creationTracker.trackStyleCreation(effectStyle);}

On abort, the controller signals every loop to stop, starts the cleanup, and tells the UI. The cleanup removes only the tracked ids. Variables and styles the user already had stay where they were. If cleanup itself partly fails, the tracker and the variable cache are still cleared, so the next run starts from the real state of the file.

This design has a limit worth knowing. The tracker records creations, so cancel undoes them. A run that updated an existing style leaves that style updated. Undoing updates would need a snapshot of each value before it changes. I think removing what was created is the right first step for a generator, because a half-made palette is the mess a user notices.

A related feature followed two weeks later. Smart Deletion, in June 2025, removes the variables and styles of tokens the user has deselected when they apply an update. I think of it as the same principle: a plugin that writes into a user's file should be able to say exactly which parts of it are its own.

The checklist

For any job a user can cancel:

  1. List every await in the job, not just the loop. Put an abort check after each one.
  2. Put a check directly before every step the user can see or cannot undo: a save, a download, a write to their document.
  3. Do not trust progress callbacks to stop work. They report on it.
  4. If the job creates things in a space the user owns, record each id as you create it, and delete only those on cancel.
  5. Know what your cancel does not undo, and decide whether that is acceptable before a user finds out.

Tony Gorb, Product Designer & Builder

Questions about this story, or a design role to talk about? Write to bytonygorb@gmail.com or find me on LinkedIn.

More stories