By using this site, you agree to the Privacy Policy and Terms of Use.
Accept
World of SoftwareWorld of SoftwareWorld of Software
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Search
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
Reading: Why Do SwiftUI Apps “Stutter”? | HackerNoon
Share
Sign In
Notification Show More
Font ResizerAa
World of SoftwareWorld of Software
Font ResizerAa
  • Software
  • Mobile
  • Computing
  • Gadget
  • Gaming
  • Videos
Search
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Have an existing account? Sign In
Follow US
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
World of Software > Computing > Why Do SwiftUI Apps “Stutter”? | HackerNoon
Computing

Why Do SwiftUI Apps “Stutter”? | HackerNoon

News Room
Last updated: 2026/04/02 at 3:20 PM
News Room Published 2 April 2026
Share
Why Do SwiftUI Apps “Stutter”? | HackerNoon
SHARE

1. Introduction — Why SwiftUI “Stutters” When It Shouldn’t

You built a polished screen in SwiftUI, but scrolling through a list reveals micro-stutters. The profiler shows thousands of body invocations. You start doubting SwiftUI — “maybe UIKit was better after all?”

The problem isn’t SwiftUI. The problem is a lack of understanding of how it decides what to update. Beneath the declarative API lies a powerful mechanism called the Attribute Graph — a directed acyclic graph (DAG) that tracks every dependency in your UI and updates exactly what needs updating. Nothing more, nothing less.

But only if you don’t get in its way.

In this article, we’ll cover:

  • How the Attribute Graph is structured
  • What re-evaluate and re-draw mean, and why they are fundamentally different
  • How invalidation works — the process of marking “dirty” nodes
  • Common mistakes that cause unnecessary recalculations
  • How to optimize your code so SwiftUI performs at its best

2. What Is the Attribute Graph?

The Attribute Graph is a directed acyclic graph (DAG) that SwiftUI constructs from your view hierarchy. It consists of three layers:

  • Source nodes — values from @State, @Binding, @Environment, @ObservedObject
  • Computation nodes — the results of each view’s body call
  • Render nodes — the concrete display layers (backing CALayer/UIView instances)

The edges of the graph represent dependencies: “this body depends on this @State

How Dependencies Are Formed

Here’s the key insight: a dependency is recorded at the moment of access, not at the point of declaration.

struct ProfileView: View { 

    @StateObject var user: UserModel 
    // properties: name, avatar, settings 

      var body: some View { 

          // SwiftUI records: ProfileView.body depends on user.name 
            Text(user.name) 

          // user.settings is NOT read → changing settings 
          // will NOT trigger a re-evaluate of this view 
        }
}

During the first invocation of body, SwiftUI records which data sources the view accessed. This forms the edges of the graph. An important nuance: with ObservableObject, tracking operates at the level of the entire object, whereas with @Observable (iOS 17+), it operates at the level of individual properties. We’ll explore this distinction in the optimization section.

Identity and Lifetime

The graph is tied to the identity of each view. Identity is determined in one of two ways:

  • Structural position — the view’s location within the body hierarchy (used by default)
  • Explicit .id() — a manually assigned identifier

When a view’s identity changes, its graph node is destroyed and recreated — all @State in child views is reset. Understanding this is essential to avoid accidentally losing state.

3. Re-evaluate vs. Re-draw — The Critical Distinction

This is the central concept of the article. The two terms sound similar, but they mean fundamentally different things.

Re-evaluate (Recalculating body)

  1. A node’s value in the graph changes (e.g., a @State property is updated via .wrappedValue)
  2. SwiftUI marks dependent nodes as “dirty” (invalidated)
  3. On the next render cycle, it calls body on the marked views
  4. body returns a new view tree (these are just lightweight structs — cheap to create)
struct CounterView: View { 

    @State private var count = 0 

