NSAttributedString and AttributedString Bridging Performance on macOS

NSAttributedString and AttributedString Bridging Performance on macOS

With the introduction of the Swift-native AttributedString in iOS 15 and macOS 12, it’s become pretty common to convert between the existing NSAttributedString and the new type. Certain first-party frameworks, like SwiftUI and App Intents, require developers to use the new AttributedString. In existing apps, simply moving off of NSAttributedString can be too heavy of a lift, and if you’re using Objective-C at any point in the stack, such a migration simply wouldn’t be feasible. I also would be the last person to shame you for enjoying well-written Objective-C code.

Luckily, we can convert between AttributedString and NSAttributedString with the help of built-in methods. While it is fairly widely known that the bridging is not toll-free, as the types use distinct underlying representations, I haven’t found any comprehensive explorations or official first-party insight into exactly how fast or how slow such conversions are.

If you’ve worked in an established SwiftUI codebase, or if you maintain one of your own, you’ve likely seen code like this:

Text(AttributedString(viewModel.nsAttributedString))

As any experienced SwiftUI developer is likely to know, our views’ body computations should be as lightweight and cheap to execute as possible. AttributeGraph will ultimately decide when and how often to reevaluate the body, and putting accidentally expensive computations that should’ve been precomputed and cached inside a view model – or elsewhere outside the view – is likely one of the more common and easier to fix performance problems in SwiftUI view hierarchies.

Depending on just how expensive these conversions are, performance of our entire app could be at stake! Given the weight of the situation, exactly how concerned should we be when we see the bridging between the two attributed string representations? Are the performance characteristics the same for both directions of the conversion? How does the performance scale with the size of the strings?

Apple’s documentation does not provide a definitive answer. So, I decided to set up some performance tests on macOS 26.2 and try to find out. I’m posting the findings here hoping they could be of use to someone asking themselves similar questions.

String length

In this test, I held the number of attributes per run (4) and run count (32) constant while increasing the length of the string.

String Length NSAttributedString to AttributedString AttributedString to NSAttributedString
256 148.254 μs 103.584 μs
2048 185.604 μs 102.726 μs
16384 483.992 μs 110.828 μs
131072 2814.156 μs 143.640 μs

This shows that the cost of conversion from NSAttributedString to AttributedString scales strongly with length: 148.3 μs at 256 characters to 2814.2 μs at 131072 characters (about 19.0x). This raises concerns for code like the above Text(AttributedString(viewModel.nsAttributedString)), where the conversion happens on a hot path, especially if you can’t guarantee the string to be sufficiently small.

The other direction stays much flatter with length, rising from 103.6 μs to 143.6 μs over the same range. It seems to generally be a lot cheaper to convert an AttributedString to an NSAttributedString than the other way around.

Number of attributes

The length of the string is not the only axis we may want to test when dealing with attributed strings. I wanted to see what happens if I held both the string length (8,192) and run count (32) constant while increasing attributes per run (1 → 4). This would show the sensitivity to “attribute richness” of the strings.

Attributes per Run NSAttributedString to AttributedString AttributedString to NSAttributedString
1 229.298 μs 52.362 μs
2 260.248 μs 76.036 μs
3 282.270 μs 91.080 μs
4 315.272 μs 108.800 μs

This shows a close to linear increase for both directions.

A combination

I also wanted to grow the length and attribute richness together to simulate a very frequent real-world scenario. Based on the above results, the hypothesis was that the growth would be dominated by the growth of the length of the string.

String Length Attr. / Run NSAttributedString to AttributedString AttributedString to NSAttributedString
256 1 61.008 μs 41.972 μs
2048 2 127.972 μs 61.394 μs
16384 3 454.824 μs 74.008 μs
131072 4 2886.374 μs 139.574 μs

The shape of the results is pretty much the same as what we saw when growing the length of the string, leading to very similar conclusions.

What this shows

The initializers of both AttributedString and NSAttributedString accepting the other representation are designed to be syntactically lightweight, but as we can see from this test, are not guaranteed to be. What this tells me is that I shouldn’t treat the work done by these conversion operations as trivial, and should therefore avoid cases where I perform a lot of these conversions on a hot code path like inside a SwiftUI view’s body, especially if I don’t know the length of the input string.

In SwiftUI

My practical recommendation here, solely based on the data above, would be as follows.

  • If you’re dealing with very small strings, or strings of a deterministic but short length, it’s probably “fine” to keep converting them inline.
    • It likely doesn’t matter how saturated with attributes the string is.
  • If the string we’re working with is based on a user input or is otherwise of a nondeterministic length, convert it once.
    • It is best to store the string as the representation you’re going to present at the View Model (or equivalent) layer. If you’re presenting via SwiftUI Text, store the string as an AttributedString.
    • When the input value affecting the string changes, rebuild it.
    • This way, you’re the one in control of how often the string conversion occurs.

What this doesn’t show

My goal with this little performance test was to see how converting between the two built-in attributed string representations performed on the current version of macOS (26.2). It was not meant to be a comprehensive test, since other platforms, devices, and OS versions may behave differently. Optimizations may be introduced at the framework level in the future. Similarly, the performance could regress, and surely both instances have happened in the past.