The Animation finished Promise and Cancellation

Part of Web Animations API Integration in Core CSS Animation Fundamentals.

The problem

A wizard animates between steps with await panel.animate(...).finished, then loads the next step. It works until a user double-clicks “Next”: the first animation is cancelled by the second, the first await throws, and the console fills with “Uncaught (in promise) AbortError”. In another flow, code awaits animation.finished after calling play() on an animation that had already finished once — and waits forever on a promise that resolved long ago, or resolves immediately when it should wait.

The promise is the right tool; it just has semantics that event-based code never had to think about.

Root cause analysis: promises that reject and get replaced

finished resolves or rejects exactly once per run. When the animation reaches its end, finished resolves with the animation object. When it is cancelled — by cancel(), by removing the element, by a CSS animation being removed from style — finished rejects with a DOMException named AbortError. Code that only handles the success path produces unhandled rejections.

Replaying creates a new promise. Once an animation has finished, its finished promise is settled. Calling play(), reverse() or changing its timing so it is no longer finished replaces animation.finished with a new pending promise. A reference captured before replay points at the old, already-settled promise. Always read animation.finished after the call that starts the run you want to wait for.

ready resolves when playback actually starts. animation.ready settles when the animation is no longer pending — for example after a pause or play is applied. It also rejects with AbortError if cancelled before starting.

CSS animations have the same promises. element.getAnimations() returns CSSAnimation and CSSTransition objects with finished and ready, which is a more reliable completion signal than end events, as described in handling animationend and transitionend reliably.

The finished promise across a replayed animationEach run has its own promise; cancel rejects the current one.The finished promise across a replayed animationRunning (promise A)Finished (Aresolved)Running (promise B)Idle (B rejected)reaches endplay()cancel()
Each run has its own promise; cancel rejects the current one.

Step-by-step resolution

Awaiting animations without hangs or noiseTreat cancellation as an expected outcome with its own branch.Awaiting animations without hangs or noise1Start the animation, then read its finished promise.The promise belongs to this run2Await it inside try/catch.No unhandled rejections3In catch, return a 'cancelled' result for AbortError and rethrow other errors.Callers can branch on outcome4Pass an AbortSignal and cancel the animation when it aborts.One cancellation mechanism for the whole flow5Before continuing a flow, check the signal again.No stale steps after a cancel6Under reduced motion, finish immediately or skip the animation.
Treat cancellation as an expected outcome with its own branch.

Production code pattern

const reduce = matchMedia('(prefers-reduced-motion: reduce)');

/** Runs an animation and resolves to 'finished' | 'cancelled'. Never rejects on cancel. */
export async function run(el, keyframes, options, { signal } = {}) {
  if (signal?.aborted) return 'cancelled';
  const animation = el.animate(keyframes, reduce.matches ? { ...options, duration: 0 } : options);

  const onAbort = () => animation.cancel();
  signal?.addEventListener('abort', onAbort, { once: true });

  try {
    await animation.finished;                      // read AFTER starting this run
    return 'finished';
  } catch (err) {
    if (err?.name === 'AbortError') return 'cancelled';
    throw err;                                     // real errors still surface
  } finally {
    signal?.removeEventListener('abort', onAbort);
  }
}

// Wizard: a new click aborts the previous transition flow.
let flow;
nextButton.addEventListener('click', async () => {
  flow?.abort();
  flow = new AbortController();
  const { signal } = flow;

  const out = await run(currentStep,
    [{ opacity: 1, translate: '0 0' }, { opacity: 0, translate: '-24px 0' }],
    { duration: 180, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'forwards' },
    { signal });
  if (out === 'cancelled' || signal.aborted) return;    // a newer click took over

  showStep(nextIndex);
  await run(nextStep,
    [{ opacity: 0, translate: '24px 0' }, { opacity: 1, translate: '0 0' }],
    { duration: 240, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
    { signal });
});
/* The steps' resting styles do not depend on script fills. */
.step { opacity: 1; translate: 0 0; }
.step[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
  .step { transition: none; }
}

Rendering Impact: composite. The animations touch opacity and translate; promise handling runs as microtasks after the animation’s state changes and adds no per-frame work. A zero-duration animation under reduced motion still resolves finished on the next animation update.

Setting duration: 0 instead of skipping the animation entirely keeps the code path identical under reduced motion: the promise still resolves and fill still applies, so later steps behave the same. Skipping is also valid, but then the final styles must be applied some other way.

Two ways to await an animationThe difference only shows when users act faster than the animation.Two ways to await an animationawait el.animate(...).finishedThrows AbortError on cancelUnhandled rejection noiseFlow continues with stale stepsBreaks on double-clickrun() with signal and outcomeReturns 'cancelled' instead of throwingAborting cancels the animationFlow checks signal before continuingRobust to fast input
The difference only shows when users act faster than the animation.

Verification checklist

Constraints and trade-offs

  • finished never settles for an animation paused forever; add a timeout if pausing is possible.
  • A cancelled animation drops its fill, so the element returns to its base style immediately.
  • Aborting one controller for a whole flow cancels every animation in it; use separate controllers for independent effects.
  • Infinite animations never resolve finished; do not await them.
  • Older engines may lack finished on CSS animation objects; feature-detect before relying on it.

Frequently asked questions

Why does awaiting animation.finished throw AbortError?

The animation was cancelled before it finished — by cancel(), by removing the element, or by the CSS animation being removed. Catch the error and treat it as a cancellation outcome.

Why does my await never resolve after calling play() again?

Or why does it resolve immediately? A finished animation’s promise is already settled. Calling play() creates a new promise, so read animation.finished again after play().

What is the difference between ready and finished?

ready settles when playback actually begins after a pending play or pause; finished settles when the animation reaches its end or is cancelled.

Can I await CSS animations the same way?

Yes. getAnimations() returns CSSAnimation and CSSTransition objects that have the same finished and ready promises.