    var body: some View { 

          let _ = Self._printChanges() 
          // 🔍 Prints: "CounterView: _count changed." 
          VStack { 
                Text("Count: (count)") 

                Button("Increment") { 
                          count += 1 } 

                HeavyView() 
                      // Will NOT be re-evaluated if it doesn't depend on count 
                } 
          }
      }

Re-evaluate is a function call that returns lightweight structs. It’s fast.

Re-draw (Rendering)

After re-evaluate, SwiftUI performs a structural diff — comparing the old and new view trees. It then updates only the changed backing layers (the underlying CALayer/UIView instances). This involves GPU work, layout calculations, and animations — an expensive operation.

An Analogy

Re-evaluate is re-reading the recipe. Re-draw is actually replacing an ingredient on the plate.

The key insight: frequent re-evaluations are normal — they’re cheap. Problems arise when:

  • A re-evaluate is unnecessary — a view recalculates even though its data hasn’t changed
  • The body is heavy — objects are allocated, computations are performed inline

4. Invalidation — How the Graph Decides What to Recalculate

Invalidation is the process by which SwiftUI marks graph nodes as “dirty” and schedules them for recalculation. The crucial point is that it does this surgically, not across the entire tree.

I prepared mini web-app for fully understood how it works please visit

https://pavelandreev13.github.io/SwiftUI_attribute_graph/

The Process, Step by Step

Step 1 — At rest. All graph nodes are valid. SwiftUI recalculates nothing — the UI is stable.

Step 2 — A change occurs. The user taps a button; count becomes 1. SwiftUI detects that a node’s value has changed and begins traversing its outgoing edges.

Step 3 — Marking (invalidation). SwiftUI follows only the edges leading from the changed node. CounterView.body reads count — it’s marked dirty. HeaderView, BgView, and ProfileView don’t depend on count — they are skipped entirely.

Step 4 — Re-evaluate. On the next render cycle, SwiftUI calls body only on the marked views. It receives a new UI description. The remaining body methods are never invoked.

Step 5 — Result. The diff between the old and new trees reveals that only one Text has changed. That element alone is re-drawn. The other three render layers are untouched. The graph is stable once again.

Coalescing — Batching Changes

Invalidation is a synchronous marking step, while re-evaluate is a deferred recalculation. SwiftUI batches multiple changes within a single cycle (coalescing):

Button("Update All") { 

        count += 1 

        // marks CounterView dirty 
        title = "New" 
        // marks HeaderView dirty 
        // Re-evaluate happens ONCE, not twice
  }

This means you can safely mutate multiple @State properties in sequence within the same block — SwiftUI won’t recalculate intermediate states.

5. Anti-pattern “AnyView”

AnyView erases the type — SwiftUI sees only “AnyView → AnyView” and cannot determine whether the content has actually changed. The result: a full re-draw every time. With @ViewBuilder, SwiftUI knows the concrete type _ConditionalContent<TextView, ImageView> and performs a precise, targeted diff.

// ❌ SwiftUI can't compare types — full re-draw every time
    func makeView(for type: ContentType) -> AnyView { 
          switch type { 
              case .text: return AnyView(TextView()) 
              case .image: return AnyView(ImageView()) 
    }
}

// ✅ SwiftUI knows the exact types — efficient diff

@ViewBuilder
func makeView(for type: ContentType) -> some View { 

          switch type { 
              case .text: TextView() 
              case .image: ImageView() 
          }
}

6. Unnecessary Re-evaluations via Closures

struct ParentView: View { 

        @State private var count = 0 
        @State private var title = "Hello" 

        var body: some View { 
              VStack { 
                  Text("Count: (count)") 

                // ❌ The closure captures self → ChildView depends on ALL @State properties 
                ChildView(action: { doSomething(count) }) 

                // ✅ Pass the value directly — ChildView doesn't depend on count 
                ChildView(value: count) } 
        }
}

When you pass a closure into a child view, SwiftUI doesn’t analyze what the closure actually uses — it only sees that the closure captures self. That single fact makes the child view depend on the entire parent, not just the specific state property the closure touches.

Why this matters

