Skip to main content

Performance Tips

While Nitro is already insanely fast, there are some things you can do that affect the performance of your library. Here are some tips to make your library even faster.

Avoid dynamic types

Any dynamic types require runtime type checking, and cannot be optimized as good as statically known types (compile-time).

Untyped Maps

An untyped map (AnyMap) is not only untyped, but also in-efficient. If you can, avoid untyped maps:

Bad ❌
interface BadDatabase extends HybridObject {
getUser(): AnyMap
}
Good ✅
interface User {
name: string
age: number
}
interface GoodDatabase extends HybridObject {
getUser(): User
}

In some cases (e.g. network requests) you do not know the shape of your data, like in a JSON web-request. In this case, it might make sense to use ArrayBuffer or string, and parse the data on the JS side using JSON.parse - benchmark your code (before vs after) to see if this optimization makes sense for you.

Variants

Variants (A | B) are dynamic types. Each time you pass a variant to native, Nitro has to check it's type at runtime - is it A or B? Those type-checks are very efficient so this is considered a micro-optimization, but if you can, avoid variants like so:

Bad ❌
interface BadDatabase extends HybridObject {
set(value: number | string): void
}
Good ✅
interface GoodDatabase extends HybridObject {
setNumber(value: number): void
setString(value: string): void
}

Avoid unnecessary objects

It is a common pattern to wrap everything in an object in JavaScript. In Nitro, every object gets it's own struct and has to be allocated. On iOS this performance impact is almost zero, but on Android the struct is a heap-allocation.

If you can, avoid unnecessarily wrapping everything in objects, and flatten the types out in the function signature:

Bad ❌
interface SetPayload {
key: string
value: string
onCompleted: () => void
}
interface BadDatabase extends HybridObject {
set(payload: SetPayload): void
}
Good ✅
interface GoodDatabase extends HybridObject {
set(key: string,
value: string,
onCompleted: () => void): void
}

Use Threading/Asynchronous Promises

By default, every function in Nitro is fully synchronous. If your function takes long to execute, the JS Thread can not do any other work in the meantime.

In such cases, mark your function asynchronous by returning a Promise, which you can then use to run the heavy processing code on a different thread:

Bad ❌
interface BadDatabase extends HybridObject {
writeLargeData(data: string): void
}
Good ✅
interface GoodDatabase extends HybridObject {
writeLargeData(data: string): Promise<void>
}

Keep in mind that switching to a different Thread on the native side introduces a small overhead by itself. This only benefits performance if the actual computation inside the function body takes longer than the thread-switch.

Use ArrayBuffer for large data

For large data sets, conventional arrays are in-efficient as each value has to be copied individually. In contrast to conventional arrays, Array Buffers are zero-copy, meaning native memory can be directly shared to JS without copying the data.

For example, to return a large list of numbers we could use array buffers (Float64Array) instead of arrays:

Bad ❌
interface BadDatabase extends HybridObject {
getAsBlob(): number[]
}
Good ✅
interface GoodDatabase extends HybridObject {
getAsBlob(): ArrayBuffer
}

Use Hybrid Objects to implement proxy-results

If a function returns a large amount of data to JS, but only a sub-set of that data is used, we can implement it as a Hybrid Object instead of a struct.

This way data will be accessed lazily, and all the data that the user does not access will never be converted to JS, which means Nitro has to do less work:

Bad ❌
interface AllData {
rows: DataRow[]
}

interface BadDatabase extends HybridObject {
getAllData(): AllData
}

const database = // ...
const data = database.getAllData()
const row = data.rows
.find((r) => r.name === "Marc")
Good ✅
interface AllData extends HybridObject {
findRowWithName(name: string): DataRow
}

interface GoodDatabase extends HybridObject {
getAllData(): AllData
}

const database = // ...
const data = database.getAllData()
const row = data.findRowWithName("Marc")

The Bad example is significantly slower than Good, because Nitro has to convert all rows to JS, and there could be thousands of rows - even if we only use the row with the name "Marc".

The Good example is significantly faster than Bad because the result of getAllData() is a Hybrid Object, and all the thousands of rows do not have to be converted to JS at all, instead they are simply held in native memory. The function findRowWithName(...) iterates through the list on the native side and finds the matching row - only this single row will then have to be converted to JS.

Properly use memorySize

Since Hybrid Objects are implemented in native code, the JS runtime does not know the memory size of such objects. To let the JavaScript runtime know about a Hybrid Object's actual size in memory, Nitro exposes a memorySize (or getExternalMemoryPressure()) API which you can use to give a rough estimation on the native object's memory size (including any heap allocations you perform):

class HybridImage : HybridImageSpec {
private var cgImage: CGImage
public var memorySize: Int {
let imageSize = cgImage.width * cgImage.height * cgImage.bytesPerPixel
return getSizeOf(self) + imageSize
}
}

That way the JS garbage collector knows how big an Image is exactly in memory, and can delete any unused Image objects sooner to free up the native memory (cgImage), potentially avoiding memory warnings or garbage collector panics.

Avoid too many native calls

While Nitro is insanely fast, there is still an unavoidable overhead associated with calling native code from JS. In general, it is a good practice to stay within one environment (here; JavaScript) as long as possible, and only call into native when really needed.

Some things (like the Math.add(...) function I often use) are faster in JavaScript, as the overhead of calling into native might be greater than the overall execution time of the function.

Use C++ if possible

If possible, write Nitro Modules in C++. This is faster as bridging to Swift or Kotlin is not required.

Avoid large arrays

Large arrays have to be deep-copied, as they are immutable memory in JS. If possible, avoid sending large arrays back and forth - this is the same principle as "Use Hybrid Objects to implement proxy-results".