Implementing the fastest list renderer for React Native
See the following comparison video between my list implementation (Skia List) and Shopify’s Flash List (the fastest list renderer for React Native to date).
The <Message />
components for both lists are heavy and have the same functionality: context menu, swipe actions, reactions and attachments.
You can see that SkiaList is rendering incredibly fast, with no blank spaces, and consistently at 120 FPS.
While FlashList can’t keep up when scrolling fast and has blank spaces.
Benchmark
For a quantitative comparison, see the benchmark below, which tested the rendering of 1000 list items and measured the following metrics:
Skia List | React Native’s FlatList | Shopify’s FlashList | |
---|---|---|---|
Dropped Frames | 52 | 164 | 196 |
Render Time | 442ms | 4417ms | 4165ms |
The benchmark was made on an iPhone 13 Pro Max with react-native version 0.75 (new architecture enabled).
The Render Time is the total time taken to render all list items.
Background
I started this project because I was frustrated with the performance of the current list renderers available for React Native.
Performance was particularly bad when rendering heavy UI components, such as chat messages, which contain many interactive/heavy elements.
I imagined there were the following bottlenecks that would need to be addressed:
- React reconciler overhead
- Layout measurement for each item
- Rendering heavy UI components
and I couldn’t think of a solution to these problems with React Native’s component architecture except for building the list item component natively.
However, I remembered that the Shopify team had built a Skia renderer for React Native, which allows you to render 2D graphics using the Skia rendering engine (btw. Skia is also used by Flutter and Chrome).
I thought using Skia to render the list items could be a viable alternative to natively building the list item component.
Mainly because it is fast, can render complex UI components from JS, and is very customizable.
React Native Skia rendering approaches
CanvasView with JS callback
The first approach was to use the <SkiaView />
component from react-native-skia
, which was available in version 0.1.229 and earlier.
It allowed you to render to the canvas imperatively using a JS callback that would be called on the main JS thread.
That approach worked well until you had some logic running on the main JS thread that would block the thread, which would lead to dropped frames and stutters when scrolling.
Image Offscreen Canvas
To combat the issue of blocking the main JS thread, react-native-skia provides the ability to render to an image offscreen canvas on a separate thread.
However, the issue is that for each frame, a new canvas has to be created, and the image has to be sent between the threads, resulting in high memory usage:

Picture Offscreen Canvas
The solution to the high memory usage was to use the Picture
API from Skia, which allows you to render to an offscreen canvas and share the SkPicture
between threads.
Even though the high memory issue was solved, this approach had the problem that it had 100% CPU usage and wasn’t performant enough.

Skia DOM
Skia DOM uses React to declaratively create and render Skia elements:
const width = 256;
const height = 256;
const r = width * 0.33;
return (
<Canvas style={{ width, height }}>
<Group blendMode="multiply">
<Circle cx={r} cy={r} r={r} color="cyan" />
<Circle cx={width - r} cy={r} r={r} color="magenta" />
<Circle cx={width / 2} cy={width - r} r={r} color="yellow" />
</Group>
</Canvas>
);