@State properties in SwiftUI are stored outside the struct, but self inside a View is the struct itself — a value type regenerated on every body evaluation. When ChildView receives a closure that captures self, its dependency graph now includes every @State property on ParentView: count, title, and anything else added in the future. The moment any of them changes, ChildView.body is marked dirty and re-evaluated — even if the closure never touches the changed property.

// ❌ title changes → ChildView re-evaluates even though it only uses count
ChildView(action: { doSomething(count) })

The compiler has no way to peer inside the closure at compile time and determine a narrower dependency. From SwiftUI’s perspective the closure is an opaque () -> Void blob that holds a reference to the whole parent struct.

The fix: pass values, not self

Extracting the concrete value you need and passing it directly severs the hidden dependency chain. ChildView now only receives an Int. SwiftUI can diff that scalar precisely — the view re-evaluates if and only if count itself changes.

// ✅ title changes → ChildView is completely unaffected
ChildView(value: count)

7. Optimization: How to helping the Attribute Graph

The principle: the fewer dependencies a body has, the less often it recalculates. Break views into smaller units not for “code cleanliness,” but to isolate recalculation zones.

// ❌ Monolith: any change → everything recalculatesstruct 

ProfileScreen: View { 
      @StateObject var vm: ProfileVM 

      var body: some View { 
            VStack { 
                Image(vm.avatar) 
                Text(vm.name) 
                BadgeView(vm.badges) 
                StatsGrid(vm.stats) 
      } 
  }
}

// ✅ Granular: each block recalculates independentlystruct 

ProfileScreen: View { 

        @StateObject var vm: ProfileVM 

        var body: some View { 
               VStack { 
                  AvatarView(avatar: vm.avatar) 
                  NameView(name: vm.name) 
                  BadgeListView(badges: vm.badges) 
                  StatsGrid(stats: vm.stats) 
          } 
      }
}

Equatable to Prevent Unnecessary body Calls

By default, SwiftUI re-evaluates a child view’s body every time its parent re-evaluates — even if the child’s inputs haven’t changed at all. For lightweight views this is cheap enough to ignore. For a view that renders a complex chart, runs a heavy layout pass, or processes large datasets, that cost adds up fast.

The Equatable protocol gives you a way to short-circuit this: tell SwiftUI exactly what “nothing changed” means for your view, and it will skip the body call entirely when that condition holds.

How it works under the hood

When you call .equatable() on a view, SwiftUI wraps it in an EquatableView<YourView>. On every render cycle, before invoking body, SwiftUI calls your custom == implementation and compares the previous props with the new ones:

Parent re-evaluates n │ n ▼ n SwiftUI calls lhs == rhs n │ n ┌────┴────┐ n true false n │ │ n ▼ ▼ n skip body call body → diff → re-draw

If == returns true, the entire subtree rooted at your view is frozen — no body, no diff, no re-draw. SwiftUI reuses the last rendered output as-is.

Why define a custom == instead of using Equatable synthesis

Swift can synthesize Equatable automatically if all stored properties are themselves Equatable. But automatic synthesis compares every field byte-for-byte. For a ChartData struct that contains arrays, nested objects, or computed properties, that comparison can be just as expensive as re-rendering — or it can produce false negatives that trigger unnecessary redraws.

A hand-written == lets you define a semantic equality:

static func == (lhs: Self, rhs: Self) -> Bool {
    lhs.data.id == rhs.data.id && lhs.data.version == rhs.data.version
}

This says: “the data is the same if the identity and version haven’t changed.” Two integer comparisons replace a potentially deep structural diff of the entire ChartData object. This is the same idea behind database row versioning — you don’t compare every column, you compare a version counter.

What .equatable() does NOT do

