Skip to content

[Android] HttpContent.toArrayBuffer() and ArrayBuffer request bodies are copied byte by byte over JNI #11467

Description

@modos189

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug-pending-triageReported bug, pending triage to confirm.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions