Using shared objects

Edit page

Learn how to use a shared object from the Expo Modules API.


Shared objects let you expose long-lived native instances from Android and iOS to your app's JavaScript/TypeScript without giving control of their lifecycle. They can be used to keep heavy state objects, such as a decoded bitmap, alive across React components, rather than spinning up a new native instance every time a component mounts.

In this guide, let's understand what a shared object is and how they are implemented in native platforms.

What is a shared object?

A shared object is a custom class that bridges a native instance from Android and/or iOS to your app's JavaScript/TypeScript code through an Expo module. On the native side in Kotlin and Swift, you declare the class by inheriting from SharedObject and expose it in your module definition using Class(). A shared object is deallocated automatically once neither JavaScript nor native holds a reference.

Why use shared objects?

Large media assets, like images, can exceed several megabytes once decoded into memory. Without shared objects, passing these assets between different parts of your app forces each part to reload from the disk every time and decode the same file multiple times. This can increase memory pressure, create an I/O bottleneck, drop frames, or cause battery drain.

Shared objects solve this by keeping a single native instance alive in memory while multiple JavaScript references point to it.

Example: Image manipulation without disk I/O

To understand shared objects, consider an example where you need to rotate and flip an image picked by the app user and then display that image in your app after manipulating it.

Without shared objects

Historically, native modules were often written in a stateless way, where each function operated independently without maintaining state between calls. If you wanted to perform two separate operations on the same object (such as an image file), you would load it from disk in both places and repeat the I/O operation each time.

Without shared objects, ImagePicker reads from a file URI such as "file:///path/to/image.jpg" and decodes the image into memory. The image manipulator module then reads the same URI and decodes the image into memory again. When the app user calls a transform method (for example, rotate()) to rotate the image, the module saves the rotated image to a new file. Finally, when the new URI is passed to the Image component, it decodes the image from disk again to render the image. This workflow results in two or more decode and disk read operations.

With shared objects

The same scenario becomes much more efficient with shared objects. ImagePicker reads the URI and decodes the image once into a shared object. When the app user calls a transform method (for example, rotate()), the module manipulates the in-memory bitmap without writing to the disk. If you need a file output, call an explicit save function (for example, saveAsync in an image manipulator), otherwise the transforms stay in memory only.

Finally, the shared object is passed to the Image component and this time the image is rendered from memory. The entire workflow requires just one disk read and one decode operation, with all transformations happening in memory.

The performance gains are significant. By eliminating redundant disk I/O and decode operations, you keep only a single bitmap in memory instead of multiple copies. This reduces CPU usage, which helps preserve battery life, and lowers the risk of crashes from memory pressure.

Shared objects also unlock a more convenient, object-oriented API shape. You can expose methods on a long-lived instance (for example, rotate(), flipX(), renderAsync()) and let callers chain operations on that stateful object, instead of exposing a flat set of stateless functions.

Implementation with shared objects

Now that you understand why shared objects are useful, let's look into a minimal implementation that demonstrates the core concepts of the previous example.

The example creates a simple image manipulation module that loads an image from a file path, applies transforms (rotate and flip) in memory, and exposes a shared reference that other modules can consume.

Android implementation

In Android, you create a shared object from SharedObject class provided by expo.modules.kotlin.sharedobjects.SharedObject. This class manages the decoded bitmap and exposes methods to manipulate it. The implementation keeps only the current image in memory and applies transforms in place, so you allocate a new bitmap only when a transformation like rotation or flip produces one:

import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.sharedobjects.SharedObject class ImageRef : SharedRef<Bitmap>() class SimpleImageContext( runtimeContext: RuntimeContext, bitmap: Bitmap ) : SharedObject(runtimeContext) { private var current: Bitmap = bitmap fun rotate(degrees: Float) = apply { val matrix = Matrix().apply { postRotate(degrees) } current = Bitmap.createBitmap(current, 0, 0, current.width, current.height, matrix, true) } fun flipX() = apply { val matrix = Matrix().apply { preScale(-1f, 1f) } current = Bitmap.createBitmap(current, 0, 0, current.width, current.height, matrix, true) } fun render(): ImageRef = ImageRef(current, runtimeContext) override fun sharedObjectDidRelease() { if (!current.isRecycled) current.recycle() } }

The above example is quite similar to the iOS implementation. However, there is one difference on Android: the sharedObjectDidRelease() method. This lifecycle callback is invoked when JavaScript releases all references to the shared object, providing an opportunity to clean up native resources.

When the result of this class is passed to another module, the render method returns an ImageRef, which is a specialized SharedRef<Bitmap> type that expo-image and other image-aware modules already understand.

The module definition exposes an async function to create the context and a class definition to bind methods. The Expo Modules API uses a declarative syntax where you specify the module name, functions to create instances, and a class definition that maps methods to the shared object:

import android.graphics.Bitmap import android.graphics.BitmapFactory import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class SimpleImageModule : Module() { override fun definition() = ModuleDefinition { Name("SimpleImageModule") AsyncFunction("createContextAsync") { path: String -> val bitmap = BitmapFactory.decodeFile(path) ?: throw Exceptions.IllegalArgument("Unable to decode image at $path") SimpleImageContext(runtimeContext, bitmap) } Class<SimpleImageContext>("Context") { Function("rotate") { ctx: SimpleImageContext, degrees: Float -> ctx.rotate(degrees) } Function("flipX") { ctx: SimpleImageContext -> ctx.flipX() } AsyncFunction("renderAsync") Coroutine { ctx: SimpleImageContext -> ctx.render() } } } }

In the above example, createContextAsync function decodes the bitmap from the file path and returns a new SimpleImageContext instance. Once the context exists, the rotate and flipX functions run synchronously because they only manipulate in memory. The renderAsync function is marked async to signal that it might involve copying or preparing the bitmap for consumption by other modules.

iOS implementation

In iOS, you create a shared object by inheriting from SharedObject class provided by ExpoModulesCore. This class manages the decoded bitmap and exposes methods to manipulate it. The implementation keeps only the current image in memory and applies transforms in place:

import ExpoModulesCore import UIKit final class ImageRef: SharedRef<UIImage> {} final class SimpleImageContext: SharedObject { private var current: UIImage init(path: String) throws { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let image = UIImage(data: data) else { throw Exceptions.InvalidArgument() } self.current = image super.init() } func rotate(by degrees: Double) { current = current.rotated(degrees: degrees) } func flipX() { current = current.withHorizontallyFlippedOrientation() } func render() -> ImageRef { return ImageRef(current) } }

In the above example, SimpleImageContext reads an image file and keeps a single UIImage in memory. The rotate and flipX methods mutate the current image in memory without touching the disk.

When the result of this class is passed to another module, the render method returns an ImageRef, which is a specialized SharedRef<UIImage> type that expo-image and other image-aware modules already understand.

Now, with the shared object class definition, you can expose it through your module definition. The Expo Modules API uses a declarative syntax where you specify the module name, functions to create instances, and a class definition that maps methods to the shared object:

public final class SimpleImageModule: Module { public func definition() -> ModuleDefinition { Name("SimpleImageModule") AsyncFunction("createContextAsync") { (path: String) -> SimpleImageContext in return try SimpleImageContext(path: path) } Class("Context", SimpleImageContext.self) { Function("rotate") { (ctx, degrees: Double) -> SimpleImageContext in ctx.rotate(by: degrees) return ctx } Function("flipX") { (ctx: SimpleImageContext) -> SimpleImageContext in ctx.flipX() return ctx } AsyncFunction("renderAsync") { (ctx: SimpleImageContext) -> ImageRef in return ctx.render() } } } }

