Issue Description
This is a runtime performance issue on Android. Both directions of binary HTTP data are converted between JS and Java one element at a time, on the calling thread (usually the UI thread).
Receiving: HttpContent.toArrayBuffer()
|
toArrayBuffer(this: BaseHttpContent) { |
|
return Uint8Array.from((this.raw as java.io.ByteArrayOutputStream).toByteArray()).buffer; |
|
}, |
toByteArray() returns a Java byte[], which on the JS side is a proxy to a native object, not data in the V8 heap. Uint8Array.from reads it element by element, and every index is a separate call across the JNI bridge. This path is used by Http.getBinary(), by XMLHttpRequest with responseType arraybuffer/blob, and therefore by fetch().arrayBuffer()/.blob().
Sending: ArrayBuffer request body
|
} else if (options.content instanceof ArrayBuffer) { |
|
const typedArray = new Uint8Array(options.content as ArrayBuffer); |
|
const nativeBuffer = java.nio.ByteBuffer.wrap(Array.from(typedArray)); |
|
javaOptions.content = nativeBuffer; |
|
} |
The buffer is expanded into a plain JS array, and the runtime then marshals that array into a Java byte[] element by element. It is cheaper than the receiving side but still linear.
Measurements
Android 16 emulator (arm64, sdk_gphone64_arm64), median of 5 runs after warm-up. Every variant was checked to produce identical bytes, including negative Java bytes.
Response body → ArrayBuffer |
16 KB |
100 KB |
300 KB |
1024 KB |
Uint8Array.from(raw.toByteArray()).buffer (current) |
9.5 ms |
64.4 ms |
170.8 ms |
620.4 ms |
ArrayBuffer.from(ByteBuffer.wrap(raw.toByteArray())) |
0.1 ms |
0.3 ms |
0.3 ms |
1.1 ms |
ByteBuffer.allocateDirect(n) + put + ArrayBuffer.from |
0.1 ms |
0.1 ms |
0.2 ms |
0.7 ms |
ArrayBuffer → request body |
16 KB |
100 KB |
300 KB |
1024 KB |
ByteBuffer.wrap(Array.from(new Uint8Array(buffer))) (current) |
0.9 ms |
5.8 ms |
16.1 ms |
65.2 ms |
ByteBuffer.allocate(n).put(buffer) |
0.0 ms |
0.0 ms |
0.0 ms |
0.0 ms |
For comparison, the iOS implementations of the same two paths (interop.bufferFromData(this.raw) and NSData.dataWithData(buffer)) take under 0.1 ms at 1 MB on an iOS 18.6 simulator.
Expected behavior: converting an HTTP body between ArrayBuffer and native memory should cost about one memory copy, as it already does on iOS and in File.readBuffer* on Android.
Suggested fix
The runtime already converts between java.nio.ByteBuffer and ArrayBuffer in a single step, and core relies on this in FileSystemAccess.readBufferAsync (ArrayBuffer.from(result)).
Receiving:
toArrayBuffer(this: BaseHttpContent) {
return ArrayBuffer.from(java.nio.ByteBuffer.wrap((this.raw as java.io.ByteArrayOutputStream).toByteArray()));
},
(or allocateDirect + put + ArrayBuffer.from, which matches what readBufferAsync receives from Async.File.readBuffer; the difference is below a millisecond.)
Sending: the body has to stay a heap buffer, because Async.Http writes it with ByteBuffer.array():
} else if (options.content instanceof ArrayBuffer) {
javaOptions.content = java.nio.ByteBuffer.allocate(options.content.byteLength).put(options.content as any);
}
I intend to submit a PR for this issue.
Note: the draft PR #10069 (switch to OkHttp) moves the same Uint8Array.from(raw.toByteArray()).buffer line to its new location, so the issue would carry over.
Found along the way (separate issue)
While preparing this, I found that binary request bodies other than ArrayBuffer are silently dropped on both Android and iOS. Http.request({ content: Uint8Array }), xhr.send(Uint8Array | Blob) and fetch(url, { body: Uint8Array | Blob }) all reach the server with Content-Length: 0. The cause is that both requestInternal implementations only accept content instanceof ArrayBuffer, even though HttpRequestOptions.content is typed to accept Uint8Array.
Reproduction
No network needed. Run on Android with ns run android:
const size = 300 * 1024;
// Receiving: same type as HttpContent.raw on Android
const bytes = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, size);
new java.util.Random(42).nextBytes(bytes);
const raw = new java.io.ByteArrayOutputStream(size);
raw.write(bytes, 0, size);
let at = Date.now();
Uint8Array.from(raw.toByteArray()).buffer; // what toArrayBuffer() does today
console.log(`receive, current: ${Date.now() - at} ms`);
at = Date.now();
ArrayBuffer.from(java.nio.ByteBuffer.wrap(raw.toByteArray()));
console.log(`receive, via ByteBuffer: ${Date.now() - at} ms`);
// Sending: what requestInternal does with an ArrayBuffer body today
const body = new Uint8Array(size).buffer;
at = Date.now();
java.nio.ByteBuffer.wrap(Array.from(new Uint8Array(body)));
console.log(`send, current: ${Date.now() - at} ms`);
at = Date.now();
java.nio.ByteBuffer.allocate(size).put(body);
console.log(`send, via ByteBuffer: ${Date.now() - at} ms`);
With a real response:
import { Http } from '@nativescript/core';
const response = await Http.request({ url: '<any page of a few hundred KB>', method: 'GET' });
const at = Date.now();
const buffer = response.content.toArrayBuffer();
console.log(`toArrayBuffer: ${Date.now() - at} ms for ${buffer.byteLength} bytes`);
Relevant log output (if applicable)
Environment
OS: macOS 15.7.3
CPU: (10) arm64 Apple M1 Pro
Shell: /opt/homebrew/bin/fish
node: 26.10.0
npm: 11.19.1
nativescript: 9.1.1
# android
java: 17.0.20.1
ndk: Not Found
apis: 33, 34, 35, 36, 36
build_tools: 33.0.1, 34.0.0, 35.0.0, 35.0.1, 36.0.0, 36.1.0
system_images:
- android-35 | Google Play ARM 64 v8a
- android-36 | Google Play ARM 64 v8a
# ios
xcode: 26.3/17C529
cocoapods: 1.16.2
python: Not Found
python3: 3.9.6
ruby: 3.3.12
platforms:
- DriverKit 25.2
- iOS 26.2
- macOS 26.2
- tvOS 26.2
- visionOS 26.2
- watchOS 26.2
Dependencies
"dependencies": {
"@valor/nativescript-websockets": "^2.0.3",
"nativescript-theme-core": "^1.0.4"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "2.1.3",
"@angular/build": "^21.0.0",
"@angular/compiler-cli": "^21.0.0",
"@csstools/css-calc": "~2.1.2",
"@csstools/css-color-parser": "^3.0.8",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"@nativescript/hook": "^3.0.4",
"@nativescript/nx": "^22.0.0",
"@nstudio/focus": "^20.0.2",
"@nstudio/nps-i": "~2.0.0",
"@nx/devkit": "22.5.4",
"@nx/eslint-plugin": "22.5.4",
"@nx/jest": "22.5.4",
"@nx/js": "22.5.4",
"@nx/node": "22.5.4",
"@nx/plugin": "22.5.4",
"@nx/vite": "22.5.4",
"@nx/vitest": "22.5.4",
"@nx/web": "22.5.4",
"@nx/workspace": "22.5.4",
"@prettier/plugin-xml": "^3.4.1",
"@rollup/plugin-alias": "^6.0.0",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-replace": "^6.0.3",
"@swc-node/register": "1.11.1",
"@swc/core": "1.15.8",
"@swc/helpers": "0.5.19",
"@types/jest": "30.0.0",
"@types/node": "^20.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.46.4",
"@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vitest/coverage-v8": "4.0.9",
"@vitest/ui": "4.0.9",
"@vue/compiler-sfc": "^3.5.24",
"acorn": "^8.15.0",
"acorn-stage3": "^4.0.0",
"copy-webpack-plugin": "^13.0.0",
"copyfiles": "^2.4.0",
"css": "^3.0.0",
"css-tree": "^3.1.0",
"css-what": "^6.1.0",
"dotenv": "~16.4.0",
"dotenv-webpack": "^7.0.0",
"emoji-regex": "^10.3.0",
"enhanced-resolve": "^5.18.3",
"esbuild": "^0.27.4",
"eslint": "~8.57.0",
"eslint-config-prettier": "^10.0.0",
"fork-ts-checker-webpack-plugin": "^7.0.0",
"form-data": ">=4.0.4",
"gonzales": "^1.0.7",
"husky": "^9.0.0",
"jest": "30.0.5",
"jest-environment-jsdom": "30.0.5",
"jest-util": "30.0.5",
"jiti": "2.4.2",
"jsdom": "~22.1.0",
"lint-staged": "^15.2.0",
"loader-utils": "^2.0.0 || ^3.0.0",
"module-alias": "^2.2.2",
"nativescript": "9.1.0-alpha.17",
"nativescript-typedoc-theme": "1.1.0",
"nx": "22.5.4",
"parse-css": "git+https://github.com/tabatkins/parse-css.git",
"parserlib": "^1.1.1",
"plist": "^5.0.0",
"postcss": "^8.0.0",
"postcss-import": "^16.0.0",
"postcss-loader": "^8.0.0",
"prettier": "^3.2.5",
"react-reconciler": "^0.33.0",
"sass": "^1.72.0",
"sass-loader": "^16.0.0",
"shady-css-parser": "^0.1.0",
"terser-webpack-plugin": "^5.0.0",
"tree-kill": "^1.2.2",
"ts-dedent": "^2.2.0",
"ts-jest": "29.4.5",
"ts-loader": "^9.0.0",
"ts-node": "10.9.2",
"ts-patch": "^3.0.0",
"tslib": "^2.6.0",
"typedoc": "^0.28.14",
"typescript": "5.9.3",
"vite": "^8.0.0",
"vite-plugin-solid": "^2.11.11",
"vite-plugin-static-copy": "^4.1.1",
"vitest": "4.0.9",
"vue-loader": "^15.0.0 <= 15.9.8",
"vue-tsc": "^3.2.5",
"webpack-bundle-analyzer": "^4.0.0",
"webpack-chain": "^6.0.0",
"webpack-merge": "^6.0.0",
"webpack-virtual-modules": "^0.4.0",
"zx": "^8.3.0"
}
Please accept these terms
Issue Description
This is a runtime performance issue on Android. Both directions of binary HTTP data are converted between JS and Java one element at a time, on the calling thread (usually the UI thread).
Receiving:
HttpContent.toArrayBuffer()NativeScript/packages/core/http/http-request/index.android.ts
Lines 11 to 13 in a032c22
toByteArray()returns a Javabyte[], which on the JS side is a proxy to a native object, not data in the V8 heap.Uint8Array.fromreads it element by element, and every index is a separate call across the JNI bridge. This path is used byHttp.getBinary(), byXMLHttpRequestwithresponseTypearraybuffer/blob, and therefore byfetch().arrayBuffer()/.blob().Sending:
ArrayBufferrequest bodyNativeScript/packages/core/http/http-request-internal/index.android.ts
Lines 164 to 168 in a032c22
The buffer is expanded into a plain JS array, and the runtime then marshals that array into a Java
byte[]element by element. It is cheaper than the receiving side but still linear.Measurements
Android 16 emulator (arm64,
sdk_gphone64_arm64), median of 5 runs after warm-up. Every variant was checked to produce identical bytes, including negative Java bytes.ArrayBufferUint8Array.from(raw.toByteArray()).buffer(current)ArrayBuffer.from(ByteBuffer.wrap(raw.toByteArray()))ByteBuffer.allocateDirect(n)+put+ArrayBuffer.fromArrayBuffer→ request bodyByteBuffer.wrap(Array.from(new Uint8Array(buffer)))(current)ByteBuffer.allocate(n).put(buffer)For comparison, the iOS implementations of the same two paths (
interop.bufferFromData(this.raw)andNSData.dataWithData(buffer)) take under 0.1 ms at 1 MB on an iOS 18.6 simulator.Expected behavior: converting an HTTP body between
ArrayBufferand native memory should cost about one memory copy, as it already does on iOS and inFile.readBuffer*on Android.Suggested fix
The runtime already converts between
java.nio.ByteBufferandArrayBufferin a single step, and core relies on this inFileSystemAccess.readBufferAsync(ArrayBuffer.from(result)).Receiving:
(or
allocateDirect+put+ArrayBuffer.from, which matches whatreadBufferAsyncreceives fromAsync.File.readBuffer; the difference is below a millisecond.)Sending: the body has to stay a heap buffer, because
Async.Httpwrites it withByteBuffer.array():I intend to submit a PR for this issue.
Note: the draft PR #10069 (switch to OkHttp) moves the same
Uint8Array.from(raw.toByteArray()).bufferline to its new location, so the issue would carry over.Found along the way (separate issue)
While preparing this, I found that binary request bodies other than
ArrayBufferare silently dropped on both Android and iOS.Http.request({ content: Uint8Array }),xhr.send(Uint8Array | Blob)andfetch(url, { body: Uint8Array | Blob })all reach the server withContent-Length: 0. The cause is that bothrequestInternalimplementations only acceptcontent instanceof ArrayBuffer, even thoughHttpRequestOptions.contentis typed to acceptUint8Array.Reproduction
No network needed. Run on Android with
ns run android:With a real response:
Relevant log output (if applicable)
Environment
Dependencies
Please accept these terms