Building a pet app means juggling high-resolution photos, real-time notifications, and background sync—all while keeping the UI buttery smooth. Users expect instant scrolling through adoption listings, quick zoom on a puppy's face, and no lag when scheduling a vet visit. The standard Swift patterns often fall short under these demands. This checklist covers advanced techniques that we've seen work in production: lazy data loading, diffable data sources, actor-based concurrency, custom caching, and more. Each section includes a concrete scenario, a pattern breakdown, and a quick reference for when to apply it.
Why Pet App Performance Matters Now
Pet apps have crossed into daily-use territory. A photo-heavy feed that stutters on a three-year-old iPhone will drive users to a competitor. Background tasks that drain the battery during a walk will earn one-star reviews. And any delay in loading critical info—like vaccine records or emergency vet contacts—can erode trust fast.
We've seen teams invest heavily in feature sets while neglecting the performance layer. The result: an app that looks great in a demo but crumbles under real usage. The good news is that Swift offers mature patterns to handle these loads without rewriting everything. The challenge is choosing the right pattern for each bottleneck and knowing where to stop before over-engineering.
The Real Cost of Jank
Frame drops aren't just cosmetic. On a pet adoption app, a stuttering scroll can cause a user to accidentally tap the wrong listing, leading to frustration. In a vet scheduling flow, a laggy calendar picker might make a user abandon the booking altogether. Performance issues also correlate with lower session times and higher uninstall rates—especially on older devices that still represent a large share of the market.
What This Guide Covers
We'll walk through six advanced patterns: lazy image loading with prefetching, diffable data sources for smooth updates, actor-based concurrency for safe background work, custom caching layers, protocol-oriented view models for testability, and efficient networking with URLSession and Combine. Each pattern includes a checklist of when to use it, a simple code sketch, and a note on common mistakes.
Core Idea: Lazy Loading and Prefetching
The central principle behind pet app performance is simple: never do work the user can't see. Lazy loading defers expensive operations—like decoding a large image—until the moment they're needed. Prefetching takes it a step further by starting that work just before the user scrolls to it, so the data is ready when they arrive.
In a typical pet feed, each cell might display a photo, a name, a breed, and a status badge. Loading all images upfront for a list of 200 pets would cause a massive memory spike and a long initial load. Instead, we load only the visible cells and prefetch the next few offscreen. UICollectionView and UITableView both support prefetching through the UITableViewDataSourcePrefetching protocol.
How Prefetching Works
The prefetch delegate receives an array of index paths for cells that will likely become visible soon. You start loading the data (e.g., download an image) in that callback. When the cell actually appears, the data is already cached, so the cell can display it instantly. The system also calls a cancel callback if the user changes direction, so you can abort unnecessary work.
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let pet = pets[indexPath.row]
ImageLoader.shared.loadImage(url: pet.photoURL)
}
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let pet = pets[indexPath.row]
ImageLoader.shared.cancelLoad(url: pet.photoURL)
}
}Common Pitfalls
One mistake is prefetching too aggressively. If you prefetch 20 cells ahead on a slow network, you might start downloads that never get used if the user scrolls back. Another pitfall is forgetting to cancel prefetches, which can lead to wasted bandwidth and memory. A good rule is to prefetch only the next screen's worth of data (typically 5-10 cells) and always implement the cancel method.
How Diffable Data Sources Keep the UI Responsive
Pet app data changes frequently: a pet gets adopted, a new listing appears, a user updates their favorites. Updating the UI with reloadData() is simple but causes a full layout recalculation, which can drop frames. Diffable data sources, introduced in iOS 13, compute the exact difference between the old and new state and apply minimal animations.
The pattern works by defining a snapshot—a representation of the current data—and applying it to the data source. The system automatically inserts, deletes, and reorders rows with smooth animations. This is especially valuable in pet apps where the list might change while the user is scrolling.
Setting Up a Diffable Data Source
You start by creating a UITableViewDiffableDataSource or UICollectionViewDiffableDataSource, providing a cell provider closure. Then, whenever the data changes, you create a new snapshot, append or delete items, and apply it. The system handles the rest.
enum Section { case main }
var dataSource: UITableViewDiffableDataSource<Section, Pet>!
dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, pet in
let cell = tableView.dequeueReusableCell(withIdentifier: "PetCell", for: indexPath) as! PetCell
cell.configure(with: pet)
return cell
}
func updatePets(_ newPets: [Pet]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Pet>()
snapshot.appendSections([.main])
snapshot.appendItems(newPets)
dataSource.apply(snapshot, animatingDifferences: true)
}When to Use It
Diffable data sources shine when your data changes in unpredictable ways—like a live feed of adoptable pets that gets updated from a server push. They also simplify state management because you never manually insert or delete rows; you just update the snapshot. However, they add a small overhead for computing diffs, so for static lists with fewer than 20 items, reloadData() might be fine.
Actor-Based Concurrency for Safe Background Work
Pet apps often perform multiple background tasks: syncing data, processing images, sending notifications. Before Swift concurrency, managing thread safety required careful use of serial queues or locks. Actors, introduced in Swift 5.5, provide a simpler model: an actor protects its own state, ensuring that only one task accesses it at a time.
Consider a pet image cache that stores downloaded images in memory. Multiple cells might request the same image simultaneously. Without protection, you could end up with duplicate downloads or corrupted cache entries. An actor can serialize access to the cache and coordinate downloads.
Building a Caching Actor
actor ImageCache {
private var cache: [URL: UIImage] = [:]
private var pendingTasks: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = cache[url] { return cached }
if let existingTask = pendingTasks[url] {
return try await existingTask.value
}
let task = Task { try await downloadImage(from: url) }
pendingTasks[url] = task
let image = try await task.value
cache[url] = image
pendingTasks[url] = nil
return image
}
}This actor ensures that the same URL is only downloaded once, even if multiple callers request it at the same time. The pendingTasks dictionary prevents redundant network calls, and the actor's isolation guarantees that the cache is updated safely.
When Actors Are Overkill
Actors add overhead for every access because of the implicit synchronization. For simple value types that are never mutated concurrently, a struct with a serial queue might be lighter. Also, actors can cause deadlocks if you call a synchronous method from within the same actor's context. Use them for shared mutable state that crosses task boundaries, not for every piece of data.
Custom Caching Layers for Repeated Data
Pet apps display the same data repeatedly: breed images, vet logos, user avatars. Relying solely on URLSession's default cache is often insufficient because it doesn't persist across launches and has limited control. A custom caching layer lets you define expiration policies, memory limits, and disk storage.
A good caching strategy uses two tiers: a fast in-memory cache (NSCache or a dictionary) and a slower on-disk cache (file system or Core Data). The in-memory cache serves repeated requests within a session, while the disk cache survives app restarts.
Designing a Two-Tier Cache
class PetImageCache {
private let memoryCache = NSCache<NSURL, UIImage>()
private let diskCacheURL: URL
init() {
memoryCache.countLimit = 100
let paths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
diskCacheURL = paths[0].appendingPathComponent("ImageCache")
try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true)
}
func getImage(for url: URL) -> UIImage? {
if let image = memoryCache.object(forKey: url as NSURL) { return image }
let fileURL = diskCacheURL.appendingPathComponent(url.lastPathComponent)
if let data = try? Data(contentsOf: fileURL), let image = UIImage(data: data) {
memoryCache.setObject(image, forKey: url as NSURL)
return image
}
return nil
}
func setImage(_ image: UIImage, for url: URL) {
memoryCache.setObject(image, forKey: url as NSURL)
let fileURL = diskCacheURL.appendingPathComponent(url.lastPathComponent)
if let data = image.jpegData(compressionQuality: 0.8) {
try? data.write(to: fileURL)
}
}
}Expiration and Eviction
NSCache automatically evicts objects when memory is low, but you should also set a count limit and total cost limit. For disk cache, implement a periodic cleanup that removes files older than a certain date (e.g., 7 days). You can also use a URLSession with a custom URLCache for HTTP caching, but that's less flexible for custom expiration rules.
Protocol-Oriented View Models for Testability
Performance isn't just about runtime speed; it's also about development speed. A well-structured view model layer lets you test business logic without running the UI, which catches bugs early and reduces regressions. Protocol-oriented programming in Swift makes it easy to swap real services with mocks.
In a pet app, a view model might fetch a list of pets, filter by species, and format dates. By defining a protocol for the data service, you can inject a mock during tests and verify that the view model behaves correctly without hitting the network.
Example: PetListViewModel
protocol PetServiceProtocol {
func fetchPets() async throws -> [Pet]
}
class PetListViewModel {
private let service: PetServiceProtocol
@Published var pets: [Pet] = []
@Published var error: Error?
init(service: PetServiceProtocol) {
self.service = service
}
func loadPets() async {
do {
pets = try await service.fetchPets()
} catch {
self.error = error
}
}
}In tests, you create a mock PetServiceProtocol that returns predefined data. This lets you test filtering, sorting, and error handling without waiting for network calls. The pattern also makes it easy to switch from a remote service to a local database without changing the view model.
Limitations
Protocols add indirection, which can make the code harder to follow for beginners. Overusing protocols for every tiny dependency leads to unnecessary complexity. Use them for boundaries where you need testability or flexibility—like networking, caching, and persistence—not for simple value transformations.
Efficient Networking with URLSession and Combine
Network requests are often the biggest performance bottleneck in pet apps. Poorly managed requests can cause timeouts, duplicate downloads, and wasted data. URLSession, combined with Combine publishers, provides a declarative way to manage requests, retries, and cancellation.
One advanced pattern is to use a single URLSession instance with a custom delegate to handle authentication and caching. Combine's dataTaskPublisher turns a request into a publisher that you can chain with operators like retry, timeout, and map.
Building a Reusable Network Client
class PetAPIClient {
private let session: URLSession
private let decoder = JSONDecoder()
init() {
let config = URLSessionConfiguration.default
config.requestCachePolicy = .returnCacheDataElseLoad
config.timeoutIntervalForRequest = 30
session = URLSession(configuration: config)
}
func fetch<T: Decodable>(_ endpoint: Endpoint) -> AnyPublisher<T, Error> {
let request = endpoint.urlRequest
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw APIError.invalidResponse
}
return data
}
.decode(type: T.self, decoder: decoder)
.retry(2)
.eraseToAnyPublisher()
}
}When to Use Combine vs. Async/Await
Combine is powerful for complex request pipelines that need debouncing, throttling, or merging multiple sources. For simple request-response patterns, async/await is cleaner and easier to debug. In a pet app, you might use Combine for a search-as-you-type feature (debouncing input) and async/await for straightforward data fetches.
Limits of the Approach
No pattern is a silver bullet. Lazy loading can cause visible loading spinners if prefetching isn't aggressive enough. Diffable data sources add complexity for simple lists. Actors can introduce subtle bugs if you mix synchronous and asynchronous code. Custom caches require maintenance and can bloat the app's storage if not cleaned.
The key is to measure before optimizing. Use Instruments to identify real bottlenecks—don't guess. A pet app that loads 50 images might not need a custom cache if the network is fast and the images are small. Start with the simplest solution that meets your performance budget, then layer in complexity only where needed.
We recommend keeping a performance checklist: profile on a mid-range device, test on slow networks, monitor memory usage, and review crash logs. The patterns in this article are tools, not rules. Apply them where they solve a real problem, and don't be afraid to revert if they add more overhead than they save.
Next Steps
- Profile your current pet app with Instruments (Time Profiler, Allocations, Network). Identify the top three bottlenecks.
- Implement lazy image loading with prefetching for any scrollable feed. Measure the improvement in scroll smoothness.
- Replace manual data source updates with diffable data sources if you see frequent UI updates.
- Introduce an actor for shared mutable state—like a cache or a download manager—and verify thread safety.
- Set up a two-tier cache (memory + disk) for images that are reused across sessions.
- Write unit tests for your view models using protocol mocks; aim for at least 80% coverage of business logic.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!