Define the ownership boundary
Treat each request as one client operation with its own AbortController, stream reader, accumulated text, and identity token. The controller governs work observable by the client. The reader governs access to the local ReadableStream. A cancellation signal can cause a pending local read or fetch integration to reject or stop delivering data, but it is not evidence that a remote service stopped generating a response. Likewise, it does not establish that a provider refunded, waived, or avoided a charge. Those outcomes require provider-specific documentation or an independently verifiable server-side contract.
function createOperation() {
return { id: Symbol("operation"), controller: new AbortController(),
reader: null, text: "" };
}Keep cancellation and cleanup explicit
When a user cancels, abort the operation and make a deliberate UI decision about text already received. Do not silently retry an aborted operation, and do not assume that cancellation should erase partial output. If a reader has been acquired, release its lock during final cleanup. A reader may also expose cancellation through its cancel method, but application code should coordinate that action with the operation's abort policy and handle possible rejection. Cleanup must be safe when cancellation happens before the first chunk, after partial output, after completion, or while a read is failing. A standalone AbortSignal does not wake a pending read on an arbitrary stream. The example explicitly connects abort to reader.cancel(), handles cancellation rejection, and removes its listener in finally. The underlying source must provide a bounded, cooperative cancel implementation; this is not a guarantee for an arbitrary external source. Supply decoded string chunks, not Uint8Array or raw SSE.
async function consumeStream(operation, stream, onUpdate) {
const reader = stream.getReader();
const signal = operation.controller.signal;
operation.reader = reader;
let complete = false;
// AbortSignal alone does not cancel an arbitrary reader.read().
const cancelRead = () => { void reader.cancel().catch(() => {}); };
signal.addEventListener("abort", cancelRead, { once: true });
try {
if (signal.aborted) cancelRead();
while (!signal.aborted) {
const { value, done } = await reader.read();
if (signal.aborted) break;
if (done) { complete = true; break; }
// This example accepts decoded text, not fetch() byte chunks or SSE frames.
if (typeof value !== "string") throw new TypeError("text chunks required");
operation.text += value;
onUpdate(operation.text);
}
} finally {
signal.removeEventListener("abort", cancelRead);
if (!complete) cancelRead();
reader.releaseLock();
if (operation.reader === reader) operation.reader = null;
}
}Isolate late events from replaced operations
Aborting an old operation does not make every previously queued callback disappear. A read, timer, promise continuation, or adapter callback may run after the UI has started a replacement operation. Store the current operation identity outside the consumer and require every state update to prove that it still belongs to that operation. This prevents an old chunk or error from overwriting the newer result. The identity check is a UI-state safeguard, not a transport-level guarantee. The returned handle exposes cancel() and done for deterministic local checks. A normal return after abort still records cancelled, not complete. Error reporting uses a fixed category rather than exposing a raw error object that may contain request data. The demo has one UI slot; create a separate owner per independent view.
let currentOperation = null;
function startOperation(stream, setState) {
const operation = createOperation();
const previous = currentOperation;
currentOperation = operation;
previous?.controller.abort();
const done = consumeStream(operation, stream, (text) => {
if (currentOperation === operation) setState({ status: "streaming", text });
}).then(() => {
if (currentOperation === operation) {
setState({ status: operation.controller.signal.aborted ? "cancelled" : "complete",
text: operation.text });
}
}).catch(() => {
if (currentOperation === operation) {
setState({ status: operation.controller.signal.aborted ? "cancelled" : "error",
text: operation.text, reason: "stream_stopped" });
}
});
return {
done,
cancel() { if (currentOperation === operation) operation.controller.abort(); }
};
}Exercise lifecycle races with a synthetic stream
Use controlled local promises rather than a real model or provider call. The harness should expose enqueue, close, and fail controls so each scenario can determine exactly when a read resolves. Cover cancellation before the first chunk, cancellation after partial output, normal completion, read failure, and a late callback from an operation that has been replaced. Verify that the reader lock is released on every exit path and that stale callbacks cannot change current state. These checks describe browser-side behavior only; they do not verify any provider's cancellation, server execution, quota, or billing behavior. Confirm the target runtime's support for the relevant AbortController and Streams APIs before relying on this pattern. The source below intentionally has no pending pull promise: reader.cancel() can finish a blocked local read. Do not call push/close after cancellation or closure; those are invalid source operations. A stale callback is tested by enqueueing before replacement and allowing the promise continuation to run afterwards.
function makeControlledStream() {
let source, cancelled = false;
const stream = new ReadableStream({
start(controller) { source = controller; },
cancel() { cancelled = true; }
});
return {
stream,
push(text) { source.enqueue(text); },
close() { source.close(); },
fail(error) { source.error(error); },
get cancelled() { return cancelled; }
};
}