It’s important to understand the boundaries of this optimization:

  • If == returns true — body is skipped and the entire subtree is frozen. SwiftUI reuses the last rendered output unchanged.
  • If == returns false — body runs normally and its result goes through the standard diff pipeline.
  • If the view uses @State internally — internal state changes always trigger re-evaluation regardless of what == returns. The Equatable check only guards against prop changes coming from the parent.
  • If the view uses @EnvironmentObject — environment changes bypass the == check entirely. The view will still re-evaluate whenever the observed object publishes a change.
  • If .equatable() is omitted at the call site — == is never called, even if the view fully conforms to Equatable. The conformance alone does nothing; the modifier is what activates the optimization.

When to reach for this pattern

Equatable diffing is worth adding when all three conditions hold:

  1. The view is expensive — complex layout, heavy drawing, large data processing.
  2. The inputs have a cheap equality check — an id + version pair, a hash, a timestamp. If checking equality is as costly as re-rendering, you gain nothing.
  3. The parent re-evaluates frequently — if the parent is stable, the optimization never fires and adds only noise.

A good rule of thumb: profile first with Instruments’ SwiftUI template. If you see ExpensiveView.body appearing in the call tree during interactions that shouldn’t touch it, Equatable is one of the cleanest fixes available.

8. List of anti-patterns: “What Not to Do” Checklist

Here is a concise list of the most common mistakes that lead to unnecessary recalculations:

  1. Storing everything in a single ObservableObject — one objectWillChange signal updates every subscribed view
  2. Allocating objects inside body — DateFormatter(), NumberFormatter(), heavy models are recreated on every re-evaluate
  3. Using AnyView — type erasure makes diffing impossible
  4. Passing closures that capture unnecessary dependencies — the closure drags in the entire self
  5. Running heavy computations in body — filtering, sorting, or mapping arrays inline during tree construction
  6. Using @ObservedObject instead of @StateObject — the object is recreated on every parent re-evaluate
  7. Monolithic views with 5+ dependencies — one large body instead of several isolated ones
  8. Ignoring lazy containers — a VStack with ForEach over thousands of items instead of LazyVStack

9. Diagnostic Tools

1. Self._printChanges()

The quickest way to see what triggered a re-evaluate:

var body: some View { 

         let _ = Self._printChanges() 
          // Output: "MyView: _count changed." 
          // or: "MyView: @self changed." (the view struct was recreated) 
          Text("Hello")
}

2. os_signpost for Custom Measurements

import os

let log = OSLog(subsystem: "com.app", category: "performance")

var body: some View { 
      let signpostID = OSSignpostID(log: log) 

      os_signpost(.begin, log: log, name: "HeavyView.body", signpostID: signpostID) 
      defer { 
          os_signpost(.end, log: log, name: "HeavyView.body", signpostID: signpostID) 
      } 

      // ... your body

}

3. Instruments → SwiftUI Template

Xcode Instruments includes a SwiftUI template with two primary instruments:

  • View Body — shows how many times each view’s body was called

  • View Properties — shows which property changes triggered a re-evaluate

4. Flash Updated Regions

Attention: you need to connection with real device

A built-in Xcode overlay that highlights every region of the screen being redrawn in real time — no third-party tools, no code changes required.

  1. Connect a real device — the feature is unavailable in the simulator
  2. Run the app via ⌘R and wait for it to fully launch
  3. In the menu bar: Debug → View Debugging → Rendering → Flash Updated Regions
  4. A checkmark confirms it’s active — takes effect instantly, no restart needed

Every time SwiftUI issues a draw call to the GPU, the affected region flashes yellow on screen for a fraction of a second. No flash means SwiftUI reused the cached texture from the previous frame — which is exactly what you want for views that haven’t changed. The overlay is composited on top of your UI so the app remains fully interactive while you observe it.

Why it’s useful

It answers the question “is this view redrawing when it shouldn’t?” instantly and visually — without opening Instruments or reading call stacks. You interact with the app naturally and watch whether flashes appear in places they have no reason to be:

  • A static header flashing on every scroll → unnecessary parent re-evaluation
  • All list cells flashing when one updates → overly broad @ObservableObject dependency
  • The entire screen flashing on a button tap → monolithic view structure, state living too high in the hierarchy
  • Any flash during idle → background state or timer invalidating views at rest