At first, I avoided using the Skia DOM because it used the react reconciler to update and render the Skia elements, which would have the same reconciler overhead as the currently available list renderers for React Native.
However, I realized that I could skip that part by directly creating and updating the Skia elements using
addChild
/removeChild
and therefore avoid the reconciler overhead.
This approach worked well and was the fastest of the methods I tried.
There is a new
approach
available using react-native-wgpu
, which is even faster, and I will switch to it
in the future.
Creating a ScrollView component
The next step was to create a simple ScrollView component that could render scrollable content.
I used react-native-gesture-handler
with a Pan GestureDetector to handle the touch events.
For the momentum scroll animation I tried using withDecay()
from react-native-reanimated
. However, it didn’t feel like the iOS scroll behavior.
Fortunately there is a good article by Ilya Lobanov on how UIScrollView’s animations work, which I used to recreate the momentum and overbounce scroll animations.
Creating a FlatList component
The last step was to create a FlatList component that could render a large number of items efficiently.
The idea was to render only the items that are visible on the screen and unmount the items that are not visible.
For that a callback is registered that is run when the scroll position changes, which then calculates the visible items and updates the list.
That function looped over the list items, calculated their position and size, and rendered them if they were visible on screen.
However there was a performance issue when scrolling to the end of a list with thousands of items, because the render loop started from zero and needed to check the visibility of all following items.
One optimization I made was to save the last visible item index and only check the items before/after that index, which improved the performance drastically.
How to use react-native-skia-list
Install the package:
yarn add react-native-skia-list
Have a look at the documentation and copy the following example:
// needed for types for global.SkiaDomApi
import type {} from "@shopify/react-native-skia/lib/module/renderer/HostComponents";
import { matchFont, Skia } from "@shopify/react-native-skia";
import { SkiaFlatList, useSkiaFlatList } from "react-native-skia-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useEffect, useMemo } from "react";
export default function FlatList() {
// Get the safe area insets to handle padding around the screen edges (needed for devices with notches)
const safeArea = useSafeAreaInsets();
// Create a Skia paint object to define a blue color for drawing the rectangle background
const blue = Skia.Paint();
blue.setColor(Skia.Color("rgb(124, 165, 230)"));
// Define the white color that will be used for the text inside each rectangle
const white = Skia.Color("#fff");
// Memoize the paragraph builder to optimize performance by only creating it once and reusing
const paragraphBuilder = useMemo(() => {
return Skia.ParagraphBuilder.Make({
textStyle: {
fontSize: 24, // Set the font size for the text
fontFamilies: ["Arial"], // Define font family for text
color: white, // Set the color of the text to white
},
});
}, []);
// Function to generate the initial data for the list, creating 10_000 items
const initialData = useCallback(() => {
return Array.from({ length: 10_000 }, (_, i) => {
paragraphBuilder.reset(); // Reset the paragraph builder before adding new text
// Return an object for each item containing an id and a Skia paragraph with the text
return {
id: `${i}`,
text: paragraphBuilder.addText(`Item ${i}`).build(), // Build the paragraph with the item text
};
});
}, []);
// Define the type for each entry in the list (an object with id and text properties)
type Entry = ReturnType<typeof initialData>[number];
// Function to extract the unique key from each list item (required)
const keyExtractor = useCallback((item: Entry) => {
"worklet"; // Indicates this function is used in a worklet context (required to run on the UI thread)
return item.id; // Use the id property of each item as the unique key
}, []);
// Define padding and margin values for each rectangle in the list
const rectPadding = 10;
const rectMargin = 10;
// useSkiaFlatList() to create and manage the list state
const list = useSkiaFlatList({
safeArea: {
bottom: safeArea.bottom, // Pass the safe area insets to handle screen padding
top: safeArea.top,
left: 15, // Add padding to the left and right of the screen
right: 15,
},
keyExtractor, // Provide the keyExtractor function
initialData, // Provide the initial data for the list
estimatedItemHeight: 100, // Set an estimated height for each item (to calculate the initial max height of the list)
// Render function for each item
renderItem: (item, index, state, element) => {
"worklet"; // required to run on the UI thread
// calculate the width of the item based on the screen width and safe area insets
const width = state.layout.value.width - state.safeArea.value.left - state.safeArea.value.right;
let maxTextWidth = width - rectPadding * 2; // Calculate maximum width available for the text
// Layout the text with the calculated width
item.text.layout(maxTextWidth); // needed to calculate the height of the text
const textHeight = item.text.getHeight(); // Get the height of the laid-out text
const rectHeight = textHeight + rectPadding * 2; // Calculate the height of the rectangle including padding
const itemHeight = rectHeight + rectMargin; // Calculate the total item height including margin
if (!element) return itemHeight; // If no element is passed, the item is not rendered and only its height is calculated
// Add a rectangle node to the item background with the calculated dimensions and blue color
element.addChild(
SkiaDomApi.RectNode({
x: 0,
y: 0,
width,
height: rectHeight,
paint: blue, // Set the paint to blue
})
);
// Add a paragraph node to the item containing the text
element.addChild(
SkiaDomApi.ParagraphNode({
paragraph: item.text, // Set the paragraph (text) to be displayed
x: rectPadding, // Add horizontal padding
y: rectPadding, // Add vertical padding
width: maxTextWidth, // Set the maximum width for the text
color: white, // Set the text color to white
})
);
return itemHeight; // Return the calculated item height for proper layout
},
});
// Render the SkiaFlatList with a flex style to fill the available space
return <SkiaFlatList list={list} style={{ flex: 1 }} />;
}
See the example in action: