DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Rendering Pipeline Explained

SwiftUI can feel mysterious when it comes to rendering.

You change one value…

Suddenly:

  • views re-render
  • animations restart
  • layout recalculates
  • performance drops
  • things update “too often”

This happens because SwiftUI has a rendering pipeline — and most developers never learn how it works.

This post explains exactly how SwiftUI renders your UI, step by step, using the modern mental model.

Once you understand this pipeline, SwiftUI stops feeling random.


🧠 The Big Picture

Every SwiftUI update goes through the same pipeline:

State Change

View Invalidated

body Recomputed

Layout Pass

Diffing

Rendering

Nothing skips this.
Nothing is magic.

Performance problems happen when too much work happens in one of these stages.


🔥 1. State Change (The Only Entry Point)

Everything starts with state.

Examples:

  • @State changes
  • @StateObject / @observable property changes
  • @Binding updates
  • Environment value changes

If no state changes → nothing re-renders.

This is why SwiftUI apps can be extremely efficient.


⚠️ Common Mistake

Developers often think:

“body recomputes too much”

That’s not the problem.

body recomputation is cheap.

Invalidation + layout + diffing is what costs time.


🧱 2. View Invalidation

When state changes, SwiftUI:

  • marks affected views as dirty
  • schedules them for update

Important:

  • only affected subtrees invalidate
  • not the entire app

Bad architecture (global state everywhere) = massive invalidation.

Good architecture (scoped state) = minimal invalidation.


🔁 3. body Recomposition (Cheap)

SwiftUI re-executes:

var body: some View { ... }
Enter fullscreen mode Exit fullscreen mode

This is:

  • fast
  • expected
  • frequent

SwiftUI compares values, not objects.

Recomposing views is cheap.
Recreating identity is not.


🧠 Key Insight

SwiftUI is optimized for frequent body recomputation, not frequent identity changes.

📐 4. Layout Pass

After body recomputation, SwiftUI performs layout:

  1. Parent proposes size
  2. Child chooses size
  3. Parent positions child

This happens:

  • after every invalidation
  • during animations
  • during size changes
  • during rotations
  • during list updates

Layout becomes expensive when:

  • GeometryReader is overused
  • layouts depend on dynamic measurements
  • views constantly change size

🧨 Layout Performance Killers

  • GeometryReader inside lists
  • deeply nested stacks
  • dynamic size measurement per frame
  • view size depending on async data repeatedly

🔍 5. Diffing (The Critical Step)

SwiftUI compares:

  • previous view tree
  • new view tree

Using:

  • view type
  • position
  • identity (id)

If identity matches:

  • state is preserved
  • view is updated in place

If identity changes:

  • state is destroyed
  • view recreated
  • animations reset
  • tasks restart

Diffing is where most “bugs” come from.


🆔 Identity Is Everything

This is fast:

ForEach(items, id: \.id) { item in
    Row(item)
}
Enter fullscreen mode Exit fullscreen mode

This is expensive and buggy:

ForEach(items) { item in
    Row(item)
        .id(UUID())
}
Enter fullscreen mode Exit fullscreen mode

You just forced a full teardown every update.


🎨 6. Rendering (GPU Stage)

After diffing:

  • SwiftUI issues drawing commands
  • GPU renders the final pixels

Rendering is usually fast unless you use:

  • heavy blurs
  • layered materials
  • complex masks
  • offscreen rendering
  • too many shadows

Rendering problems show up as:

  • dropped frames
  • janky animations
  • scrolling stutter

🧵 7. Animations in the Pipeline

Animations affect every stage:

  • state changes → animated
  • layout interpolated
  • rendering interpolated

Important:

  • animations don’t skip layout
  • animations don’t skip diffing
  • animations amplify inefficiencies

This is why poor architecture feels much worse when animated.


⚙️ 8. Async & Rendering Coordination

Bad pattern:

Task {
    data = await load()
}
Enter fullscreen mode Exit fullscreen mode

Better:

@MainActor
func load() async {
    isLoading = true
    defer { isLoading = false }
    data = await service.load()
}
Enter fullscreen mode Exit fullscreen mode

Why?

  • predictable invalidation
  • single render cycle
  • avoids cascading updates

Async should batch state changes, not drip-feed them.


🧪 9. Why Lists Are Special

Lists:

  • reuse views aggressively
  • rely heavily on identity
  • trigger frequent layout passes
  • render offscreen rows

Performance depends on:

  • stable IDs
  • lightweight rows
  • minimal layout work
  • cached data

Lists amplify every mistake in the rendering pipeline.


🧠 Mental Debugging Checklist

When something feels slow, ask:

  1. What state changed?
  2. How much did it invalidate
  3. Did identity change?
  4. Did layout get expensive?
  5. Did animation amplify the cost?
  6. Did async trigger multiple updates?

You can almost always pinpoint the issue.


🚀 Final Mental Model

Think in layers, not code:

State
 
Invalidation
 
Body
 
Layout
 
Diffing
 
Rendering
Enter fullscreen mode Exit fullscreen mode

Control:

  • state scope
  • identity stability
  • layout complexity

…and SwiftUI becomes fast, predictable, and scalable.


🏁 Final Thoughts

SwiftUI performance isn’t about tricks.

It’s about respecting the rendering pipeline.

Once you understand:

  • where work happens
  • why identity matters
  • how layout impacts performance

You stop fighting SwiftUI — and start working with it.

Top comments (0)