Last reviewed: October 29, 2026 (Asia/Shanghai).
Direct answer
Implement CometAPI chat request cancellation with two controls: pass a fresh AbortController.signal to each browser request, and let only the current operation update the interface. When the user cancels or starts a replacement request, remove the old operation’s ownership before calling abort(). Check ownership again after asynchronous work completes and before clearing shared state.
The MDN abort() reference documents cancellation of Fetch requests, response-body consumption, and streams. That gives the browser a way to stop its request path. The ownership check handles a different application problem: an obsolete callback can still finish after the user has moved on, particularly when a custom adapter or later processing does not honor the signal.
Consider a reply panel where request A starts, then request B replaces it. If A finishes while B is waiting, A must neither display its reply nor clear B’s active state. A Cancel button needs the same protection: cancellation should remain visible even if an old promise later resolves.
Who this is for
This tutorial is for JavaScript developers adding cancellation to a browser interface backed by a server-side CometAPI integration. It implements a single reply panel with a deliberate rule: the latest submitted prompt owns the panel. A conversation showing several concurrent replies needs ownership per message rather than one shared active operation.
The browser calls an existing same-origin application route. The CometAPI credential stays on the server. This example assumes complete JSON replies; it does not parse SSE or the AI SDK UI stream protocol. Keep your established backend, authentication, input limits, and request deadlines. The site’s client-timeout guide covers the separate decision about how long a caller should wait.
Key takeaways
- Give every send its own controller and operation identity.
- Remove the old operation’s authority before aborting its request.
- Guard success, failure, and cleanup against obsolete operations.
- Give explicit cancellation its own visible state.
- Allocate a fresh signal after cancellation; an aborted signal cannot be reset.
- Test with an adapter that ignores abort so the ownership check must work independently.
Build the request owner
Save this module as reply-controller.mjs. requestReply receives a prompt and a signal, then returns a promise for a string. render synchronously applies a complete view state and should not throw. It can update DOM nodes or call your framework’s state setter.
export function createReplyController(requestReply, render) {
let active = null;
let disposed = false;
function retireActive() {
const previous = active;
active = null;
previous?.controller.abort();
}
async function send(prompt) {
if (disposed) return;
retireActive();
const operation = { controller: new AbortController() };
active = operation;
const ownsView = () => !disposed && active === operation;
try {
render({ state: 'pending', reply: '' });
if (!ownsView()) return;
const reply = await requestReply(prompt, {
signal: operation.controller.signal
});
if (!ownsView()) return;
if (typeof reply !== 'string') throw new Error('invalid_reply');
render({ state: 'complete', reply });
} catch {
if (ownsView()) render({ state: 'failed', reply: '' });
} finally {
if (ownsView()) active = null;
}
}
function cancel() {
if (disposed || !active) return;
retireActive();
render({ state: 'canceled', reply: '' });
}
function dispose() {
disposed = true;
retireActive();
}
return { send, cancel, dispose };
}
The object stored in active is the operation’s identity. Comparing object identity avoids confusing an older request with a later request for identical prompt text. retireActive() clears that identity before aborting, so the old operation loses permission to render immediately. Neither its eventual success nor its rejection can change the view.
The finally condition matters as much as the success condition. An unconditional active = null would let A’s cleanup erase B’s ownership. B could then finish without displaying anything, and Cancel would stop finding an active operation. In this implementation, an obsolete operation cannot modify that shared slot.
dispose() permanently retires this controller without rendering. Call it when the panel is removed or its owning component is destroyed; create a new controller when mounting a new panel. It is also a suitable boundary when changing accounts or conversations: cancel the old owner before handing the view to a new one.
Connect the same-origin route
Use an application contract you control. Here, POST /api/chat accepts { "prompt": "..." } and returns { "reply": "..." }. Those names describe this application’s route, not a CometAPI endpoint or response envelope. The server must already translate between its browser contract and the selected CometAPI API.
export async function requestReply(prompt, { signal }) {
const response = await fetch('/api/chat', {
method: 'POST',
signal,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt })
});
if (!response.ok) throw new Error('http_error');
const data = await response.json();
if (typeof data?.reply !== 'string') throw new Error('invalid_reply');
return data.reply;
}
The MDN Fetch guide
explains both relevant boundaries: Fetch can fulfill with an HTTP error response, so check response.ok; and aborting after headers arrive can still make body consumption reject. Keep response.json() inside the operation’s awaited path rather than treating receipt of headers as completion.
This small adapter expects bounded replies from your application route. response.json() buffers the complete body; it does not enforce a response-byte ceiling. Keep that policy on the route or in a bounded reader. The response-byte tutorial
explains the separate resource limit for a server-side client.
Wire the controller once per mounted reply panel:
import { createReplyController } from './reply-controller.mjs';
import { requestReply } from './request-reply.mjs';
const form = document.querySelector('#chat-form');
const input = document.querySelector('#chat-prompt');
const cancelButton = document.querySelector('#chat-cancel');
const status = document.querySelector('#chat-status');
const reply = document.querySelector('#chat-reply');
const labels = {
pending: 'Waiting for a reply.',
complete: 'Reply ready.',
canceled: 'Request canceled in this interface.',
failed: 'The reply could not be loaded.'
};
const controller = createReplyController(requestReply, view => {
status.textContent = labels[view.state];
reply.textContent = view.reply;
cancelButton.disabled = view.state !== 'pending';
});
form.addEventListener('submit', event => {
event.preventDefault();
const prompt = input.value.trim();
if (prompt) void controller.send(prompt);
});
cancelButton.addEventListener('click', () => controller.cancel());
The example expects a form, a labeled prompt input, a Cancel button with type="button", and separate status and reply elements with those IDs. Start the Cancel button disabled. Sending again deliberately replaces the pending request; label the action clearly if your product normally queues messages instead. Render generated text through textContent, and connect the state transitions to your existing accessibility behavior. The guide to accessible chat updates
covers announcements and focus choices in depth.
Contract details to verify
| Boundary | Required behavior | Evidence to collect |
|---|---|---|
| Replacement send | The previous signal is aborted and only the new operation owns the panel. | Resolve A while B remains pending, then complete B. |
| Explicit cancel | The panel enters canceled immediately and ignores late completion. | Cancel an unresolved operation, then resolve its promise. |
| Next send | A new controller supplies an un-aborted signal. | Compare signal identities before and after cancellation. |
| Body consumption | Cancellation remains active while the body is being read. | Use a controlled response that sends headers before delaying its body. |
| Teardown | Late callbacks produce no further view updates. | Dispose while pending, then reject the operation. |
| Backend execution | Browser cancellation and upstream termination have separate evidence. | Inspect server cancellation propagation and completion records. |
The MDN AbortSignal reference
says an aborted signal makes subsequent Fetch calls using that signal reject immediately. This is why the controller lives inside send(). Keeping one controller for the entire page would make the first cancellation break later sends.
Browser abort is local evidence. Whether a disconnect reaches the application server, whether that server cancels its CometAPI request, and whether generation or charging stops require their own supported contracts and observations. Keep server deadlines and usage reconciliation even when the interface already says canceled. The code neither retries automatically nor claims that cancellation rolls back remote work.
Verify races without paid traffic
A real network often completes in an inconvenient order for testing. Use deferred promises so you choose exactly when A and B settle. The adapter below records signals but deliberately ignores them. That forces the ownership guard to handle a late result rather than passing only because abort happened to stop the test request.
Save this as check-controller.mjs beside the controller and run node check-controller.mjs:
import assert from 'node:assert/strict';
import { createReplyController } from './reply-controller.mjs';
const calls = [];
const views = [];
const requestReply = (_prompt, { signal }) => new Promise((resolve, reject) => {
calls.push({ signal, resolve, reject });
});
const controller = createReplyController(requestReply, view => views.push(view));
const first = controller.send('first');
const second = controller.send('second');
assert.equal(calls[0].signal.aborted, true);
calls[0].resolve('obsolete reply');
await first;
assert.deepEqual(views.at(-1), { state: 'pending', reply: '' });
calls[1].resolve('current reply');
await second;
assert.deepEqual(views.at(-1), { state: 'complete', reply: 'current reply' });
const canceled = controller.send('cancel this');
controller.cancel();
assert.equal(calls[2].signal.aborted, true);
calls[2].resolve('late canceled reply');
await canceled;
assert.deepEqual(views.at(-1), { state: 'canceled', reply: '' });
const afterCancel = controller.send('fresh request');
assert.notEqual(calls[3].signal, calls[2].signal);
assert.equal(calls[3].signal.aborted, false);
calls[3].resolve('fresh reply');
await afterCancel;
assert.equal(views.at(-1).reply, 'fresh reply');
const beforeDispose = controller.send('leave this panel');
controller.dispose();
const count = views.length;
calls[4].reject(new Error('late failure'));
await beforeDispose;
await controller.send('after disposal');
assert.equal(views.length, count);
assert.equal(calls.length, 5);
console.info('Controller race checks passed');
These controller checks passed locally on Node.js 24.22.1 during preparation. They verify ownership transitions using synthetic promises, not browser compatibility or authenticated CometAPI behavior. Before deployment, separately exercise a normal reply, an HTTP error, malformed application JSON, and cancellation during a delayed body read in the browsers you support. Verify server propagation through your own controlled integration environment.
Failure modes
Aborting without retiring ownership. A custom adapter resolves anyway, or later processing finishes after the old request is canceled. If the callback still owns the panel, it can restore an unwanted reply. Invalidate ownership first and check it after the await.
Guarding success but leaving cleanup unconditional. An old finally block clears the new active operation or removes its loading state. Tie every shared state mutation to the same operation identity.
Displaying cancellation as a provider failure. The user pressed Cancel, but the old rejection updates the panel to failed. Render cancellation directly in cancel() and prevent the retired operation from writing any terminal state.
Reusing the aborted signal. Every later request immediately rejects. Construct the controller for each operation, including a retry initiated by the user.
Reusing a disposed controller. The panel remounts, but sends do nothing because teardown is permanent. Construct the controller with the new panel’s lifecycle and remove old event listeners during teardown.
Using one owner for a multi-message conversation. A second send cancels a reply that the product intended to keep. Choose the concurrency policy first; assign an independent owner to each message when several replies may run together.
Sources checked
These maintained public technical references were fetched successfully on October 28 UTC, October 29 in Asia/Shanghai. They support an evergreen browser implementation, with no claim of a new provider release or incident.
- MDN: AbortController: abort() method documents aborting Fetch, response-body consumption, and streams.
- MDN: AbortSignal documents signal state, per-operation controller examples, and rejection when an aborted signal is reused.
- MDN: Using the Fetch API documents passing a signal, cancellation after headers, and explicit HTTP-status checks.
The operation-ownership design, application route shape, and verification matrix are engineering recommendations built on those browser rules.
FAQ
Why check ownership if Fetch supports abort?
The guard expresses which operation may change the interface. A custom adapter, cached result, or asynchronous processing step may finish despite the old request being canceled. Removing ownership makes those completions harmless to the current panel.
Does Cancel guarantee that CometAPI stopped generating?
No such guarantee follows from browser cancellation. Verify the server’s disconnect handling and upstream cancellation contract separately, and reconcile usage from authoritative records.
Can this controller wrap a streaming client?
The ownership rule can apply, but this implementation waits for one final string. A streaming adapter must check ownership before every visible update and clean up its reader on cancellation. Use the parser and response protocol required by that client.
When should the interface create a controller?
Create one when mounting the reply panel, keep it through successive sends, and dispose it during teardown. Each send creates its own underlying AbortController. If your framework already supplies cancellation and stale-result handling, verify those behaviors before adding another owner.
Reader next step
Add the controller to one reply panel and run the deferred-promise checks first. Then connect the existing same-origin route, test cancellation both before headers and during body consumption, and verify teardown. Keep routine diagnostics to fixed outcome codes and request correlation values; omit prompts, replies, credentials, and raw error bodies. Enable the Cancel action only after the interface and server checks establish what cancellation means at each boundary.