What you will build

When a user leaves an AI drafting screen, a pending response should not update that screen later. Build an adapter that accepts an AbortSignal, passes it to its transport and checks it again before returning text. The transport here is a local timer fixture. It demonstrates client control flow without making a model call or representing a provider-specific request format.

Cancellation is cooperative. Passing a signal only helps when the underlying API honors it. A second check at the adapter boundary remains useful because an implementation might finish despite cancellation. That check prevents stale text from escaping this function, although it cannot undo completed work.

Prerequisites

A supported Node.js release with ES modules, AbortController and node:timers/promises. Save as cancel.mjs. Tested on Node.js v26.13.0; no npm dependencies or AI server are required.

import assert from 'node:assert/strict';
import {setTimeout as sleep} from 'node:timers/promises';

async function generate(transport, prompt, signal) {
  signal.throwIfAborted();
  const text = await transport(prompt, {signal});
  signal.throwIfAborted();
  return text;
}
let started = 0;
let completed = 0;
async function fixture(prompt, {signal}) {
  started++;
  await sleep(50, undefined, {signal});
  completed++;
  return `draft:${prompt}`;
}
const controller = new AbortController();
const pending = generate(fixture, 'hello', controller.signal);
controller.abort();
await assert.rejects(pending, {name: 'AbortError'});
assert.equal(started, 1);
assert.equal(completed, 0);
await assert.rejects(generate(fixture, 'hello', controller.signal));
assert.equal(started, 1);
const live = new AbortController();
assert.equal(await generate(fixture, 'hello', live.signal), 'draft:hello');
assert.equal(completed, 1);
// A transport that ignores the signal can finish work, but its text is discarded.
const late = new AbortController();
const ignored = generate(async () => {
  late.abort();
  return 'stale';
}, 'hello', late.signal);
await assert.rejects(ignored);
console.log('in-flight, pre-aborted, success, stale-result fixtures passed');

Run and expected output

node cancel.mjs
in-flight, pre-aborted, success, stale-result fixtures passed

How it works

The first throwIfAborted() prevents dispatch if the operation was already cancelled. The fixture increments its start counter and waits using the promise-based timer API, passing the same signal. The official timer documentation describes AbortController support and rejection with AbortError. The AbortSignal reference documents the signal checks.

The first test starts generation, aborts immediately and awaits rejection. The completion counter must remain zero. Reusing that cancelled signal must reject before incrementing the start counter again. A fresh controller exercises success and produces the expected draft text. Together these tests distinguish skipping work from interrupting work already in progress.

The final fixture intentionally ignores the signal. It cancels the controller and returns stale text. The adapter rejects at its second check. Replace the fixture with your reviewed provider adapter while keeping both boundary checks and the signal parameter.

Edge cases and scope

The test asserts rejection and counters rather than exact elapsed milliseconds, which would be fragile under a busy scheduler. It awaits every promise so cancellation does not become an unhandled rejection. Applications should distinguish expected user cancellation from transport failures when choosing what to display or log.

Aborting an HTTP request does not establish that a remote model stopped generating or that charges were avoided. Provider-side cancellation and accounting require separate documented behavior and integration testing. This example does not parse streams, attach UI listeners or implement a timeout. Connect lifecycle cleanup in the application layer and remove listeners it owns.

Troubleshooting

If the pre-aborted test starts the transport, the initial guard is missing or moved after dispatch. If stale output reaches the caller, verify the final guard runs after every asynchronous result path. If a production transport ignores cancellation, investigate whether its HTTP library accepts a signal and whether the adapter forwards it. The .mjs extension avoids CommonJS import errors.

Primary references

Continue with node.js tutorials and all Java Samples categories.