Comparing iOS rendering performance: SwiftUI vs. React Native vs. Flutter
Mo Khazali7 min read
When SwiftUI first came out, I remember reading about complaints around its performance. Some animations were janky (compared to UIKit) and app layouts were getting recalculated far too much, resulting in unnecessary computation power being used.
People reported it being generally slower than UIKit:
Buttons dropping FPS drastically:
And various other issues.
While previews are still broken in 2023, a lot has changed since 2019. SwiftUI has simplified iOS native development greatly - creating clean, performant, and cross-platform (at least in the Apple world) apps. Playing around with SwiftUI, the simplicity of getting smooth buttery fast animations working “out of the box” has been quite extraordinary. Just check out this graph animation on the Apple Developer docs - it works with minimal additional code, and the interpolation can be stopped midway through without any extra configuration.
All of this got me thinking about rendering performance and how SwiftUI compares to cross-platform frameworks like React Native and Flutter.
I set out to do some (very crude) experimentation to get a baseline comparison between rendering performance across the board.
The Experiment
The base level components across an app will be views & text. We can get some sense of performance by rendering a large number of View
and Text
elements in each platform, and measuring how long the render/paint took in each instance. These are run on a bare project with no added dependencies to avoid any potential added overhead.
We run the following set of experiments 10 times on each platform and average out the results:
- 1000, 2000, & 3000 empty views with a border rendered on each.
- The same views with a single text node added into each.
Each of these tests were run on an iPhone 14 Simulator with iOS 16, on my 2021 M1 MacBook Pro with 16GB of memory.
How is this implemented on each platform?
SwiftUI
We store the start time on init
of the View
and using the onAppear
method, we calculate the difference between the initial time and the time it took for the views to finish rendering.
struct BoxesView: View {
var number: Int
var showText: Bool
private let creationDate: Date
init(initNum: Int, initShowText: Bool) {
creationDate = Date()
number = initNum
showText = initShowText
}
var body: some View {
HStack {
ForEach(0..<number, id: \.self) { i in
Group {
if showText {
SingleCellWithText()
} else {
SingleCell()
}
}
}
}
.onAppear {
print(Date().timeIntervalSince(creationDate))
}
}
}
React Native
Update: The method for measuring times for React Native has been updated to be more accurate. The original method of using a useLayoutEffect
meant that the times were not taking into account the paint time on the native UI thread, and the effect would have been called synchronously from the UI thread.
I will be writing a follow-up article deep diving into React Native performance and will explain the new method of measurement there.
We store the initial render time of the component in a useState
. Using a useLayoutEffect
, we get the time when the render has been completed, and calculate the difference as the time it took to render the component.
import { useLayoutEffect } from "react";
import { StyleSheet, Text, View } from "react-native";
const N = 1000;
const text = true;
export default function Boxes() {
const start = Date.now();
useLayoutEffect(() => {
console.log(Date.now() - start)
}, []);
return (
<View style={styles.container}>
{new Array(N).fill(0).map((_, i) => (
<View key={i} style={styles.styledView}>
{text ? <Text>1</Text> : null}
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
display: "flex",
flexDirection: "row",
},
styledView: {
borderColor: "red",
borderWidth: 2,
padding: 5,
},
});
Flutter
We create the following component for the Flutter implementation.
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
List<int> nums = Iterable<int>.generate(2000).toList();
bool showText = false;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Wrap(children: <Widget>[for (var i in nums) RedBox(showText)]),
],
),
);
}
}
class RedBox extends StatelessWidget {
final bool showText;
RedBox(this.showText);
@override
Widget build(BuildContext context) {
return Container(
height: 20,
width: 20,
margin: EdgeInsets.all(2), // Add spacing between views
decoration: BoxDecoration(
border: Border.all(color: Colors.red, width: 2),
),
child: showText ? const Text("1") : null,
);
}
}
The challenge is that Flutter has a very opinionated way of assessing render performance. You’ll need to launch the app in profile mode using flutter run --profile
, and assess how long the Paint
events take in the timeline:
This approach is different to the rather crude approach we have in SwiftUI & React Native, and it can potentially introduce some discrepancy in the accuracy of our tests (particularly by swaying the results in favour of Flutter, since the performance is being assessed on a lower level).
The Results
Update: React Native times have been updated.
Test | Swift UI | React Native | Flutter | |||
---|---|---|---|---|---|---|
Average (ms) | SD | Average (ms) | SD | Average (ms) | SD | |
1000 Views | 62.6 | 9.1 | 104.0 | 15.0 | 365.9 | 83.6 |
2000 Views | 98.3 | 27.5 | 205.5 | 1.9 | 281.5 | 31.0 |
3000 Views | 173.8 | 51.0 | 287.5 | 3.0 | 340.1 | 142.4 |
1000 Views w/ Text | 127.1 | 13.5 | 277.2 | 9.8 | 655.8 | 745.7 |
2000 Views w/ Text | 240.4 | 15.1 | 536.5 | 29.5 | 936.2 | 859.2 |
3000 Views w/ Text | 373.1 | 30.3 | 765.4 | 5.4 | 1206.2 | 1327.9 |
What does it mean?
Without much surprise, SwiftUI gets the best performance by a solid margin compared to React Native & Flutter.
Interestingly, Flutter seems to do marginally better when it’s not rendering text nodes, but really starts to struggle as soon as you render text inside of views.
The bigger concern is around the large deviation of times that Flutter has. While SwiftUI & React Native have relatively similar standard deviation (24ms
and 30ms
on average respectively), Flutter has an average standard deviation 530ms
across the different tests, which is significantly higher. This effectively means that our tests have found that Flutter apps can exhibit more inconsistency when it comes to rendering a large number of items.
What’s in the future?
SwiftUI continues to become the de-facto way to create UIs in the Apple Ecosystem. Its performance has greatly improved over the past few years, and the results really show it. Apple has been working on creating content and resources around optimising performance for SwiftUI apps, and in WWDC23, there was a session dedicated to “Demystify SwiftUI performance”. SwiftUI is the way to build cross-platform apps across Mac, iPhone, iPad, Apple Watch, and now, Vision Pro. Its declarative syntax, that’s been largely inspired by React, is almost unanimously loved by native developers.
The React Native core team have recently announced that they’ve been working on Static Hermes, which uses TS types (introducing a few additional constraints for soundness) to compile down JS to native layer code and get native level performance.
This will improve performance massively, and also has the benefits of stronger typing, resulting in fewer bugs. It also reduces the amount of native layer code that needs to be written, since the JS code can be compiled down to C level - leading to faster iteration speed for developers. This is a massive deal in the performance space for React Native and the future is exciting.
Lastly, the Flutter team announced earlier this year that they’ll be replacing Skia with a new rendering engine called Impeller. This new renderer is built specifically for Flutter apps, with the goal of consistently achieving 60+ FPS across the board. It does this by precompiling the renderer’s shaders at build time, rather than loading them at runtime. Since it’s now a smaller set of shaders being precompiled into the app (rather than the entirety of Skia), the bundle size of apps is also smaller in comparison.
Feel free to reach out
Feel free to reach out to me on Twitter @mo__javad. 🙂