Before submitting a new issue
Bug summary
fetch() resolves as a normal success after its AbortSignal has already fired, so the caller receives — and parses — a response for a request it abandoned.
The abort listener does ask native to cancel:
abortListener = () => { client!.cancelRequest(requestId); };
signal.addEventListener('abort', abortListener, { once: true });
But UrlRequest.cancel() (Cronet) and URLSessionTask.cancel() are best-effort, and both are documented no-ops once the request has completed. When the cancel loses that race, nitroFetchRaw never re-checks the signal on the success path:
const res = await client.request(req);
…
return res; // no signal check
} catch (e) {
if (signal?.aborted) throw createAbortError(); // only the catch guards
So await res.json() runs on the caller's thread for a response nobody is waiting for — exactly the work the abort was meant to skip. nitroStreamFetch checks signal?.aborted in several places (added in #150 for #149); it is only the buffered path that is missing it.
Worth noting the existing harness case abort mid-flight cancels a slow request does not catch this: it aborts 100 ms into /delay/20, where the native cancel wins comfortably. The gap is the window where the response is already buffered.
Library version
1.6.3 (also present on main @ 42ddb6c)
Environment info
Android (Cronet) measured; iOS untested but the buffered path is shared. React Native 0.83.10 (react-native-tvos), new architecture, Hermes. Device: Fire TV stick AFTSS, Android 9, release build.
Steps to reproduce
- Issue a
fetch(url, { signal }) to a slow endpoint.
- Let the response be fully received natively, but abort before the JS promise continuation runs — on a busy JS thread this window is easy to hit; deterministically, block the JS thread for ~1.5 s after starting the fetch, then call
controller.abort().
await the promise.
- Observe: it resolves with a normal
Response instead of rejecting with AbortError, and res.json() parses the body.
Reproducible example repository
Not needed to see the defect — compare the buffered path in nitroFetchRaw with nitroStreamFetch in packages/react-native-nitro-fetch/src/fetch.ts at v1.6.3. A device harness case reproducing it is included in the PR below.
Measurement
React Native app on the device above. Search fans out to 11 servers behind a 2 s debounce; typing s → t → a → r at 3 s intervals supersedes fan-outs still in flight (TanStack Query aborts the dropped queries on observer swap). 48 requests per run, 4 superseded in flight.
- 4 of 4 completed and were JSON-parsed anyway, with
signal.aborted === true at the moment the success path ran. Zero surfaced AbortError.
- After adding the guard: 0 of 4 parsed; all four surfaced
AbortError.
- Instrumenting both rejection paths separately attributes those four: 3 hit the new guard (Cronet delivered the completed response), and 1 was a genuine
Cronet canceled rejection the existing catch already handled. So cancelRequest does sometimes win the race — here it lost 3 times out of 4.
- ~40 ms of main-thread JSON parse per search session, on a JS thread at 92.5% occupancy.
Suggested fix
One guard after the await, mirroring what the streaming path already does:
const res: NitroResponseNative = await client.request(req);
+ if (signal?.aborted) {
+ throw createAbortError();
+ }
Opened as #231, with a harness case for the buffered window.
Separately, and not addressed there: NitroResponse has no signal awareness, so an abort landing after the promise settles still leaves res.json() resolving, where a browser would reject.
Before submitting a new issue
Bug summary
fetch()resolves as a normal success after itsAbortSignalhas already fired, so the caller receives — and parses — a response for a request it abandoned.The abort listener does ask native to cancel:
But
UrlRequest.cancel()(Cronet) andURLSessionTask.cancel()are best-effort, and both are documented no-ops once the request has completed. When the cancel loses that race,nitroFetchRawnever re-checks the signal on the success path:So
await res.json()runs on the caller's thread for a response nobody is waiting for — exactly the work the abort was meant to skip.nitroStreamFetchcheckssignal?.abortedin several places (added in #150 for #149); it is only the buffered path that is missing it.Worth noting the existing harness case
abort mid-flight cancels a slow requestdoes not catch this: it aborts 100 ms into/delay/20, where the native cancel wins comfortably. The gap is the window where the response is already buffered.Library version
1.6.3 (also present on
main@ 42ddb6c)Environment info
Android (Cronet) measured; iOS untested but the buffered path is shared. React Native 0.83.10 (react-native-tvos), new architecture, Hermes. Device: Fire TV stick AFTSS, Android 9, release build.
Steps to reproduce
fetch(url, { signal })to a slow endpoint.controller.abort().awaitthe promise.Responseinstead of rejecting withAbortError, andres.json()parses the body.Reproducible example repository
Not needed to see the defect — compare the buffered path in
nitroFetchRawwithnitroStreamFetchinpackages/react-native-nitro-fetch/src/fetch.tsatv1.6.3. A device harness case reproducing it is included in the PR below.Measurement
React Native app on the device above. Search fans out to 11 servers behind a 2 s debounce; typing
s → t → a → rat 3 s intervals supersedes fan-outs still in flight (TanStack Query aborts the dropped queries on observer swap). 48 requests per run, 4 superseded in flight.signal.aborted === trueat the moment the success path ran. Zero surfacedAbortError.AbortError.Cronet canceledrejection the existingcatchalready handled. SocancelRequestdoes sometimes win the race — here it lost 3 times out of 4.Suggested fix
One guard after the
await, mirroring what the streaming path already does:Opened as #231, with a harness case for the buffered window.
Separately, and not addressed there:
NitroResponsehas no signal awareness, so an abort landing after the promise settles still leavesres.json()resolving, where a browser would reject.