Implementing the fastest list renderer for React Native

react-native
skia

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).

Skia List

Shopify’s Flash List

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 ListReact Native’s FlatListShopify’s FlashList
Dropped Frames52164196
Render Time442ms4417ms4165ms

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:

React Native Skia Image Offscreen 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.

React Native Skia Picture Offscreen CPU Usage

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>
);
React Native Skia DOM

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:

Thank you for reading!
If you want to support me you can sponsor me on GitHub 🫶
If you have any questions or feedback, feel free to contact me. 👨‍💻