react-native-nitro-image-pipeline
v1.6.0
Published
High-performance image loading, caching, and processing for React Native, built with Nitro Modules
Maintainers
Readme
react-native-nitro-image-pipeline
A high-performance image loading, caching, and processing library for React Native, built with Nitro Modules.
Features
- Load images from the network (with built-in memory and disk caching), the file system, or bundled
require()assets - Prefetch single or multiple images in the background
- Resize (aspect-fill, center-crop) and apply Gaussian blur and rounded corners (uniform or per-corner) at load time
- Apply Gaussian blur to already-loaded images
- Clear the image cache on demand
useImagehook for declarative image loading in components
Requirements
- React Native v0.76.0 or higher
- Node 18.0.0 or higher
[!IMPORTANT] To support
Nitro Viewsyou need React Native v0.78.0 or higher.
Installation
# npm
npm install react-native-nitro-image-pipeline react-native-nitro-modules react-native-nitro-image
# pnpm
pnpm add react-native-nitro-image-pipeline react-native-nitro-modules react-native-nitro-image
# bun
bun add react-native-nitro-image-pipeline react-native-nitro-modules react-native-nitro-imageUsage
<PipelineImage> component
The zero-math way to load an image in a component — no manual PixelRatio conversions:
import { PipelineImage } from 'react-native-nitro-image-pipeline';
function MyComponent() {
return (
<PipelineImage
url="https://example.com/photo.jpg"
style={styles.photo} // bitmap is sized to this layout × PixelRatio.get()
blur={2} // points, like style
/>
);
}
const styles = StyleSheet.create({
photo: { width: 300, height: 200, borderRadius: 12 }, // baked into the bitmap
});Numeric width/height in style load immediately; percentage or flex-based sizes wait for the
first onLayout before fetching, so the full-size image is never requested just to be squeezed
into a small view. blur is in points on this component only — it's converted to bitmap
pixels internally, unlike the pixel-based values used everywhere else in this library.
cornerRadius works the same way, but if you don't pass it, it's derived instead from style's
borderRadius (or the per-corner borderTopLeftRadius/etc. properties, e.g. a "ticket" shape) —
so a style that already rounds the view rounds the bitmap too, with no separate prop. Pass
cornerRadius explicitly to override that. onLoad/onError callbacks are supported, and every
other prop (resizeMode, recyclingKey, testID, …) is passed straight through to
NativeNitroImage.
<NativePipelineImage> component
The fully native-driven variant of <PipelineImage>, for when per-image JS work matters (long,
fast-scrolling lists): after the first render there are zero JS round trips per image. The
native view starts the request the moment it attaches to the window — at its own laid-out size,
so nothing waits for an onLayout event to reach JS — and cancels it (releasing the bitmap) when
it detaches, which makes off-screen list cells free. Re-attaching hits the shared memory cache,
so recycled cells re-display instantly.
import { NativePipelineImage } from 'react-native-nitro-image-pipeline';
<NativePipelineImage
url="https://example.com/photo.jpg"
style={styles.photo} // size measured natively; borderRadius baked into the bitmap
blur={2} // points, like PipelineImage
/>;blur/cornerRadius are in points and style's borderRadius is picked up automatically,
exactly like <PipelineImage>. The trade-offs of going fully native:
- No
onLoad/onError— the loadedImagenever crosses into JS. Use<PipelineImage>oruseImagewhen you need them. - The bitmap is loaded once at the size the view first has; if the view resizes later, the bitmap scales with it instead of reloading.
Under the hood this is NitroImagePipeline.createImageLoader(url, options) — an
ImageLoader driven by
NativeNitroImage — so you can also use the usePipelineImageLoader(url, options) hook directly
with your own <NativeNitroImage image={loader} />.
Animating with react-native-reanimated
<PipelineImage> forwards its ref to the underlying NativeNitroImage host view, so it can be
passed straight to Animated.createAnimatedComponent — from
react-native-reanimated or React Native's
built-in Animated:
import Animated, { FadeIn, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import { PipelineImage } from 'react-native-nitro-image-pipeline';
const AnimatedPipelineImage = Animated.createAnimatedComponent(PipelineImage);
function Photo({ url }: { url: string }) {
const pressed = useSharedValue(false);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: withSpring(pressed.value ? 1.1 : 1) }],
}));
return (
<AnimatedPipelineImage
url={url}
entering={FadeIn}
style={[styles.photo, animatedStyle]}
onTouchStart={() => (pressed.value = true)}
onTouchEnd={() => (pressed.value = false)}
/>
);
}
const styles = StyleSheet.create({ photo: { width: 300, height: 200, borderRadius: 12 } });Layout animations (entering/exiting) work as on any animated component, and wrapping a plain
<PipelineImage> in an Animated.View is always an option if you'd rather not create one.
Because the pipeline bakes its processing into the bitmap at load time, the props fall into two groups — view-layer properties that animate freely, and bitmap properties that don't:
transformandopacity— the ideal case: they run entirely on the UI thread and never touch the bitmap. Prefer ascaletransform over animatingwidth/height.width/height— the animation itself works (Reanimated drives the native view directly), but the bitmap doesn't follow it. With numeric dimensions instyle(including an animated style's initial values) the bitmap is decoded once at that size and stretched by the view while it animates; with flex/percent sizing the size comes fromonLayout, which fires repeatedly during the animation and requests a new variant each time. Animate ascaletransform instead and let the layout settle where it will.borderRadius— by default the component bakesstyle'sborderRadiusinto the bitmap; an animated radius updates only the view layer, so the baked rounding wins and stays stale. To animate rounding, opt out of baking withcornerRadius={0}and round at the view layer instead:overflow: 'hidden'plus the animatedborderRadius.blur— not animatable. It's a load-time bitmap operation behind an async native call, not a view property, so a changingblurre-runs the pipeline per value — far too slow to drive per-frame. To animate blurriness, render the sharp and blurred variants as two stacked<PipelineImage>s and cross-fade the blurred one'sopacity(both share the URL cache, so the second variant loads from the same fetched source):
function BlurFade({ url, blurred }: { url: string; blurred: boolean }) {
const blurOpacity = useAnimatedStyle(() => ({
opacity: withTiming(blurred ? 1 : 0),
}));
return (
<View style={styles.photo}>
<PipelineImage url={url} style={StyleSheet.absoluteFill} />
<Animated.View style={[StyleSheet.absoluteFill, blurOpacity]}>
<PipelineImage url={url} blur={12} style={StyleSheet.absoluteFill} />
</Animated.View>
</View>
);
}Local images and require()
Every url in this library — <PipelineImage>, <NativePipelineImage>, useImage,
usePipelineImageLoader — also takes a require()d asset, and any url string may point at the
file system. The image goes through the same pipeline, so a bundled logo or a photo from the camera
roll gets the same resize-to-layout, blur and rounded corners as a download:
// A bundled asset — streamed from Metro in debug, read from the app bundle /
// resources in release, at the scale that matches the screen (like <Image>).
<PipelineImage url={require('./assets/logo.png')} style={styles.logo} blur={2} />
// A file on disk — a `file://` URL or a plain absolute path, e.g. the path
// react-native-nitro-image's `saveToTemporaryFileAsync` returns.
<NativePipelineImage url={`file://${photoPath}`} style={styles.thumb} />
<NativePipelineImage url={photoPath} style={styles.thumb} />The direct NitroImagePipeline.loadImage/createImageLoader calls take a string; resolve a
require() first with resolveImageUrl:
import { NitroImagePipeline, resolveImageUrl } from 'react-native-nitro-image-pipeline';
const logo = await NitroImagePipeline.loadImage(resolveImageUrl(require('./assets/logo.png')), {
resize: { width: 200, height: 200 },
});Accepted url forms: http(s)://, file://, a plain absolute path, data:, and on Android also
content:// URIs and bare drawable resource names (what require() resolves to in a release build
there). Local sources are cached in memory only — there is nothing to gain from copying a file
that is already on disk into the disk cache — so cache: 'disk' on a local url means no caching,
and preLoadImage(s) treats local sources as a no-op.
useImage hook
The simplest way to load an image in a component:
import { PixelRatio, useImage, resizeForStyle } from 'react-native-nitro-image-pipeline';
function MyComponent() {
const { image, error } = useImage({
url: 'https://example.com/photo.jpg',
blur: 4, // Gaussian sigma in bitmap pixels — same result on iOS and Android
// Resize to the size you display (points × screen scale) so the corner
// radii apply 1:1 to what you see instead of the full-resolution source.
resize: resizeForStyle(styles.image), // display size × PixelRatio.get()
cornerRadius: 12 * PixelRatio.get(), // bitmap pixels
});
if (error) return <Text>Failed to load image</Text>;
if (!image) return <ActivityIndicator />;
// use `image` with react-native-nitro-image
return <NitroImage image={image} style={styles.image} />;
}
const styles = StyleSheet.create({ image: { width: 300, height: 200 } });Pass enabled: false to defer the request — used internally by <PipelineImage> to wait for
layout before it has a size to resize to.
Direct API
import { NitroImagePipeline } from 'react-native-nitro-image-pipeline';
// Load an image with options. resize and cornerRadius are in pixels of the
// produced bitmap: without resize, the radius applies to the full-resolution
// source and shrinks along with it when displayed small.
const image = await NitroImagePipeline.loadImage('https://example.com/photo.jpg', {
blur: 4, // Gaussian sigma in bitmap pixels — see "Blur units"
resize: { width: 600, height: 400 }, // aspect-fill + center-crop, exact output size
cornerRadius: 12,
cache: 'disk',
});
// Per-corner radii — e.g. a "ticket" shape with larger bottom corners.
// The rounding is baked into the bitmap, so no view-layer masking is needed.
const ticket = await NitroImagePipeline.loadImage('https://example.com/photo.jpg', {
resize: { width: 600, height: 400 },
cornerRadius: { topLeft: 24, topRight: 24, bottomLeft: 48, bottomRight: 48 },
});
// Prefetch a single image
await NitroImagePipeline.preLoadImage('https://example.com/photo.jpg');
// Prefetch multiple images
await NitroImagePipeline.preLoadImages([
'https://example.com/a.jpg',
'https://example.com/b.jpg',
]);
// Apply Gaussian blur to an already-loaded image
const blurred = await NitroImagePipeline.gaussianBlur(image, 10);
// Clear the image cache
await NitroImagePipeline.clearCache();API Reference
loadImage(url, options?)
Loads an image from a URL and returns a Promise<Image>. url is a string — http(s)://, file://,
a plain absolute path, or the other forms listed under
Local images and require(); pass a require() through
resolveImageUrl first.
| Option | Type | Default | Description |
|---|---|---|---|
| blur | number | 0 | Gaussian blur strength applied at load time — see Blur units |
| resize | { width, height } | source size | Target bitmap size in pixels. Scales to fill and center-crops (CSS object-fit: cover, upscaling if needed) before blur/cornerRadius run, so their pixel units refer to this final size. Typically your display size in points × PixelRatio.get() |
| cornerRadius | number \| CornerRadii | 0 | Corner radius in pixels of the produced bitmap — a single number for all four corners, or { topLeft?, topRight?, bottomLeft?, bottomRight? } for independent per-corner radii (omitted corners stay square). Pair with resize for radii that match your layout |
| cache | 'memory' \| 'disk' \| 'none' | platform default | Caching strategy |
<PipelineImage>
| Prop | Type | Default | Description |
|---|---|---|---|
| url | string \| number | — | Image to load: a URL string (https://, file://, an absolute path) or a require()d asset |
| style | StyleProp<ViewStyle> | — | Layout style; also determines the resize target (see resizeForStyle) and, if cornerRadius is omitted, the corner radius (see cornerRadiusForStyle) |
| blur | number | 0 | Gaussian blur strength, in points (converted to bitmap pixels internally) |
| cornerRadius | number \| CornerRadii | derived from style | Corner radius, in points (converted to bitmap pixels internally). When omitted, derived from style's borderRadius/borderTopLeftRadius/etc.; square if neither is set |
| cache | 'memory' \| 'disk' \| 'none' | platform default | Caching strategy |
| onLoad | (image: Image) => void | — | Called when the image finishes loading |
| onError | (error: Error) => void | — | Called if loading fails |
| onLayout | (event: LayoutChangeEvent) => void | — | Standard View layout callback; also drives the deferred resize for non-numeric sizes |
| ref | Ref<PipelineImageRef> | — | Forwarded to the underlying NativeNitroImage host view — gives access to native-view methods (measure, …) and makes the component work with Animated.createAnimatedComponent (see Animating) |
| …NativeNitroImage props | — | — | Everything else (resizeMode, recyclingKey, testID, …) is passed through to NativeNitroImage |
<NativePipelineImage>
| Prop | Type | Default | Description |
|---|---|---|---|
| url | string \| number | — | Image to load: a URL string (https://, file://, an absolute path) or a require()d asset |
| style | StyleProp<ViewStyle> | — | Layout style. The native side measures the view and loads at that size; borderRadius-family properties drive cornerRadius when it's omitted |
| blur | number | 0 | Gaussian blur strength, in points (screen scale applied natively) |
| cornerRadius | number \| CornerRadii | derived from style | Corner radius, in points (screen scale applied natively) |
| cache | 'memory' \| 'disk' \| 'none' | platform default | Caching strategy |
| resize | { width, height } | measured from the view | Explicit target bitmap size in pixels, skipping the native measurement. Rarely needed |
| ref | Ref<NativePipelineImageRef> | — | Forwarded to the underlying NativeNitroImage host view |
| …NativeNitroImage props | — | — | Everything else (resizeMode, recyclingKey, testID, …) is passed through; recyclingKey defaults to url |
No onLoad/onError: loading happens entirely natively and the result never crosses into JS.
createImageLoader(url, options?) / usePipelineImageLoader(source, options?)
Creates the ImageLoader that powers
<NativePipelineImage>, for use with your own <NativeNitroImage image={loader} />. The view
calls into it natively when it attaches (load at the view's laid-out size) and detaches
(cancel + release). options takes blur/cornerRadius in points and an optional
pixel-based resize override — see the ViewOptions type. The hook memoizes by value, so
inline options literals are fine, and its source may be a require() as well as a URL string.
loader.loadImage() also works imperatively and resolves with the processed Image.
resolveImageUrl(source)
Turns an ImageSource (string | number) into the URL string the native pipeline loads: strings
pass through unchanged, a require()d asset is resolved with Image.resolveAssetSource to the
scale-matched variant. The components and hooks do this internally; use it when calling
loadImage, createImageLoader or preLoadImage(s) directly with a require(). Throws if the
number is not a registered asset; the components and hooks never throw for one — useImage reports
it through error, and usePipelineImageLoader/<NativePipelineImage> load a URL no loader can
resolve, so the request fails at load time like a missing file.
resizeForStyle(style) / resizeForLayout(width, height)
Converts a layout size in points to a bitmap resize option in pixels. Returns
{ width, height } in whole pixels via PixelRatio.getPixelSizeForLayoutSize, or undefined for
non-numeric sizes (e.g. '100%', undefined) — resizeForStyle reads style.width/style.height,
resizeForLayout takes explicit numbers.
cornerRadiusForStyle(style)
Converts a view style's borderRadius/borderTopLeftRadius/borderTopRightRadius/
borderBottomLeftRadius/borderBottomRightRadius (in points) into a cornerRadius option — a
plain number for uniform borderRadius alone, or a CornerRadii object once any per-corner
property is set (falling back to borderRadius for the corners left unset). Returns undefined
when none are set. This is what <PipelineImage> uses internally when its cornerRadius prop is
omitted.
preLoadImage(url)
Prefetches a single image into the disk cache, without decoding it. Returns Promise<void>.
Local sources (file://, paths, resources) have no download to cache and are a no-op.
Prefetching only pays the network and disk I/O cost up front — no bitmap is decoded or held in
memory, so prefetching a long list of URLs doesn't balloon RAM. The image is decoded (at the
resize target size, when one is given) the first time loadImage/useImage/<PipelineImage>
actually displays it.
preLoadImages(urls)
Prefetches multiple images into the disk cache — same behavior as preLoadImage, for a batch.
Returns Promise<void>.
gaussianBlur(image, radius)
Applies a Gaussian blur to an existing Image object. Returns Promise<Image>. radius uses the
same unit as the blur option — see Blur units.
Blur units
blur (and gaussianBlur's radius) is the standard deviation (sigma) of the Gaussian, in
source-image pixels. The same value on the same source file produces the same result on iOS and
Android — the platforms are calibrated against each other rather than each exposing its native
backend's own idea of "radius".
// ~11px of blur on both platforms, whatever the device
await NitroImagePipeline.loadImage(url, { blur: 11 });Two things follow from the unit being source pixels:
- Blur is measured against the image's own resolution, not the size it is displayed at. A 4000px
photo at
blur: 11looks subtler than a 400px thumbnail atblur: 11. To keep a feed visually consistent, scale the value with the source width. - Coming from React Native's
<Image blurRadius={n} />? That halves its input internally, soblurRadius={n}≈blur: n / 2. RN's value is also density-scaled on Android and not on iOS, which is why the two never quite matched there.
Values below ~1 are smaller than the smallest kernel either backend can build and are effectively a no-op. There is no upper bound.
Implementation: both platforms run the same three box-convolution passes sized to hit the requested
sigma (the standard three-box Gaussian approximation, accurate to a few percent) — iOS through
Accelerate's vImageBoxConvolve_ARGB8888, Android through a C++ port of that kernel working directly
on the bitmap's pixels. The two are checked against each other on every CI run and produce
byte-identical output for the same input. Both clamp at the edges, so blurred images keep their
borders instead of fading out.
setMemoryCacheLimit(bytes)
Caps the in-memory cache of decoded bitmaps at bytes, evicting least-recently-used entries
immediately if the cache is currently larger. Pass 0 to disable in-memory caching entirely — the
disk cache keeps working. Synchronous; throws on negative or non-finite values.
// Keep at most 32 MB of decoded bitmaps in RAM
NitroImagePipeline.setMemoryCacheLimit(32 * 1024 * 1024);
// Or opt out of decoded-bitmap caching altogether
NitroImagePipeline.setMemoryCacheLimit(0);clearCache()
Removes all cached images from memory and disk. Returns Promise<void> that resolves once both caches are cleared.
Memory usage
The pipeline is set up so RAM scales with what you display, not with what you download:
- Pass
resize(or just use<PipelineImage>, which derives it from layout). With a target size known, both platforms decode the source near that size instead of at full resolution — iOS via a downsampled thumbnail decode, Android via Coil's subsampling. Withoutresize, a 48 MP photo decompresses to ~190 MB of bitmap no matter how small you display it. - Android draws transformed images from hardware bitmaps (API 26+). When a view loads its
image natively (
<NativePipelineImage>, or<NativeNitroImage image={createImageLoader(...)}>), a resized, blurred or rounded result is uploaded to aBitmap.Config.HARDWAREbitmap once and cached like that, so its pixels live in GPU memory instead of the native heap and each view drawing it skips a texture upload. In a 200-cell list of rounded thumbnails this cut the app's PSS by ~100 MB on a Pixel 6a. Images returned to JavaScript —loadImageandcreateImageLoader(...).loadImage()— skip that step, so their transformed results stay software bitmaps with readable pixels (toArrayBuffer/toBase64); the two paths cache under different keys only when that upload step is present. - Prefetching stores bytes, not bitmaps.
preLoadImage(s)writes the download to the disk cache and skips decoding entirely. - The in-memory cache is capped and tunable (defaults: 128 MB on iOS, 25% of the app's memory
class on Android). It holds decoded bitmaps for instant re-display and evicts
least-recently-used entries — also in response to memory warnings and backgrounding. Memory
profilers attribute this cache to the app; a plateau at the cap is expected and evictable, not a
leak. Lower the cap with
setMemoryCacheLimit, usecache: 'disk'orcache: 'none'on images you won't show again soon, andclearCache()to drop everything. - Coming from a setup with no decoded-image cache (e.g. loading files you downloaded
yourself)? Steady-state RAM will read higher here by design: after screens unmount, the cache
keeps their bitmaps around for instant re-display. For the old memory profile with the
pipeline's features intact, pass
cache: 'disk'on your requests or callsetMemoryCacheLimit(0)once — RAM then holds only the images currently referenced, and re-displays decode from the disk cache (cheap, since decodes are subsampled to the target size). - Android +
Image.dispose(): only calldispose()on images loaded withcache: 'disk'/cache: 'none'(or with the memory cache disabled). With the memory cache on, the returned image shares its bitmap with the cache, and disposing recycles a bitmap the cache may serve again. Withoutdispose(), images are freed by the JS garbage collector — their bitmap size is reported to it, so unreferenced images do get collected under pressure.
Upgrading from 0.3.x
blur and gaussianBlur(image, radius) changed meaning in 1.0. They used to hand the number
straight to each platform's native blur, and the two platforms disagreed about what it meant; now
both read it as a Gaussian sigma in source-image pixels (see Blur units).
| | what blur: n did in 0.3.x | what it does in 1.0 |
|---|---|---|
| iOS | fed n to CIGaussianBlur(inputRadius:), measured at sigma ≈ 1.18 × n | sigma = n |
| Android | RenderScript on a copy downscaled to 512px, so strength scaled with the source resolution: sigma ≈ (0.4n + 0.6) × max(w, h) / 512 | sigma = n, resolution-independent |
To keep the look you had:
- iOS: multiply your old value by ~1.18 (
blur: 10→blur: 12). - Android: there is no single factor — the old result depended on the source image's resolution. Re-tune against iOS, which the two platforms now agree with.
Also changed:
blurabove 25 used to reject the promise on Android. Sigma is now unbounded.- Fractional values used to be truncated to whole numbers on iOS. They are honoured now.
- Blurred images used to fade out at the borders on iOS. Edges are clamped on both platforms now.
Credits
Bootstrapped with create-nitro-module.
Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
