Repository navigation
[Android] HttpContent.toArrayBuffer() and ArrayBuffer request bodies are copied byte by byte over JNI #11467
Description
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
| 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
NativeScript/packages/core/http/http-request-internal/index.android.ts
Lines 164 to 168 in a032c22
| } 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.2Dependencies
"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
- I have searched the existing issues as well as StackOverflow and this has not been posted before
- This is a bug report
- I agree to follow this project's Code of Conduct
Activity
triniwiz commentedon Sep 28, 2026
In the android runtime TypedArrays and ArrayBuffer can be marshalled as nio buffers with little to no overhead the only downside is for the auto marshal (zero copy) the type has to be know.
This
.contentis anObjectin java so no auto marshal also when a buffer it converts it to an array.To get the best performance here you I would add a new method to the that takes
java.nio.ByteBufferthat way it will auto marshal into the nio buffer then you can either user the channels api to write to the stream or create a type slice to read/write to the steam. I've done something similar for canvas but never got the time to add it to core.farfromrefug commentedon Oct 4, 2026
@triniwiz will that work with napi runtimes?
triniwiz commentedon Oct 4, 2026
Yes it does work check NativeScript/napi-android@37a04cd