The workflow is simple: tap something, see only what should redraw flash, investigate anything that shouldn’t. Fix it, re-interact, confirm the flash is gone — all without leaving the app.

10. Conclusion

The Attribute Graph is a contract between you and SwiftUI. You describe what to display; SwiftUI decides when and how to update it. The engine is optimized for surgical updates — but only if you isolate your dependencies correctly.

Three guiding principles:

  1. Granularity — small views with minimal dependencies outperform monoliths
  2. Precision — @Observable over ObservableObject, specific values over entire models
  3. Purity — body should be a pure function with no side effects and no allocations

Understanding the Attribute Graph transforms SwiftUI’s “magic” into a predictable, controllable tool. Once you can see how data flows through the graph, you know exactly why the UI updated — and how to ensure it only updates when it should.

Closures are the most common source of invisible over-dependence in SwiftUI. The fix is almost always the same: extract the specific value the closure needs, pass it as a typed argument, and let the child close over that narrow value instead of the entire parent.

n

n

Sign Up For Daily Newsletter

Be keep up! Get the latest breaking news delivered straight to your inbox.
By signing up, you agree to our Terms of Use and acknowledge the data practices in our Privacy Policy. You may unsubscribe at any time.
Share This Article
Facebook Twitter Email Print
Share
What do you think?
Love0
Sad0
Happy0
Sleepy0
Angry0
Dead0
Wink0
Previous Article Here’s Why NASA’s Artemis II Mission Is So Special – BGR Here’s Why NASA’s Artemis II Mission Is So Special – BGR
Next Article Every Google Pixel device that supports Apple AirDrop Every Google Pixel device that supports Apple AirDrop
Leave a comment

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Stay Connected

248.1k Like
69.1k Follow
134k Pin
54.3k Follow

Latest News

Telehealth giant Hims & Hers says its customer support system was hacked |  News
Telehealth giant Hims & Hers says its customer support system was hacked | News
News
Social Media Content Curation Mistakes You Might Be Making |
Social Media Content Curation Mistakes You Might Be Making |
Computing
ANBERNIC’s screen-flipping handheld finally has a name
ANBERNIC’s screen-flipping handheld finally has a name
News
Microsoft Generative AI Report: The 40 Jobs Most Disrupted Jobs & The 40 Most Secure Jobs  | HackerNoon
Microsoft Generative AI Report: The 40 Jobs Most Disrupted Jobs & The 40 Most Secure Jobs | HackerNoon
Computing

You Might also Like

Social Media Content Curation Mistakes You Might Be Making |
Computing

Social Media Content Curation Mistakes You Might Be Making |

19 Min Read
Microsoft Generative AI Report: The 40 Jobs Most Disrupted Jobs & The 40 Most Secure Jobs  | HackerNoon
Computing

Microsoft Generative AI Report: The 40 Jobs Most Disrupted Jobs & The 40 Most Secure Jobs | HackerNoon

1 Min Read
Ground control to Microsoft: Artemis 2 astronauts deal with Outlook hiccup in deep space
Computing

Ground control to Microsoft: Artemis 2 astronauts deal with Outlook hiccup in deep space

2 Min Read
Microsoft’s Newest Open-Source Project: Runtime Security For AI Agents
Computing

Microsoft’s Newest Open-Source Project: Runtime Security For AI Agents

1 Min Read
//

World of Software is your one-stop website for the latest tech news and updates, follow us now to get the news that matters to you.

Quick Link

  • Privacy Policy
  • Terms of use
  • Advertise
  • Contact

Topics

  • Computing
  • Software
  • Press Release
  • Trending

Sign Up for Our Newsletter

Subscribe to our newsletter to get our newest articles instantly!

World of SoftwareWorld of Software
Follow US
Copyright © All Rights Reserved. World of Software.
Welcome Back!

Sign in to your account

Lost your password?