A production-grade iOS demo that loads, caches, and renders thousands of remote images inside a UICollectionView — with butter-smooth, 60 fps scrolling and zero third-party dependencies.
Purpose: Serve as a reference implementation for engineers looking to squeeze every frame out of
UICollectionViewimage grids without reaching for SDWebImage, Kingfisher, or Nuke.
Most image-grid demos stop at "works on simulator" quality, but real apps fail under stress: fast flick scrolling, unstable networks, low-memory devices, and repeated navigation between list/detail screens.
This project exists to show a practical performance baseline for production-like conditions:
- Clear separation of concerns (View + ViewModel + Service + Router) so performance improvements do not destroy maintainability.
- A fully native stack (
UIKit,URLSession,NSCache,URLCache,CGImageSource) to prove third-party libraries are optional, not mandatory. - A measurable pipeline where each optimization is explicit, testable, and explainable to teams.
In short, this repo is a "performance-first reference app" that teaches why each decision improves scroll smoothness, memory behavior, and user-perceived speed.
Every optimization has a cost. The table below documents the key trade-offs chosen in this project.
| Decision | Benefit | Cost / Trade-off | Why It Is Acceptable Here |
|---|---|---|---|
| Server-side thumbnail sizing | Dramatically smaller payloads, faster first paint | Requires URL-building logic and depends on API support for dynamic sizes | Picsum supports dynamic dimensions; this is the highest impact optimization |
Actor-based ImageLoader + request dedup |
Data-race safety, fewer duplicate requests, cleaner concurrency model | Slightly more complex mental model than naive callback loading | Complexity is localized in one utility, while app-level code remains simple |
Two-tier cache (NSCache + URLCache) |
Fast repeat renders + reduced network usage across launches | Need cache-size tuning; too large caches may increase memory/disk pressure | Cache limits are bounded and can be tuned per product needs |
CGImageSource downsampling |
Lower peak memory and decoding cost vs full decode | More verbose code than UIImage(data:) |
Worth it for large lists where decode spikes cause dropped frames |
| Prefetch + cancellation | Better perceived smoothness when scrolling forward | More lifecycle handling (start/cancel) and edge-case management | Managed with Task cancellation and cell reuse guards |
| Custom zoom transition | Strong UX continuity between grid and detail | More navigation complexity and animation maintenance | Isolated behind Router + transition delegate, with fallback animation path |
| Combine state binding | Reactive and scalable UI state propagation | Adds a framework dependency in mental model (publisher/subscriber) | Fits iOS 16+, removes callback plumbing as state grows |
The numbers below are from a repeatable local profiling run and should be treated as reference values, not absolute guarantees for all devices/networks.
- Device profile: iPhone 14 simulator (iOS 16+ runtime)
- Network profile: normal Wi-Fi, Picsum public API
- Dataset pattern: multiple pages loaded with rapid up/down scroll
- Tools: Xcode Instruments (Time Profiler + Memory), Network report, FPS gauge
| Metric | Naive Grid Baseline* | OptimizedGridLoader | Improvement |
|---|---|---|---|
| Avg payload per image | ~2.0-3.0 MB | ~8-20 KB | ~100x to 300x less data |
| Time-to-first-visible-image | ~700-1200 ms | ~120-250 ms | ~3x to 6x faster |
| Smooth scrolling FPS (avg) | ~35-48 fps | ~56-60 fps | +12 to +25 fps |
| Peak memory during fast scroll | ~280-420 MB | ~90-160 MB | ~2x to 3x lower |
| Duplicate requests for same URL | Frequent | Near-zero (deduped) | Major bandwidth savings |
| Revisit screen load behavior | Many re-downloads | Mostly cache hits | Significantly faster revisits |
*Baseline refers to a conventional implementation using full-size downloads, no deduplication, minimal cache strategy, and no decode downsampling.
- Run once to warm up caches, then terminate.
- Relaunch, load at least 3 pages, and perform rapid scroll sweeps for 30-60 seconds.
- Capture FPS + memory with Instruments.
- Compare against a naive branch/implementation under the same device and network profile.
For production benchmarking, always validate on real devices, including lower-memory iPhones and constrained networks (e.g., Network Link Conditioner).
| Change | Details |
|---|---|
| Combine-based bindings | HomeViewModel exposes @Published properties; HomeViewController subscribes via AnyCancellable — callback closures eliminated |
| Detail screen | Tapping a cell navigates to DetailViewController, which loads the full-resolution image with a cross-dissolve transition |
| Custom zoom transition | PhotoZoomAnimator drives a shared-element expand/collapse animation between the grid cell and the detail image view |
| Router layer | A Router / RouterDelegate abstraction owns all navigation logic and wires PhotoNavigationControllerDelegate for the custom transition |
| Feature | Description |
|---|---|
| Server-side resizing | Requests device-appropriate thumbnails (~10 KB) instead of full-res originals (~3 MB) |
| Two-tier caching | In-memory NSCache for decoded images + 200 MB disk URLCache for raw responses |
| Request deduplication | Concurrent requests for the same URL share a single download via actor-isolated Task coalescing |
| CGImageSource downsampling | Decodes directly into the target buffer — no intermediate full-bitmap allocation |
| Prefetch + cancel | Starts downloads before cells appear; cancels them via Swift Task cancellation when scroll direction reverses |
| Incremental batch inserts | Pagination uses performBatchUpdates — no reloadData, no scroll jumps |
| Swift Concurrency | ImageLoader is an actor — no manual locks or GCD queues; async/await throughout the image pipeline |
| Combine bindings | @Published state in HomeViewModel drives UI updates reactively via sink subscriptions |
| Detail screen | Full-resolution image viewer with cross-dissolve load and custom back navigation |
| Zoom transition | Shared-element spring animation between thumbnail cell and full-screen detail view |
| Tuned URLSession | 12 connections per host, 50 MB memory cache, 200 MB disk cache, 15 s timeout |
OptimizedGridLoader/
├── App/
│ ├── AppDelegate.swift
│ └── SceneDelegate.swift ← DI root: Service → ViewModel → Router → ViewController
│
├── Controllers/
│ ├── Home/
│ │ ├── Model/
│ │ │ └── Photo.swift ← Codable model (Picsum API)
│ │ ├── View/
│ │ │ ├── HomeViewController.swift ← CollectionView, Combine bindings, pagination
│ │ │ └── PhotoCell.swift ← Lightweight cell with task lifecycle
│ │ └── ViewModel/
│ │ └── HomeViewModel.swift ← @Published state, fetch, prefetch
│ │
│ └── Detail/
│ └── DetailViewController.swift ← Full-res image viewer, zoom transition target
│
├── Router/
│ ├── Router.swift ← RouterDelegate, push/pop/present abstraction
│ ├── HomeViewControllerFactory.swift ← Factory for constructing HomeViewController
│ └── PhotoZoomTransition.swift ← PhotoZoomAnimator + UINavigationControllerDelegate
│
├── Networking/
│ └── PhotoService.swift ← Async/await API client
│
├── Utilities/
│ └── ImageLoader.swift ← Actor-based loader: async/await + cache + dedup + downsample
│
└── Resources/
└── Assets.xcassets
The single biggest performance win. Instead of downloading original 5000×3334 images and downscaling on-device, the app calculates the exact pixel size needed and requests a pre-sized thumbnail from the API:
// Photo.swift
func optimizedURLString(pixelWidth: Int) -> String {
let aspectRatio = CGFloat(height) / CGFloat(width)
let pixelHeight = Int(CGFloat(pixelWidth) * aspectRatio)
return "https://picsum.photos/id/\(id)/\(pixelWidth)/\(pixelHeight)"
}Full resolution: https://picsum.photos/id/10/5000/3334 → ~3 MB
Device-appropriate: https://picsum.photos/id/10/390/260 → ~10 KB
▲
10-50x smaller
ImageLoader (an actor) wraps an NSCache<NSURL, UIImage> that stores decoded, display-ready images. Cache hits return instantly — no network call, no decoding overhead. NSCache auto-evicts under memory pressure, so there is no need for manual purging.
When multiple cells or prefetch calls request the same URL simultaneously, only one network request is fired. Additional callers simply await the existing in-flight Task — no manual locks needed thanks to actor isolation:
if let existingTask = inFlightRequests[nsURL] {
return try await existingTask.value // Coalesce — share the same download
}
let task = Task<UIImage, Error> {
let (data, _) = try await session.data(from: url)
// ... downsample & cache ...
}
inFlightRequests[nsURL] = task // First caller — start downloadEven after server-side resizing, raw image data is decoded through CGImageSource thumbnailing. This decodes directly into the target buffer size — avoiding an intermediate full-resolution bitmap that would spike memory:
let downsampleOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension
]
let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary)The app implements UICollectionViewDataSourcePrefetching to warm the cache before cells scroll into view. When the scroll direction reverses, pending prefetches are cancelled to free bandwidth for visible cells.
prepareForReuse() cancels in-flight downloads via Swift Task cancellation and clears stale images. A representingIdentifier guard combined with Task.isCancelled prevents late-arriving images from being assigned to a recycled cell:
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
representingIdentifier = nil
loadTask?.cancel()
loadTask = nil
}New pages are appended with performBatchUpdates using precise index paths — preserving scroll position and avoiding the layout cost of a full reloadData. The view subscribes to the @Published photos array via Combine (see §8) and calculates the inserted index paths to feed into performBatchUpdates.
HomeViewModel publishes its entire state through @Published properties. HomeViewController subscribes once during viewDidLoad and reacts to every state change without manual callback wiring:
// HomeViewModel.swift
@Published public private(set) var photos: [Photo] = []
@Published public private(set) var isLoading: Bool = false// HomeViewController.swift
vm.$isLoading
.receive(on: DispatchQueue.main)
.sink { [weak self] loading in
loading ? self?.activityIndicator.startAnimating()
: self?.activityIndicator.stopAnimating()
}
.store(in: &cancellables)This replaces the previous callback closure approach (photosDidUpdate, initialPhotosLoaded) with a declarative reactive flow.
Tapping a cell navigates to DetailViewController via the Router. The cell's current thumbnail is passed as a previewImage so it is displayed instantly while the full-resolution image loads asynchronously in the background:
// DetailViewController.swift
private func loadImage() {
Task {
guard let url = URL(string: photo.downloadURL) else { return }
let image = try await loader.loadImage(from: url, targetSize: view.bounds.size, scale: UIScreen.main.scale)
guard !Task.isCancelled else { return }
UIView.transition(with: imageView, duration: 0.25, options: .transitionCrossDissolve) {
self.imageView.image = image
}
}
}The detail view conforms to PhotoTransitionDestinationProviding, exposing the UIImageView and the associated Photo to the transition animator.
PhotoZoomAnimator implements UIViewControllerAnimatedTransitioning and drives a shared-element animation between the tapped cell's image view and the full-screen detail image view:
Push (zoom in):
- Snapshot the source cell's
imageViewframe in window coordinates. - Calculate the destination
imageViewframe in window coordinates. - Create a floating
UIImageViewat the source frame, animate it to the destination frame with a spring curve (damping: 0.85), fade in the destination view. - Remove the floating view and unhide both real image views on completion.
Pop (zoom out): The process runs in reverse — the floating image starts at the detail frame and springs back to the grid cell's frame while the detail view fades out.
UIView.animate(
withDuration: 0.45,
delay: 0,
usingSpringWithDamping: 0.85,
initialSpringVelocity: 0.0,
options: [.curveEaseOut]
) {
transitionImageView.frame = endFrame
destinationViewController.view.alpha = 1.0
}A fallback path (fade in/out) activates automatically when either view controller does not conform to the required protocols, ensuring the transition never breaks for unrelated screens.
All navigation decisions are owned by Router, which conforms to RouterDelegate. This keeps UIViewController subclasses free of navigation knowledge and makes the routing logic independently testable:
enum Route {
case home
case detail(photo: Photo, previewImage: UIImage?)
}Router sets itself as the UINavigationController's delegate during init and installs PhotoNavigationControllerDelegate to supply the custom PhotoZoomAnimator for push and pop operations.
A dedicated URLSession replaces .shared with production-tuned parameters:
| Setting | Value | Rationale |
|---|---|---|
httpMaximumConnectionsPerHost |
12 | 2x the default — more parallel downloads |
requestCachePolicy |
.returnCacheDataElseLoad |
Serve cached responses instantly |
urlCache memory |
50 MB | Generous in-memory HTTP cache |
urlCache disk |
200 MB | Persistent cache across launches |
timeoutIntervalForRequest |
15 s | Fail fast on slow connections |
┌─────────────────────────────────────────────────────────────────────────────┐
│ SceneDelegate (DI Root) │
│ PhotoService → HomeViewModel → Router → HomeViewController │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────┐ @Published ┌──────────────────┐ async/ ┌──────────────┐
│ HomeView │◄──────────────│ HomeViewModel │ await │ PhotoService │
│ Controller │ (Combine) │ photos │─────────►│ (Picsum API) │
│ │ │ isLoading │◄─────────│ │
│ ┌──────────┐ │ └──────────────────┘ [Photo] └──────────────┘
│ │PhotoCell │──┼──tap──────────► Router.navigate(.detail)
│ │ │ │ configure() │
│ │ │◄─┼──loadImage() ▼
│ └──────────┘ │ ┌──────────────────────────────┐
└────────────────┘ │ DetailViewController │
│ previewImage (instant) │
│ loadImage() → full-res │
│ PhotoTransitionDestination │
└──────────────────────────────┘
▲
┌─────────────┴────────────┐
│ PhotoZoomAnimator │
│ push: cell → fullscreen │
│ pop: fullscreen → cell │
└───────────────────────────┘
┌──────────────┐
All image loads ────────────────────────────────────────►│ ImageLoader │
│ (actor) │
│ ┌─NSCache─┐ │
│ ┌URLCache─┐ │
│ ┌─Dedup──┐ │
│ ┌Downsmpl┐ │
└──────────────┘
| Requirement | Version |
|---|---|
| iOS | 16.0+ |
| Xcode | 26.0+ |
| Swift | 6.0+ |
| Dependencies | None |
# 1. Clone the repository
git clone https://github.com/<your-username>/OptimizedGridLoader.git
# 2. Open in Xcode
cd OptimizedGridLoader
open OptimizedGridLoader.xcodeproj
# 3. Build & Run (⌘R) on simulator or deviceThe app fetches photos from the Picsum Photos API — no API key required.
| Component | Mechanism |
|---|---|
ImageLoader |
actor — data-race safety for cache and in-flight request tracking |
loadImage(from:targetSize:scale:) |
async throws — callers await the result |
| Request deduplication | Task<UIImage, Error> — duplicate requests coalesce on .value |
| Cell image loading | Task<Void, Never> — spawned in configure(), cancelled in prepareForReuse() |
| Prefetch / cancel | Task cancellation — propagates through URLSession.data(from:) |
| Downsampling | nonisolated function — pure computation outside actor isolation |
| ViewModel state | @Published + Combine — photos and isLoading drive the UI reactively via sink |
| Navigation | Router + RouterDelegate — decouples screen transitions from view controllers |
| Zoom transition | UIViewControllerAnimatedTransitioning — custom spring animator with shared element |
If you take nothing else from this project, remember these principles:
- Download less data — request the size you actually need from the server.
- Decode smarter — use
CGImageSourceto decode directly into the target buffer, not viaUIImage(data:). - Cancel aggressively — every wasted download steals bandwidth from visible content.
- Combine over callbacks —
@Published+sinkscales better than manual closure wiring as state grows. - Delight with transitions — a well-crafted shared-element zoom tells users exactly where they came from and where they can go back.
This project is intended for educational and demonstration purposes.