
Two years ago, my team started working on a new project: developing a cross-platform app for health studies that records sensor data from participants, encrypts it, and sends it to a backend, where researchers can analyze it using a study app.
We chose React Native because of its strong community, extensive libraries, and good integration with native modules.
For the persistence layer, we evaluated numerous React Native modules and were quickly convinced by the performance of react-native-mmkv
, an adaptation of Tencent’s MMKV — a memory-mapped key-value store — for React Native. While we could store all low-frequency data without issues using MMKV, we ran into problems with the high-frequency time-series data from IMUs. MMKV wasn’t designed as a time-series database, and its key indices weren’t suitable for storing every data point individually (key = timestamp, value = data point). Additionally, it didn’t provide a way to append data to an existing value.
We settled on a compromise: splitting recordings into data chunks and storing them that way.
However, the technical debt that came with this approach felt unsatisfying, and the lack of a library similar to react-native-fs
— one that could store time-series recordings like files while also supporting fast encryption and decryption in real-time during random access — was frustrating. A custom solution was needed.
I already had some ideas for the implementation. I wanted:
- Memory mapping instead of system-call-based file access
- AES encryption for stored data, meaning that
write()
should encrypt data on the fly, andread()
should decrypt it in real time - Support for random access, so that, e.g., when appending to the end of a recording, only the end of the file needs to be encrypted and written
To my surprise, these three requirements were the easiest to implement. Thanks to ARM NEON and Intel AES-NI instructions, the AES cipher was fast and could be implemented with just a few lines of code. To enable random access encryption, I chose AES-CTR (Counter Mode) — a stream cipher also used for file system encryption. As a stream cipher, it generates a pseudo-random bitstream from a key and IV, which is XORed with the data. XORing the bitstream again during decryption restores the original data. The advantage of CTR mode is that the bitstream can be computed at any position without having to start from the beginning.
Turbo Modules
When I wanted to provide the C++ implementation as a pure C++ native module for React Native, Turbo Modules seemed like the best option. I followed the tutorial, and after some struggles, I was able to call my C++ functions from React Native.
The idea behind Turbo Modules is that you define an interface in TypeScript, which a code generator (codegen) then uses to create a C++ interface that must be implemented in C++. For React Native to recognize and use the module, it needs to be registered on Android/iOS and referenced under the same name in React Native.
But...
- Unfortunately, Turbo Modules don’t make it easy to work with ArrayBuffers in function arguments, e.g.
write(offset: number, buffer: ArrayBuffer)
Handling this properly requires additional indirection and fully manual function argument handling, resulting in a lot of boilerplate code. - While the tutorial makes it easy to set up an app that uses native functions, it doesn’t explain how to package everything as a separate React Native module.
- Even with react-native-builder-bob, I couldn’t get the C++ part of my module to compile and register successfully as part of a React Native app. The issue lay somewhere deep in CMake, where Turbo Modules are supposed to be automatically discovered, built as a Gradle target, and linked. 😬
Enter Nitro Modules
Luckily, I discovered Nitro Modules, developed by Marc Rousavy, the author of react-native-mmkv. Not only do Nitro Modules allow direct usage of ArrayBuffers, but they also support Promises, interface inheritance, optional arguments, and everything runs with the performance of the new React Native architecture — synchronously, with direct access to JS objects and no JS bridge. Native bindings are also generated by a code generator (Nitrogen).
Thanks to the great documentation, switching from Turbo Modules to Nitro Modules was easy, and I was able to eliminate a huge amount of boilerplate code. For example, to implement a write(offset: number, buffer: ArrayBuffer)
method in a Mmfile object (which represents a file), you need to do this in Turbo Modules:
class MmfileHostObject : public jsi::HostObject
{
public:
std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime &runtime)
{
return jsi::PropNameID::names(runtime, "write", "read", "size", ...);
}
jsi::Value get(jsi::Runtime &runtime, const jsi::PropNameID &propNameId)
{
if (propName == "write")
{
// Mmfile::write(offset: number, buffer: ArrayBuffer)
return jsi::Function::createFromHostFunction(
runtime, jsi::PropNameID::forAscii(runtime, propName), 2,
[this](jsi::Runtime &runtime, const jsi::Value &thisValue, const jsi::Value *arguments, size_t count) -> jsi::Value
{
if (count == 2 && arguments[0].isNumber() && arguments[1].isObject()) [[likely]]
{
jsi::Object object = arguments[1].asObject(runtime);
if (object.isArrayBuffer(runtime)) [[likely]]
{
jsi::ArrayBuffer arrayBuffer = object.getArrayBuffer(runtime);
instance->write(arguments[0].getNumber(), arrayBuffer.data(runtime), arrayBuffer.size(runtime));
return jsi::Value::undefined();
}
}
throw jsi::JSError(runtime,
"Mmfile::write: First argument ('offset') must be a number and second argument ('data') must be an ArrayBuffer");
});
}
// ...
}
private:
MMapFile *instance;
};
With Nitro Modules, this is all you need:
class HybridMmfile : public HybridMmfileSpec
{
public:
void write(double offset, const std::shared_ptr<ArrayBuffer>& buffer) {
instance->write((size_t)offset, buffer->data(), buffer->size());
}
//...
private:
MMapFile *instance;
}
Promises
It’s also just as easy to implement asynchronous functions that return their results as Promises. For example, to return the contents of a directory, you define your readDir(path: string)
function interface in TypeScript:
export interface ReadDirItem {
mtime: number; // The last modified date of the file (seconds since epoch)
name: string; // The name of the item
path: string; // The absolute path to the item
size: number; // Size in bytes
isFile: boolean; // Is the item just a file?
isDirectory: boolean; // Is the item a directory?
}
export interface MmfilePackage extends HybridObject<{ ios: 'c++', android: 'c++' }> {
readDir(path: string): Promise<ReadDirItem[]>
// ...
}
And you provide the implementation directly in C++. Everything else is handled by Nitrogen.
class HybridMmfilePackage : public HybridMmfilePackageSpec
{
std::shared_ptr<Promise<std::vector<ReadDirItem>>> readDir(const std::string& path) {
std::string absPath = getAbsolutePath(path);
return Promise<std::vector<ReadDirItem>>::async([=]() -> std::vector<ReadDirItem> {
// This runs on a separate Thread!
std::vector<ReadDirItem> items;
try {
for (const auto& entry : std::filesystem::directory_iterator(absPath)) {
ReadDirItem item;
item.name = entry.path().filename().string();
item.path = entry.path().string();
item.isFile = entry.is_regular_file();
item.isDirectory = entry.is_directory();
item.size = item.isFile ? entry.file_size() : 0;
item.mtime = std::chrono::duration_cast<std::chrono::seconds>(entry.last_write_time().time_since_epoch()).count();
items.push_back(item);
}
} catch (const std::exception& e) {
// Handle errors (e.g., directory does not exist, permissions issue)
throw std::runtime_error(std::string("Failed to read directory: ") + e.what());
}
return items;
});
}
// ...
}
The full implementation is available as the react-native-mmfile
package on GitHub and npm.
Results
To compare performance, I measured the total time required to write a 1MB file by appending chunks. The chunk size varied from small 16-byte chunks to a single 1MB chunk. It’s no surprise that react-native-mmkv
and react-native-mmfile
both outperform react-native-fs
, as the latter was built using the old architecture. Additionally, as explained above, react-native-mmkv
does not support appending, so it should be used with large chunk sizes. However, even for a single 1MB append, it is 8x slower.
Nitro Modules are a framework for building fast, type-safe native modules in React Native. They use statically compiled bindings via JSI, allowing seamless integration of JavaScript with native code written in C++, Swift, or Kotlin. Key features include cross-platform support, compile-time type safety, and minimal runtime overhead. Nitro Modules outperform alternatives like Turbo Modules and Expo Modules in speed and efficiency, and easy of use. Definitely my first choice for native modules in React Native.