SwiftUI SensoryFeedback Cache Key Pitfall: Do Not Store Continuous Values
Background
The following code can cause memory usage to grow quickly after dragging the slider, because of an internal SwiftUI implementation issue.
@available(iOS 17.0, macOS 14.0, watchOS 10.0, tvOS 17.0, *)
struct SensoryFeedbackExample: View {
@State private var intensity: Double = 1.0
@State private var feedbackTrigger = false
var body: some View {
VStack {
Text("Intensity: \(intensity, specifier: "%.2f")")
Slider(value: $intensity, in: 0...1)
Button("Trigger Impact") {
feedbackTrigger.toggle()
}
.sensoryFeedback(
.impact(weight: .light, intensity: intensity),
trigger: feedbackTrigger
)
}
}
}
I filed FB21333309 with Apple last year. I recently got the update that it is confirmed fixed in iOS 27.
The FB21333309 cache bug I submitted is now confirmed to be fixed on iOS 27.
— Kyle Ye (@KyleSwifter) June 16, 2026
Instead of introducing a new CacheKey like I suggested, SwiftUI team choose to move the intensity payload out of the FeedbackType enum to a new Payload enum and add a new payload var to SensoryFeedback storage. pic.twitter.com/rKMgKT5dZj
The issue itself is small, but easy to miss: the intensity in SensoryFeedback.impact(..., intensity:) is a playback parameter for one feedback event. It should not decide whether the cache creates a new feedback generator.
The old implementation mixed two things together. FeedbackType described the kind of feedback, but it also carried runtime parameters such as intensity in its enum payload. UIKit then used FeedbackType as the feedback generator cache key, so a continuously changing Double could expand the cache key space.
A slider is enough to hit this path:
Slider(value: $intensity)
.sensoryFeedback(
.impact(weight: .medium, intensity: intensity),
trigger: trigger
)
With the old shape, values like these became different cache keys:
.impactWeight(.medium, 0.10)
.impactWeight(.medium, 0.11)
.impactWeight(.medium, 0.12)
From UIKit's point of view, though, all of them need the same .medium style generator. What changes is the intensity passed to impactOccurred(intensity:at:), not the generator identity.
So the old implementation could lead to a subtle result: an app only changes intensity continuously, while the cache may create and keep many unnecessary UIImpactFeedbackGenerator instances.

Timeline
The timeline I have is:
- iOS 17 introduced
SensoryFeedback. At that time, impact intensity was part ofFeedbackType, and the cache behavior followed that internal structure. - On December 13, 2025, I found that changing intensity continuously could create many cached
UIImpactFeedbackGeneratorinstances and filed FB21333309. - On June 12, 2026, Apple Feedback Assistant notified me that FB21333309 had been updated and the issue was fixed.
- On June 16, 2026, the SwiftUI dump for iOS 27 confirmed the implementation detail: intensity moved out of
FeedbackTypeand into the newPayloadstorage.
The Old Shape: FeedbackType Was Too Heavy
In the SwiftUI 7.2.5 dump, SensoryFeedback had a single stored type:
struct SensoryFeedback {
var type: SensoryFeedback.FeedbackType
}
The intensity lived directly in the enum payload:
enum SensoryFeedback.FeedbackType {
case impactWeight(SensoryFeedback.Weight.Storage, Double)
case impactFlexibility(SensoryFeedback.Flexibility.Storage, Double)
case success
case warning
case error
// ...
}
If FeedbackType only means "the complete feedback value," this representation looks fine. The problem starts when the same type is also reused as the UIKit generator cache key.
For the cache, the important part is the stable identity of the generator. For impact feedback, weight or flexibility affects the generator style and belongs in the key. Intensity is only used when the feedback is played, so it should not be part of the key.
My suggested fix at the time was to derive a cache-only CacheKey and drop intensity from it:
private enum GeneratorCacheKey: Hashable {
case success
case warning
case error
case selection
case alignment
case pathComplete
case impactWeight(SensoryFeedback.Weight.Storage)
case impactFlexibility(SensoryFeedback.Flexibility.Storage)
init?(_ type: SensoryFeedback.FeedbackType) {
switch type {
case .success: self = .success
case .warning: self = .warning
case .error: self = .error
case .selection: self = .selection
case .alignment: self = .alignment
case .pathComplete: self = .pathComplete
case let .impactWeight(weight, _): self = .impactWeight(weight)
case let .impactFlexibility(flexibility, _): self = .impactFlexibility(flexibility)
default: return nil
}
}
}
That fix is local and effective. The generator cache depends only on the generator style, while intensity is passed later when the feedback is generated.
iOS 27: Payload Is Split Out
iOS 27 takes a different approach. Instead of adding a new cache key type to compensate for the old shape, the internal model itself is split: the stable feedback type goes into FeedbackType, and runtime parameters go into Payload.
In the SwiftUI 8.0.66 dump:
struct SensoryFeedback {
var type: SensoryFeedback.FeedbackType
var payload: SensoryFeedback.Payload
}
The intensity has moved into a new Payload enum:
enum SensoryFeedback.Payload {
case intensity(Double)
case empty
}
And FeedbackType now contains only the stable shape of the feedback:
enum SensoryFeedback.FeedbackType {
case impactWeight(SensoryFeedback.Weight.Storage)
case impactFlexibility(SensoryFeedback.Flexibility.Storage)
case success
case warning
case error
// ...
}
Another change points in the same direction: UIKitSensoryFeedbackCache.implementation no longer takes only a FeedbackType. It now takes the full SensoryFeedback.
// Old
UIKitSensoryFeedbackCache.implementation(
type: SensoryFeedback.FeedbackType
) -> LocationBasedSensoryFeedback?
// New
UIKitSensoryFeedbackCache.implementation(
SensoryFeedback
) -> LocationBasedSensoryFeedback?
That gives the cache both pieces of information:
- The
feedback.typefield is stable enough to key the cached generator. - The
feedback.payloadfield still carries the intensity needed byimpactOccurred(intensity:at:).
So the cache can continue to be:
Dictionary<SensoryFeedback.FeedbackType, UIFeedbackGenerator>
The difference is that FeedbackType no longer includes a continuously changing Double.
Why This Structure Is More Correct
My suggested direction was essentially to add a CacheKey: keep the original feedback value, then derive a separate representation for caching.
The iOS 27 design is more accurate because it does not patch the cache with a separate key. It redraws the model boundary:
- Stable identity is represented by
FeedbackType. - Dynamic per-play data is represented by
Payload.
That split matches the UIKit side:
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred(intensity: intensity)
The style decides which generator you need. The intensity decides how to play it this time. They have different lifetimes, so they should not live in the same cache identity.
This split also leaves a place for similar runtime parameters later. If a future value only affects one playback and does not affect the generator identity, it should go into payload instead of being pushed into FeedbackType.
Practical Impact
For app code, the public API stays the same:
SensoryFeedback.impact(weight: .medium, intensity: value)
SensoryFeedback.impact(flexibility: .soft, intensity: value)
The change is below the API surface. On iOS 27, varying intensity no longer poisons the generator cache identity.
If you still support iOS 17 through iOS 26, it is still worth avoiding arbitrary continuous intensity values in sensoryFeedback. Quantizing the value or only triggering feedback at meaningful thresholds avoids unnecessary pressure on older systems.