Skip to content
Logo Theodo

Comparing iOS rendering performance: SwiftUI vs. React Native vs. Flutter

Mo Khazali7 min read

3 iPhones with SwiftUI, React Native, and Flutter logos

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:

A post on Hackernews

Buttons dropping FPS drastically:

A slow list in SwiftUI

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.

Animations in SwiftUI

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:

  1. 1000, 2000, & 3000 empty views with a border rendered on each.
  2. 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: An example of the Timeline in Flutter

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.

A graph from the Static Hermes Announcement showing the runtime of Hermes for computationally heavy code vs Static Hermes

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

Liked this article?