Performance degradation and redraw loops when syncing SwiftUI Charts with custom AxisMarks

I am reporting a reproducible performance issue in iOS 18.6 where synchronizing the scroll position of two Chart views via chartScrollPosition(id:) causes a complete redraw loop when custom AxisMarks are used. This occurs even when the axis marks are technically "hidden," leading to significant frame drops and stuttering on modern hardware like the iPhone 15.

Environment

Device: iPhone 15 OS: iOS 18.6 (22G86) Frameworks: SwiftUI, Swift Charts, Observation

The Issue When using a shared @Observable state to sync two charts, the scrolling is fluid only if the axes are at their default settings. As soon as a custom AxisMarks block is added to either chart, the following behavior is observed:

Diffing Failure: The framework appears unable to maintain the identity of the axis components during the scroll update.

Redraw Loop: Instead of an incremental scroll translation, the diffing algorithm triggers a full reload/re-render of both charts on every scroll offset change.

Impact: CPU spikes to 100% and the UI becomes unresponsive.

This happens even if the custom AxisMarks is used solely to hide the axis (e.g., AxisMarks { _ in }), suggesting the issue is with the custom declaration itself rather than the complexity of the marks being rendered.

Steps to Reproduce Create two Chart views in a VStack. Bind both to a single @Observable property using .chartScrollPosition(id: $state.pos). Add any .chartXAxis { AxisMarks(...) { ... } } modifier. Scroll either chart; observe the stuttering.

import SwiftUI import Charts import Observation

@Observable class ChartState { var scrollPos: Date = .now }

struct PerformanceBugView: View { @State private var state = ChartState()

var body: some View {
    VStack {
        Chart(data) { ... }
            .chartScrollPosition(id: $state.scrollPos)
            .chartXAxis {
                // This custom mark triggers the performance issue
                AxisMarks { _ in AxisValueLabel() }
            }

        Chart(data) { ... }
            .chartScrollPosition(id: $state.scrollPos)
    }
}

}

Questions for the Community/Apple Engineers: Is there a way to provide a stable identifier to AxisMarks to prevent them from being treated as "new" during a scroll update? Why does even an empty AxisMarks block (used for hiding) trigger a layout invalidation that standard axes do not? Are there internal optimizations for chartScrollPosition that are bypassed when the axis layout is customized?

I was facing the same issue with very poor performance and narrowed the issue down to the XAxis. After implementing windowing for displaying data to reduce the amount being drawn in the chart, I also applied the same technique to AxisMarks to get acceptable performance.

I basically observe the scroll position and provide the data and marks in an area around that position, while keeping chartXScale and chartXVisibleDomain constant.

From a perfomance perspective your going to have a few issues here.

  1. every time the scroll position updates (for either chart) both charts will need to fully re-evaluting there full content

  2. the scroll position binding is not going to keep these charts perfectly in sync, in when it comes to edge effect bounces.

To solve issue 1)

do not bound your chartScrollPosition bindings from within the same hosting view as the Chart.

Instead higher up in your view tree create an @Observable class and inject that into the env.

then create a SwiftUI ViewModifer that reads this env value and from it creates a binding for the .chartScrollPosition(id: $state.scrollPos)

then you arched this to your chart Chart {}.viewModifier(MyScrollModifer()) you shoudl also do the same for the axis modifier.

This way when the scroll offset updates your not triggering on each tiny update the full chart content to re-evaluate.

for issue number 2, you can use the swfitui scroll behavior modifiers to stop your charts having over scroll this way the scrolling between the charts will align better.

In general you must structure your code in a way that means the view body that hosts your Chart {} is ONLY ever re-evaluated when the data your passing into the chart changes! you shoudl be able to start you app, open then screen then place a breakpoint within that view body and then continue to use teh charts, scrolling etc without ever hitting that breakpoint unless you expliclty changed the data being provided. That Chart {}. is very costly to evaluate (very very) and you DO NOT want to do so during scroll.

Performance degradation and redraw loops when syncing SwiftUI Charts with custom AxisMarks
 
 
Q