In the above example, the createContextAsync is an asynchronous function because loading and decoding an image from disk is an I/O operation. Once the context exists, the rotate and flipX functions run synchronously because they only manipulate in memory. The renderAsync function is marked async to signal that it might involve copying or preparing the bitmap for consumption by other modules, though in this simple example, it returns immediately.

Using a shared object in your app

You can now use the shared object in your app's JavaScript/TypeScript code to load the image from the path, create a context of the loaded image, chain in-memory transforms, render to get a shared reference, and then pass that reference to an Image component:

import { useState } from 'react'; import { Button } from 'react-native'; import { Image } from 'expo-image'; import type { SharedRef } from 'expo'; import SimpleImageModule from 'simple-image-module'; // The native custom module import { pickImageAsync } from './pickImage'; // The custom TypeScript function export function SharedImageExample() { const [context, setContext] = useState(null); const [result, setResult] = useState<SharedRef<'image'> | null>(null); const load = async () => { const uri = await pickImageAsync(); if (!uri) { return; } const ctx = await SimpleImageModule.createContextAsync(uri); setContext(ctx); setResult(await ctx.renderAsync()); }; const rotateAndFlip = async () => { if (!context) { return; } setResult(await context.rotate(90).flipX().renderAsync()); }; return ( <> <Button title="Pick image" onPress={load} /> <Button title="Rotate 90° + flip X" onPress={rotateAndFlip} disabled={!context} /> {result && <Image source={result} style={{ width: 200, height: 200 }} />} </> ); }

In the above example, the React component only consumes the native image transformed by the image manipulator context, which is already in memory and referenced by the shared object (ImageRef). As a result, the image view can display the image immediately on the next frame, and chained transforms never touch the file system.

The JavaScript API picks an image using ImagePicker, which returns a standard file URI. This URI is handed to a custom native module to create a shared object in SharedImageExample():

import * as ImagePicker from 'expo-image-picker'; export async function pickImageAsync() { const result = await ImagePicker.launchImageLibraryAsync({ quality: 1, allowsMultipleSelection: false, }); if (result.canceled || !result.assets?.length) { return null; } // At this point we still have a disk URI. // The native module will lift it into a shared object. return result.assets[0].uri; }

In the above example, ImagePicker doesn't need to know about shared objects. It returns what it should, which is a file path. Your native module is responsible for transforming that path into a shared object that other Expo modules can work with, such as Image from expo-image.

Expo libraries that use shared objects

Some examples of Expo SDK libraries that use shared objects and their purpose:

  • expo-image library uses SharedObject to keep a decoded operation alive and the view component accepts SharedRef<Bitmap> on Android and SharedRef<UIImage> on iOS. This design allows images to be passed between modules without decoding it again. To explore more, see expo-image library's source code for Android and iOS.
  • expo-image-manipulator library demonstrates handling asynchronous operations, queuing multiple operations, and exposing a clean JavaScript API. To explore more, see expo-image-manipulator library's source code for Android and iOS.
  • expo-sqlite library uses shared objects to keep database, session, and statement handles across calls while coordinating access to the underlying database. To explore more, see expo-sqlite library's source code for Android and iOS.
  • expo/fetch library uses shared objects to keep request and response lifecycles alive for streaming, cancellation, and redirect handling while presenting a JavaScript fetch-compatible API. To explore more, see expo/fetch library's source code for Android and iOS.

Performance benefits of shared objects

Using shared objects provides several performance improvements, such as:

  • Reduced disk I/O: A single read operation instead of multiple reads across different modules or function calls
  • Fewer decode operations: Expensive decoding (such as JPEG/PNG to bitmap) happens once, not repeatedly
  • Lower memory pressure: One decode instance in memory instead of multiple copies
  • Faster operations: In-memory transformations are significantly faster than disk-based ones
  • Avoid frame drops: Less I/O blocking means smoother UI interactions

Additional resources

The real-world impact of Shared Objects in Expo Modules

Shared Objects solve a lot of fundamental problems with Expo APIs and also unlock a whole new way to design object-oriented APIs.