Kolibri is the JNI library under Expo Modules 2.0 on Android. Calls into C++ objects are up to 51 times faster than with fbjni. JS objects reach Kotlin 3 to 5 times faster, because Kolibri passes them in a binary buffer instead of building them with JNI.
In an early look at Expo Modules 2.0, we showed the new way to write native modules on iOS and said that Android was still in the works. This post is about Kolibri, the JNI library that Expo Modules 2.0 uses on Android, which we built to replace fbjni.
TL;DR
JNI is the slowest part of every call between Kotlin and C++ on Android, and wrappers like fbjni make each call slower, because every safety feature they add costs time on every call. Kolibri, the JNI library under Expo Modules 2.0, stays close to raw JNI and lets you turn those features off where you don't need them. It skips the exception check for methods that can't throw, and it takes the JNIEnv as an argument instead of looking it up. For hybrid objects, a Kotlin compiler plugin passes the C++ pointer straight into native code, and scopes release everything tied to a JS runtime at once. Instead of building Java objects one JNI call at a time, Kolibri moves JS data to Kotlin in a shared binary buffer. The result: calls from C++ into Java are as fast as raw JNI, calls into C++ objects are 28 to 51 times faster than with fbjni, and a JS object reaches Kotlin 3 to 5 times faster in the binary buffer than when it's built one JNI call at a time.
Kolibri has three parts: a C++ library that depends on nothing but <jni.h>, a small Kotlin runtime, and a Kotlin compiler plugin that removes JNI boilerplate at compile time. Before we get to how it works, we need to cover a few concepts. If you already know JNI and fbjni, skip to Why we built our own.
What is JNI?
The Java Native Interface (JNI) is how native code talks to the Java Virtual Machine. From C or C++, it lets you call Java methods, create Java objects, read and write their fields, and change the state of the VM. It also lets Kotlin and Java code call functions written in C++.
JNI is old, and it was designed to be safe, not fast. In a typical call between Kotlin and C++, the JNI part is the slowest step. On Android, there is no alternative: if C++ needs to talk to Java, it goes through JNI.
Here is what a single method call looks like with raw JNI. It asks a Java List for its size:
Method lookup by name and string signature, an exception check after the call, and a reference to clean up at the end.
JNI references
C++ is not a managed language: you allocate memory, and you free it. Java is: the garbage collector decides when an object is no longer needed. When C++ wants to hold on to a Java object, the two models have to meet somewhere, and JNI connects them with references.
A JNI reference is a handle that the garbage collector knows about. While the reference is alive, the Java object behind it can't be collected. It's pinned in place. That's what makes it safe to use the object from C++. It's also the catch: every reference you create, you have to delete at some point, and in real code there are a lot of them.
JNI has two main kinds of references. Local references are what you get by default: every argument of a native method, and almost every object a JNI function returns, is one. A local reference is valid only during the current native method call, and only on the thread that created it. Global references are the ones you create yourself with NewGlobalRef, when you need to keep an object after the call returns or use it on another thread.
fbjni and jni-bind
Wrapper libraries make JNI easier to use. The two most popular ones are fbjni from Meta and jni-bind from Google. Both are built mostly for safety: they manage references for you, so everything gets cleaned up automatically.
The two take different approaches. jni-bind is the simpler one and focuses on calling Java from C++. fbjni is more holistic. It also handles calls in the other direction, from Java into C++, and it adds hybrid objects: objects that live on both sides of the boundary, with a Java part and a C++ part that work together. We'll come back to those later.
In the React Native world, fbjni is the default. It ships with React Native itself, TurboModules use it, and so do many other libraries in the ecosystem.
Why we built our own
Kolibri keeps the safety net, but lets you take it off. In practice, there are two ways to write Kolibri code, and they work well side by side:
- You know what you're doing. You opt out of the safety features: no automatic memory management, no exception checks. You're responsible for cleaning everything up, and in return you get the fastest code JNI allows.
- You want the safety net. You get the same features as fbjni: managed references, automatic exception checks, and so on.
Real code needs both. Some paths are hot and have to be as fast as possible. Others are tricky, performance doesn't matter much there, and you want as little risk of a leak as you can get. fbjni is a great library, but moving between raw JNI and fbjni isn't smooth. You end up working around the library, because it wasn't designed for that. Kolibri was.
Take references. Kolibri has a Ref class that owns a JNI reference and deletes it when the Ref is destroyed. You'd use it when you describe a Java class in C++:
You don't have to wrap greeter or name in a Ref to call greet. Kolibri takes the raw JNI references as they are. That's the common case for arguments that came from Java, or for references that something else will release: you don't want C++ to manage them, you only want to pass them along, and you don't want to waste time creating wrappers around them. Kolibri works well with this pattern, and with many others like it.
What Kolibri does differently
Kolibri is written in C++20, and its API is built on modern C++ features. Together with a Kotlin compiler plugin, they let it do things the other libraries can't.
Skipping exception checks
In the raw JNI example, the call into Java is followed by ExceptionCheck(). When a Java method throws, JNI doesn't throw in C++. It leaves the exception pending, and you have to ask for it after the call. That check is a JNI call of its own, so it costs time too. By default, Kolibri makes it after every call and rethrows a pending Java exception as a C++ exception.
Many Java methods never throw, and you know that for sure. Kolibri lets you say so. It describes a Java method with two template arguments: its name as a string, and its signature as a plain C++ function type. Add noexcept to that function type, and the signature says that the method doesn't throw. Here's how Kolibri describes java.util.Iterator:
hasNext() doesn't throw, so its exception check is removed at compile time. next() can throw, so it keeps the check. This works for many methods in the Java standard library. It's only safe for methods that really can't throw, but when you know that, you don't need to pay for the check.
The check isn't cheap. In our benchmarks, it adds about 24 ns to a call to ArrayList.size() that takes 97 ns without it. That's a quarter of the whole call.
Passing JNIEnv explicitly
Every JNI operation goes through a JNIEnv, the handle you use to interact with the VM. It belongs to a thread, so before you can use it, you have to get the right one.
In fbjni, this is automatic. Whenever a function needs the env, it gets the current one by itself. That's convenient, but it means every fbjni call does a bit of extra work to find the env.
Hybrid objects with scopes
Kolibri has its own take on hybrid objects, called NativeObject. The concept is the same as in fbjni: a Java object and its C++ counterpart, which can talk to each other. The difference is in how they are deallocated: Kolibri adds the concept of a scope.
A scope is a group of objects that share a lifetime. When you create a C++ object, you can bind it to a scope: the current one, or any other one you choose. When the scope is invalidated, Kolibri notifies every object bound to it, and each one releases the resources that depend on that scope.
This matters for Expo Modules because of how we use hybrid objects there. They give Kotlin access to JS objects. That makes three parties: the state lives in JS, the Kotlin code wants to work with it, and C++ glues the two together. The C++ object holds the JS object and is the native half of a Kotlin object. They are bound, so when the Kotlin object is deallocated, its C++ part goes too, and with it the JS object.
In many cases, however, the JS side goes away first. A reload, for example, destroys the whole JS runtime and every JS object in it, while the Kotlin objects that point to them are still waiting for the garbage collector.
We can't leave anything behind. If the C++ object kept its JS object after the runtime was destroyed, it would hold a reference to something that is no longer valid. Later, when the Kotlin object is garbage collected, the C++ object would try to clean up a JS value that was already freed. That's a classic use-after-free bug. So everything tied to the runtime has to be released when the runtime goes away.
That's where scopes come in. We bind each of these hybrid objects to its JS runtime, and when the runtime is destroyed, we invalidate the scope. Here's a simplified version of the class we use in Expo Modules 2.0:
Kolibri's hybrid objects are built around this from the start, which makes this much easier than it is in fbjni.
Faster calls into hybrid objects
Hybrid objects are also faster to call in Kolibri. To see why, let's look at how they work.
In C++, you create the object and get a pointer to it. That pointer is passed to Java and saved in a field of the Java object: a plain long, a number that represents the address. The Java object owns the C++ one.
Now say you call method x on the Java object, and x is bound to a C++ method. JNI binds native methods to plain functions, with no this. So the C++ side has to find the object somehow. It knows how: the pointer is right there, in the Java object. In fbjni's classic hybrid, getting it takes five more JNI calls: three field reads, one after another, and two calls to delete the local references that the first two reads create. Then the pointer is cast back to the C++ type.
Kolibri uses a Kotlin compiler plugin to skip those calls. It's the same kind of build-time rewrite that removed reflection from Expo Modules in SDK 56. You write the same kind of code you would with fbjni, an external method on the Kotlin class, and mark it with @NativeMethod:
Under the hood, the plugin rewrites the method so the pointer is passed to C++ as the first argument:
On the C++ side, you register the member function directly, and Kolibri casts the pointer back to the object, with no JNI call:
The same works for arguments. Mark a parameter that is itself a NativeObject with @AsNativePointer, and its pointer crosses the boundary the same way.
In our benchmarks, this takes a call into a hybrid object from 76–138 ns with fbjni down to 2.7 ns.
Passing data in a binary format
Kolibri isn't built only to talk between C++ and Kotlin. Its main job is to power Expo Modules 2.0, where data travels from JS. So we have to look at the whole path.
Take a JS object. It's a key-value store, and on the Kotlin side it becomes a map. The usual way to convert it is to build that map from C++: for each key and each value, you create its Java counterpart through JNI. That's how TurboModules and Expo Modules 1.0 do it. It works, but it makes a lot of JNI calls, and as this whole post shows, JNI is the slowest part of the call.
We asked whether we had to make that many calls, and benchmarked the alternatives: different formats, and different ways to pass the data. We went with a binary format in a direct byte buffer. A direct ByteBuffer is a Java object that points to native memory, so C++ and Kotlin work on the same bytes.
Kolibri provides the primitives for this. Each thread gets one buffer, allocated in C++ and exposed to Kotlin as a direct ByteBuffer through BinaryBuffer.shared(). Kolibri also defines the binary format: tagged values for booleans, numbers, strings, lists, maps and byte arrays, plus a tag that consumers can use for their own schemas. It comes with the code to write and read that format on both sides.
Here's how Expo Modules 2.0 writes a JS object into the buffer in C++, slightly simplified:
Here's how Kotlin reads it back from the same memory:
Neither loop makes a JNI call. To see what that saves, we measured a call from JS into a Kotlin function that takes one argument and returns an Int. We passed the argument in two ways:
- Built as Java objects with JNI: C++ uses JNI to create a Java object for every value: a
Doublefor each number, aBooleanfor each boolean, aStringfor each string, anArrayListfor each array and aHashMapfor each object. Eachaddandputis one more JNI call. - Written to a binary buffer: C++ writes the argument into the buffer. Kotlin reads it from there and builds the same Java objects, with no JNI calls.
Both ways give the Kotlin function the same value, and the result comes back the same way, so the only difference is how the argument gets to Kotlin. We measured strings, objects and arrays. Each number is the time for the whole call, from JS to Kotlin and back:
| JS argument | Built as Java objects with JNI | Written to a binary buffer | Speedup |
|---|---|---|---|
| A 16-character string | 0.31 µs | 0.20 µs | 1.5× |
| An object with 4 fields | 2.14 µs | 0.71 µs | 3.0× |
| An object with 12 fields | 5.68 µs | 1.71 µs | 3.3× |
| A nested object with 48 values | 27.8 µs | 5.8 µs | 4.8× |
| An array of 100 objects with 4 fields | 211 µs | 52 µs | 4.0× |
| An array of 100 mixed values | 23.9 µs | 2.7 µs | 8.8× |
| An array of 100 numbers | 27.7 µs | 2.2 µs | 12.4× |
| An array of 1,000 numbers | 272 µs | 21 µs | 13.0× |
This is the format Expo Modules 2.0 uses to pass data, and we'll look at how it works in more detail in the next post.
Performance
To see what all of this buys us, we benchmarked Kolibri against raw JNI and fbjni in both directions: C++ calling into Java, and Kotlin calling into C++ objects. All three approaches do the same work on the same objects, and all three look up classes, methods and fields once, up front, the way real code caches them. Raw JNI is the baseline.
We ran everything on an Apple M3 Pro with JDK 17. For calls from C++ into Java, each number is the median time per call over 10 rounds of 2 million calls, after a warm-up. We ran that benchmark three times (six times for the method-call table) and report the median of the runs. Calls from Kotlin into C++ were measured with JMH. This is a desktop JVM, not an Android device, so absolute times on a phone will be different.
Calling into C++ objects from Kotlin
This is where the difference is the largest. Each benchmark calls a method on a hybrid object, and the method returns a value stored in the C++ part.
In the table, the second column also passes another hybrid object as an argument, so the C++ side has to find two objects.
| How the C++ side finds the object | One object | Two objects |
|---|---|---|
| Raw JNI, pointer passed as an argument | 2.5 ns | 2.5 ns |
| Kolibri, @NativeMethod | 2.7 ns | 2.7 ns |
| Raw JNI, pointer read with GetLongField | 4.1 ns | 6.6 ns |
| fbjni, HybridClassBase | 76 ns | 143 ns |
| fbjni, classic mHybridData | 138 ns | 272 ns |
With the compiler plugin, a call into a Kolibri object costs about the same as hand-written raw JNI that passes the pointer, and 28 to 51 times less than the same call through fbjni. To find the C++ object, fbjni's cthis() reads two or three fields. All but the last one hold Java objects, so each of those reads creates a local reference that has to be deleted again. The newer HybridClassBase style skips one read and one deletion, so it makes three JNI calls instead of five, and it's about twice as fast as the classic one.
Passing the pointer also beats reading it with GetLongField by hand, but on the desktop JVM, by less: 2.7 ns against 4.1 ns.
The call itself is cheaper too. A static native method with two int arguments and no object takes 2.2 ns with raw JNI and with Kolibri, and 9.6 ns with fbjni.
Calling Java from C++
| Call | Raw JNI | Kolibri | fbjni | Kolibri vs fbjni |
|---|---|---|---|---|
| ArrayList.size() | 98 ns | 96 ns | 134 ns | −29% |
| ArrayList.get(i) | 142 ns | 140 ns | 160 ns | −12% |
| HashMap.put("key", value) | 240 ns | 241 ns | 302 ns | −20% |
| Integer.MAX_VALUE (static field read) | 25 ns | 25 ns | 31 ns | −18% |
Kolibri is as fast as raw JNI here: every row is within about 2%, which is inside the noise of the benchmark. Where the raw JNI code checks for exceptions and deletes local references by hand, Kolibri does the same work for you, at no extra cost.
The biggest gap to fbjni is on size(). That method is marked noexcept in Kolibri, so there's no exception check at all, while fbjni still does one. To measure the check on its own, we also called size() with the check added back. Raw JNI goes from 97 ns to 121 ns, and Kolibri goes from 96 ns with noexcept to 121 ns without it. fbjni, which always checks, takes 134 ns.
The rest of the everyday operations look similar:
| Operation | Raw JNI | Kolibri | fbjni |
|---|---|---|---|
| Static method call (Integer.valueOf) | 144 ns | 140 ns | 164 ns |
| Constructor (new, one int argument) | 163 ns | 170 ns | 159 ns |
| Method with four int arguments | 125 ns | 126 ns | 132 ns |
| Read an int field | 2.1 ns | 2.1 ns | 7.4 ns |
| Write an int field | 26 ns | 26 ns | 31 ns |
| Create and delete a local reference | 55 ns | 53 ns | 96 ns |
| Create and delete a global reference | 78 ns | 82 ns | 120 ns |
| Copy an int[256] into a std::vector | 115 ns | 113 ns | 136 ns |
| Look up the current JNIEnv | 3.2 ns | 1.6 ns | 5.6 ns |
Kolibri stays within about 5% of raw JNI in every row except the JNIEnv lookup, where it is faster. fbjni is slower in all of them except the constructor, where it is as fast as raw JNI.
Why not improve fbjni instead?
Kolibri is tailor-made for our use case. It isn't a general-purpose JNI library. It only contains what we needed, and its API is deliberately minimal. fbjni covers a lot more, and we didn't need most of it. For example, Kolibri has almost no wrappers for java.lang classes. If you need one, you write it yourself.
Getting the same results by changing fbjni would have meant a huge change to the library. Kolibri covers the same ground as fbjni, but with a different approach to the same problem.
Tradeoffs
Kolibri isn't free. React Native already ships fbjni, so code built on fbjni costs nothing extra: no new library, no extra app size. Kolibri is one more library in your app, and one more library for us to maintain.
That cost is smaller than it looks, though. To make fbjni as fast as we needed, we would have had to work around it in a lot of places. That's a lot of extra code too, and the size would probably have ended up about the same. In the end, we think the tradeoff is worth it.
What's next
Kolibri is the foundation. In the next post, we'll look at how Expo Modules 2.0 is built on top of it, and where the rest of the speedup comes from.
If you write native modules for Android, we'd like to hear what you think in the #creating-expo-modules channel on the Expo Discord.


