How to update SwiftUI many times a second while being performant?

Hey guys, I'm at a loss on this one. I have an async event loop that is parsing messages from an IRC chat. I have SwiftUI listening for updates via ObservableObject, but my CPU is spiking to 100%+. The data comes in many times a second, so SwiftUI is needing to update a List pretty often with new items. How do I ensure the CPU usage doesn't get out of control? Is there a best practice for updating the UI with high frequency data?

This will depend a lot on the specifics, and I cannot give you SwiftUI best-practices (because I haven't used it in ages) but in general a few things come to mind:

  • use some form of throttling/debouncing (eg: no more than once per second) to perform the more expensive update (both combine and async-algorithms have pre-made solutions for this)

  • connected to the throttling idea, you might want to batch or "chunk" your stream of updates into some lightweight data-structure, then apply the chunked updates less frequently

  • in general, make sure you use a data structure that performs well for your use case (eg: check out prepending/appending performance characteristics of Deque vs List)

I've made stopwatch with UI updated at 60fps rate and at about 90fps in promotion displays. CPU utilisation was quite low on iOS 16 / 17. Frequent UI updates is not always the bottleneck.
I should recommend to profile your App at first. Poor performance can be caused by another reasons, e.g. large amount of work in main thread, such as Decoding of large JSONs. Try to minimise UI updates impact e.g. by replacing all of your custom views with complex hierarchy by simple Text(""). If CPU is still highly utilised, use Profiler to see what functions use CPU most of the time.
There are also several WWDC videos about SwiftUI performance, structural and explicit identity and other topics. Particulary, if explicit identity is made incorrectly, the rendering become more expensive. Nuances with structural identity can also lead to poor performance.

1 Like

This made a significant improvement. I throttled the updates to just 0.3 and that helped a ton. The subscriber model is hard to figure out though. It took a while to even research how to set that up, but I did it with 2 observable objects, where one is the raw updates and the other is the debounced version that the UI subscribes to. Hopefully that sounds like a sensible design.

I also noticed I was conforming to identifiable with var id { return UUID() } which was very expensive. I changed that to simply just be a member variable and that reduced CPU consumption by about 20% lol.

1 Like

In this case, I am doing all of my heavy lifting in c++ on a background thread, so the UI isn't having to do anything except display the data. Even when I was just displaying raw text instead of my actual view it was still showing upwards of 120% CPU. the backend that processes and translates the data only uses 0.1% CPU, so the jump to 120% is very extreme.

however, you were correct about the identity. I was conforming to Identifiable via a getter which was consuming a ton of CPU each update. changing that to a simple member variable made a big difference.

Oof, var id { UUID() }, in addition to being a bit more expensive than normal, would have changed the identity of all of your models every time it was accessed, and so was causing huge amounts of recomputation within SwiftUI. Stable identity of both models and views is key to SwiftUI performance.

7 Likes

Yes, excellent point. I would also add that it is quite easy have poor performance because of structural identity pitfalls. Like this pattern from stackoverflow:

extension View {
  //  "Don't use it, it breaks animations and leads to poor performance
  @ViewBuilder public func applyIf(condition: Bool, transform: (Self) -> some View) -> some View {
    if condition {
      transform(self)
    } else {
      self
    }
  }
}

From SwiftUI point of view and structural identity particularly, the result of this function differs from call to call. Because of that, SwiftUI destroy current view and create new instance.

// instead of this
.applyIf(condition: isAuthorised) { $0.opacity(0.5) }

// it is better to write
$0.opacity(isAuthorised ? 1 : 0.5)

Just a hint what we’ve done:

We prep the complete data set on background tasks, provide it with an async stream of size 1 which just provides the latest update and drops previous ones, the on the main actor task we just assign this complete data update (with care of identifiers as mentioned).

Then we do a ā€œtail debounceā€ by sleeping the task that is reading from the stream for a randomized amount of time - then check if we overshot that time significantly (which means a saturated main thread typically) - we then add that to the sleep the next time so we back off the FPS practically. If we hit the sleep target, we will back of the sleep with hysteresis to avoid oscillations. This gives us the desired behavior to update as frequently as the main thread can handle without choking with dynamic back off if overloaded, while performing all processing on non-main thread tasks.

(We have many windows with high data rates in parallel)

2 Likes

You can also try to use EquatableView s in SwiftUI. I haven't yet profiled them for performance, but the following trick works well with diffable algorithms in UIKit.
Imagine you have a thousand of ViewMdels (immutable structs), each of them consists of some complex data that is expensive to check for equality in main thread.
The idea is to make equality check in some other Queue / Thread. Simplified solution is something like:

struct ViewModel: Equatable {
  var content: RenewableContent

  private var diffToken: Int64

  func updateWith(args: ..) {
    let newContent = content.updatedWith(args: ..)

    if newContent != content {
      // expensive direct equality via `!=` and update is done in background
      // or something more clever can be done instead of `!=`
      diffToken = Int64.random()
    }

    content = newContent
  }

  private struct RenewableContent {
    var foo: ...
    var bar: ...
  }
  
  // When viewModels will be compared in UI on main thread 
  // the equality check will be speed of light fast as Int value or something similar is compared
  public static func == (lhs: Self, rhs: Self) -> Bool { 
    lhs.diffToken == rhs.diffToken 
  }
} 

It is very convenient to have a compiler synthesised == function. But sometimes, there is a need to:

  • keep a data that is not Equatable
  • keep a helper data needed for internal logic but we want to prevent it being used for Equality comparison
    To exclude such data a property wrapper can be used:
@propertyWrapper
struct EquatableExcluded<T>: Equatable {
  var wrappedValue: T
  
  init...
  
  static func == (lhs: Self, rhs: Self) -> Bool { true }
  func hash(into hasher: inout Hasher) {}
}

I suppose similar concept can be implemented in SwiftUI

1 Like

The other piece of advice I can give here is to avoid having many other unrelated views observing the same ObservableObject, because ObservableObject tracking happens when any of the @Published properties change.

The easiest solution to this is to switch to @Observable, which does change-tracking on a per-property basis and therefore only ever updates the views that actually intend to read the high-frequency-updating value.

2 Likes

For me personally understanding two things help building SwiftUI views:

  1. view is a function of state;
  2. SwiftUI will update view when state updates (see 1) with diffing algorithm under hood based on some identity.

Then you just care about state update in the end and how it being handled in the view. Also as @harlanhaskins pointed already @Observable does some additional magic and updates only related views. If you need support for older devices—there is a swift-perception library from Pointfreeco which backports behaviour up to iOS 13.

1 Like

I came across this thread when searching for information after posting my experience on SO here: https://stackoverflow.com/questions/79814745/swiftui-performance-in-2025

I tried the async stream but doing so only added 3% CPU. As an aside, my actual project is pulling current time using an addPeriodicTimeObserver to get the current value that I then convert to text for display. The example in my SO post is just to illustrate my issue. Either way the behaviour is the same, I can shave 10% cpu off by not updating the UI.

I’m just struggling to accept that updating a UI element 30 times per second costs so much that I’m sure I’m doing something wrong as there’s no way production apps would accept this level of performance. Here is a screenshot from instruments which seems to show some hitches.

I ā€˜d really appreciate your thoughts.

1 Like

Hey, so I’ve since moved completely away from SwiftUI in favor of raw objc and UIKit. SwiftUI has some fundamental design choices that severely limit its performance.

Looking at your code, I’m not too sure what’s going on. Does the FileDocument change? because if so, then that’s going to cause the entire ContentView to be recreated constantly, which means it’s not just the text that’s being updated, but rather your entire app is being redrawn over and over just to change that simple bit of text. If you put print statements in all of your inits, do you see them constantly printing when they probably shouldn’t be?

I was afraid someone would say that. I’m doing a macOS app but for my app I need to stay under about 15-20% CPU usage in total for my app to be viable. If updating a single Text view eats up 10% then SwiftUI becomes unviable.

Regarding the FileDocument, no I didn’t even get that far, that was going to be my next expiration after this.

To put it into perspective, I had to write custom video player controls which involves a seeking timeline as well as updating the current timestamp. I update the timeline/text smoothly at 120fps using CADisplayLink. It’s so trivial that I could probably handle thousands of them at a time, all at 120fps. You’re right to be frustrated that something as simple as updating text could possibly be as inefficient as you’re seeing.

So you really have 2 options. You can try to tinker with SwiftUI until you figure it out, which will be frustrating, or you could look into AppKit. You can still use SwiftUI with AppKit, you’ll just have to use NSViewRepresentable and NSViewControllerRepresentable respectively. It’s got a learning curve to it, but luckily it’s such a mature framework that you can just ask ChatGPT questions about it. The resources online for AppKit are terrible and in my experience, ChatGPT free tier was way more than enough to get myself up to speed with it.

I don’t know what your app is and how demanding you plan for it to be, but I will say that in my experience working on Kulve, I got SwiftUI as far as I possibly could using ~70% C++ just for it to still end up needing to be replaced with UIKit for the iOS version. The Mac version currently doesn’t work on Tahoe and once I release iOS I’ll be rewriting it in pure AppKit and it’ll solve a whole slew of issues that are pretty fundamental to SwiftUI.

If reaching out to AppKit is a dealbreaker for you, then I’d advise tinkering with SwiftUI. If it’s a project you care about, it’d be worth your time to deal with the frustration for the sake of the app rather than end up dropping it completely.

1 Like

Thanks for your thoughtful response. I guess my next step is do I base my app on SwiftUI then use NSViewRepresentable or base it on AppKit and use NSHostingView.

This forum is not well suited for SwiftUI related issues as it tries to stay laser focused on Swift programming language itself.

Having said that,

  • yes, SwiftUI could be slower than UIKit/Cocoa, perhaps the main reason is the view "diffing" business.
  • there could be various optimisation tricks that might help.
  • for starters I'd distill the app further, down to a dozen of lines to ease experimenting with performance tricks.
  • as one of the SO responders, I wonder what happens if you add a second or a third text view, could be the case that those subsequent views would add much less than 10% each.
  • 10% of how much in total? I've seen cases when the total is reported as, say, 800% on an 8 core machine, and 10% out of 800% is just ~1%, so the question is how much you'd expect it to be?
1 Like