
Building an Infinite Workout Feed with Lazy Route Images
A practical approach to responsive HealthKit UIs
This post introduces a demo project that shows how to build a responsive, SwiftUI-based workout feed using HealthKit. The project is designed for iOS developers who want to display large sets of workout data, including route visualizations, while keeping the interface fast and efficient.
The demo focuses on a specific challenge: presenting an infinite scrolling workout list with lazy-loaded route images, backed by persistent caching and optimized for both light and dark mode.
You can download the full project here and explore how each part works. The implementation uses SwiftData for local caching, actors for thread-safe HealthKit access, and MapKit snapshots to generate route images only when needed.
If you’re working on a fitness app or integrating HealthKit into a custom dashboard, this demo offers a practical foundation to build on.

Designing for a Better User Experience
Building a workout feed isn’t just about listing data. It’s about creating an interface that feels fast, looks polished, and handles real-world usage gracefully. HealthKit provides detailed data, but it requires additional structure to support scalable UI patterns like infinite scrolling and dynamic image loading.
This demo was designed with that goal in mind. It balances performance, clarity, and responsiveness by focusing on three key areas:
- Smooth infinite scrolling using SwiftData
- On-demand generation and caching of route images
- Asynchronous, thread-safe access to HealthKit using Swift actors
These patterns create a user experience that feels natural, even when working with hundreds or thousands of workouts.
Architecture Overview
1. Feed Model with SwiftData
To support infinite scrolling and fast rendering, the feed uses a lightweight SwiftData model called CachedWorkout
. This model stores only the data needed for quick rendering in the workout list:
@Model
final class CachedWorkout {
var identifier: UUID
var activityType: HKWorkoutActivityType
var startDate: Date
var endDate: Date
var distance: Double
var routeStatus: WorkoutRouteStatus
}
By using this model, SwiftUI can render the list efficiently using @Query
. Full HKWorkout
data is only fetched when the user navigates to a detail view or when more information is required. This keeps memory usage low and scrolling smooth.
2. Lazy Route Image Generation
Route images are generated using MapKit snapshots and stored locally. Since HKWorkout
objects are immutable, the app can cache these images indefinitely, unless the system appearance changes.
struct MapImage: View {
@State private var manager: Manager
var body: some View {
ZStack {
if let image = manager.image, manager.status == .generated {
Image(uiImage: image)
.resizable()
.scaledToFit()
} else {
imagePlaceholder()
}
}
.onAppear(perform: load)
.onChange(of: colorScheme) { _, _ in
load()
}
}
}
The Manager
handles checking for existing images and generating new ones as needed. This ensures that the route image is only created once per workout per theme, and only when the view appears.
Why Use Images Instead of Map Views
While it might be tempting to drop a Map
view directly into each row of your workout feed, that approach doesn’t scale well. Live maps consume significant memory and CPU, especially in a scrolling list. The result can be slow rendering, choppy scrolling, or even crashes on memory-constrained devices.
Instead, the demo generates static map images using MKMapSnapshotter
. These are lightweight, can be cached, and render instantly in SwiftUI. Users still get a rich visual experience without the performance cost of rendering multiple live maps.
Generating the Route Snapshot
Here’s a simplified example of how route snapshots are generated. This function takes route coordinates and returns a PNG image with the route overlay:
func generate(coordinates: [CLLocationCoordinate2D], size: CGSize) async throws -> Data {
let region = MKCoordinateRegion(coordinates: coordinates)
let options = MKMapSnapshotter.Options()
options.region = region
options.size = size
let snapshot = try await MKMapSnapshotter(options: options).start()
let image = snapshot.image
return UIGraphicsImageRenderer(size: image.size).pngData { _ in
image.draw(at: .zero)
let points = coordinates.map { snapshot.point(for: $0) }
let path = UIBezierPath()
path.move(to: points[0])
for point in points.dropFirst() { path.addLine(to: point) }
UIColor.orange.setStroke()
path.lineWidth = 5
path.stroke()
}
}
This snapshot is saved to disk and reused in the feed, allowing for a fast and responsive interface. The full version in the demo includes support for dark mode and other refinements.
3. Background HealthKit Access with Actors
To manage thread safety and keep the UI responsive, HealthKit access is handled inside a Swift actor. This ensures that all operations are serialized and isolated from the main thread.
actor HealthProvider {
let store = HKHealthStore()
func fetchWorkoutRoute(forID uuid: UUID) async throws -> [CLLocation] {
let workout = try await fetchWorkout(with: uuid)
return try await fetchWorkoutRoute(for: workout)
}
}
Using actors helps avoid race conditions and keeps the architecture clean and maintainable.
Supporting Features
Theme-Aware Image Caching

Route visibility can vary significantly between light and dark modes, especially when using standard MapKit styling. To support both themes, the app generates and caches two image variants per workout:
workout_uuid_light.png
workout_uuid_dark.png
When the system color scheme changes, the image is regenerated in the background. This ensures consistent visual quality without blocking the UI.
Where Images Are Stored
Generated images are saved in the app’s Application Support Directory. This location is ideal for assets that are not user-generated but are essential for the app’s experience. Unlike the Caches directory, which may be purged by the system when space is low, Application Support Directory is persistent and backed up, but not directly visible to the user like the Documents directory.
Since HKWorkout
objects are immutable, these images never need to be invalidated. Once generated, they remain valid unless the user deletes the app. Storing them in a stable location ensures that your app doesn’t waste resources recreating them unnecessarily.
Since HKWorkout
objects are immutable, route images do not need to be regenerated unless the system theme changes or the user reinstalls the app. There is no need to expire or evict them under normal conditions.
When to Delete
The only time an image should be deleted is when the corresponding workout has been removed from HealthKit. At that point, the cached image is no longer relevant and can be safely discarded.
Graceful Handling of Errors
Not all workouts contain route data, and some image generations may fail due to incomplete location records. When that happens, the app gracefully marks the workout with a failed status and avoids retrying automatically. This helps preserve battery life and prevents wasted processing.
The user experience remains stable, with placeholders shown instead of broken image icons or endless loading indicators.
Decoupling from HKWorkout
For detail views and SwiftUI previews, the app uses a simplified Workout
struct:
struct Workout: Identifiable, Hashable {
let id: UUID
let activityType: HKWorkoutActivityType
let route: [CLLocation]
var coordinates: [CLLocationCoordinate2D] {
route.map(\.coordinate)
}
}
This model is easy to test and preview and removes the dependency on HealthKit in parts of the app that do not need it.
What the Demo Includes
This project focuses on solving the performance and architectural challenges involved in building a scalable workout feed. It includes:
- A SwiftData caching layer for fast rendering
- Lazy route image generation using MapKit snapshots
- Theme-aware image handling
- HealthKit access via Swift actors
The demo supports three activity types: cycling, walking, and running. It does not include incremental sync using HKQueryAnchor or real-time updates, but those can be added later depending on the app’s needs.
Why This Approach Works
- Performance: Route images are generated only when needed and cached long term
- Scalability: SwiftData keeps list rendering fast even with large datasets
- Responsiveness: All HealthKit access is performed off the main thread
- Flexibility: Clear separation between data models supports testing and previews
By combining modern SwiftUI tools with a thoughtful caching and rendering strategy, this demo offers a path to a better HealthKit-powered UI.
Try the Demo
You can download the complete project here to explore the code and test the UI. The implementation is modular and ready to adapt to a wide range of fitness apps.
This is not a full-featured fitness tracker. It is a focused example that demonstrates a repeatable solution to a common UI challenge in HealthKit apps. If you are building something similar, this approach can save time and improve the user experience out of the box.