Prevent Stale Fetch Results with AbortController and Request IDs
A search box can display the wrong results even when every HTTP request succeeds. A player types sword, then changes the query to shield. If the first request finishes last, an unguarded callback replaces the shield results with swords.
Use an AbortController to cancel obsolete network work and a request ID to decide which result may update the interface. This example targets browser pages, including Cocos Creator Web builds with the required browser APIs. Check API support separately before adapting it to a native game runtime.
Why completion order is different from input order
Consider two overlapping requests:
| Time | Event | Current query |
|---|---|---|
| 0 ms | Start request 1 for sword | sword |
| 50 ms | Start request 2 for shield | shield |
| 120 ms | Request 2 returns | shield |
| 400 ms | Request 1 returns | shield |
Rendering every response makes the interface end with the wrong query’s data. Debouncing reduces how often requests start, but requests can still overlap when a user pauses between edits.
Cancellation and rendering permission solve different parts of the problem. Cancellation asks an operation to stop. The request ID records which input still owns the view, including when a mock, wrapper, or later asynchronous step does not honor cancellation.
Keep one owner for each search view
Create one search controller per view. The endpoint in this example is a placeholder for your own same-origin API. It accepts a q query parameter and returns a JSON array of item names, such as ["Iron Shield", "Wooden Shield"].
| |
The ID changes before aborting the old request. Its eventual success or failure therefore cannot publish state for the new query. Clearing the input also increments the ID and aborts the active request before emitting the idle state.
Keep the ownership check after the final await and immediately before updating the view. If you later add asynchronous filtering or another lookup, move the check after that work as well. Apply the same check in catch; otherwise an obsolete error can replace a successful newer result.
The finally block releases only the controller belonging to the latest request. An older request finishing must not erase the reference needed to cancel a newer one. Loading state is emitted explicitly, so an old finally block cannot turn off the newer request’s loading indicator.
Connect the controller to a browser interface
Place this markup in the page, then run the following script after the elements exist and after defining createSearch:
| |
Call unmountSearchView() from your router or view teardown before removing the interface. Create a new controller if the view opens again. The renderer is synchronous and should not throw; keep component validity checks in your own renderer if the framework can destroy its targets independently.
Use textContent for names returned by the server so they are displayed as text. Replace the DOM rendering with your game UI updates when adapting the ownership pattern to a Cocos Web scene.
What aborting a request guarantees
A new request gets a new controller because an aborted signal stays aborted. Passing that signal to fetch connects cancellation to the request and response body consumption. A response can arrive before its body has finished being read, so keep response.json() inside the same try block. See the
DOM Standard’s abort definitions
and
MDN’s abort documentation
.
HTTP failures still require an explicit response.ok check, and valid JSON still needs validation against the application’s expected shape. These are separate from cancellation;
MDN’s Fetch guide
explains response handling.
Aborting the client request does not provide a server-side rollback guarantee. Use this pattern for replaceable reads. For purchases or other writes, enforce correctness on the server even if the user leaves the view.
Verify the race and cleanup paths
Test with controlled response ordering, not only a fast local server:
- Start
sword, thenshield. Resolveshieldfirst andswordlast. The final state must contain only shield results. Use a mock that ignores the abort signal to exercise the ID check independently. - Reject the old request after the new request succeeds. The newer results must stay visible without an error message.
- Clear the input while a request is pending. Resolve it afterward. The view must remain idle and empty.
- Dispose the view while fetching or reading JSON. No later state callback should run.
- Return an HTTP 500 response, malformed JSON, and a JSON object instead of an array for the current request. Each should produce an error state.
If a debounce timer is added later, invalidate the active request as soon as the input changes, before waiting for the timer. Otherwise results for the previous query can still appear during the debounce interval.