A production-grade guide to building scroll-fast, revenue-optimised iOS product grids — from LazyVGrid fundamentals to ProMotion-ready rendering.
There is a number you should have memorised before you write a single line of grid layout code: 100 milliseconds. That is the threshold at which latency stops being invisible to the human nervous system and starts registering as friction. In a product grid — where a shopper is making split-second judgements about whether to tap or scroll past — friction is revenue leaving through the back door.
This is not speculation. A landmark study co-authored by Deloitte and Google, Milliseconds Make Millions (2020), quantified the relationship between mobile site speed and commercial outcomes across hundreds of brand sites. It found that even a 0.1-second improvement in mobile load performance correlated with an 8% increase in retail conversions. On a platform as performance-sensitive as iOS — where users have been trained by Apple's own apps to expect buttery 60Hz and, on newer hardware, 120Hz ProMotion scrolling — the stakes are even higher.
SwiftUI makes it easy to build product grids that look great. What it does not do automatically is make them perform great at scale. This guide covers the full architecture: the business case, the SwiftUI primitives, the image-loading strategy, view equatability, and how to verify everything with Instruments before you ship.
The Business Case for Performance-First Grid Architecture
Before diving into code, the numbers deserve a direct statement.
The chart above models the compounding effect of accumulated latency on add-to-cart conversions. At zero lag, a well-designed product grid can achieve conversion rates approaching 5–6%. Each additional 100ms of friction — caused by unoptimised image loading, excessive view redraws, or dropped frames during scrolling — erodes that figure systematically. By the time cumulative latency reaches 500ms, conversion has typically more than halved.
David Chan, e-commerce founder of Davilane and a practitioner with over eleven years building mobile-first retail experiences, has seen this pattern repeat across platforms and product categories:
For every 100ms delay in scroll or load time we see a measurable drop in add-to-cart conversions — in our cohort data it runs between 0.7 and 1.1 percentage points per 100ms, compounding as the session goes on. The first scroll frame is a trust signal. If it stutters, the shopper's subconscious has already decided this app does not deserve their attention. The cart is empty before they even see the product.
This is not a marginal UX concern. For a mid-size retailer processing ten thousand mobile sessions daily at a $45 average order value, moving from a 300ms grid to a 100ms grid — and recovering two percentage points of conversion — represents roughly $9,000 per day in recoverable revenue. The engineering investment in grid performance has one of the highest ROI ratios in all of mobile development.
The Right Primitive: LazyVGrid Over VGrid
SwiftUI ships with two conceptually distinct grid primitives: Grid (eager) and LazyVGrid / LazyHGrid (lazy). The naming is descriptive. Grid renders all of its children immediately when the parent view is initialised — fine for a settings screen with twelve rows, catastrophic for a catalogue with five hundred products.
LazyVGrid defers view creation until items are about to enter the visible viewport. Items that scroll out of view are destroyed and their memory reclaimed. According to Apple's LazyVGrid documentation, this on-demand rendering is the primary mechanism for maintaining performance across large datasets.
The GridItem configuration type drives the column behaviour. There are three column layouts worth understanding in a commercial context:
| Column Type | Declaration | Best Use Case | Limitation |
|---|---|---|---|
fixed |
GridItem(.fixed(160)) |
Strict brand grid with pixel-perfect cards | Ignores device width; can overflow or leave gaps |
flexible |
GridItem(.flexible(min:80, max:200)) |
Responsive two/three-column grid | Requires explicit column count in array |
adaptive |
GridItem(.adaptive(minimum: 140)) |
Dynamic column count based on width | Column count changes on rotation; test carefully |
For most e-commerce product grids, a two-column flexible layout on iPhone and a three-column flexible layout on iPad represents the highest-converting layout pattern. The cards are large enough for clear product imagery and price visibility, but the grid still communicates catalogue breadth.
struct ProductGridView: View {
let products: [Product]
private var columns: [GridItem] {
let isIpad = UIDevice.current.userInterfaceIdiom == .pad
let count = isIpad ? 3 : 2
return Array(repeating: GridItem(.flexible(minimum: 140), spacing: 16), count: count)
}
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(products) { product in
ProductCard(product: product)
}
}
.padding(.horizontal, 16)
}
}
}
One subtle architectural point: LazyVGrid requires a ScrollView ancestor to function correctly. Without it, the lazy behaviour collapses — the grid renders all rows eagerly regardless of the Lazy prefix.
Image Loading: The Biggest Performance Variable
In any product grid, images account for 70–90% of the visual weight of each cell and, if mishandled, for the majority of frame-drop events during scrolling. SwiftUI ships AsyncImage as a first-party solution. It is the right choice for simple cases, but it comes with a hard constraint that matters enormously at scale: AsyncImage does not persist its image cache across view destruction.
When a product card scrolls off screen, LazyVGrid destroys the view. When it scrolls back into view, AsyncImage re-initiates the network request. On a fast connection this produces a flash of loading placeholder on every scroll-back. On a slow connection it can stall a frame entirely.
You can configure URLCache globally to give AsyncImage more persistence:
// In your App init or scene setup
URLCache.shared.memoryCapacity = 50_000_000 // 50 MB memory
URLCache.shared.diskCapacity = 500_000_000 // 500 MB disk
This helps significantly for repeat sessions but does not eliminate the within-session flash. For high-volume catalogues, a dedicated image-caching library such as Kingfisher resolves this by maintaining an in-process memory cache keyed to the image URL:
import Kingfisher
struct ProductImageView: View {
let url: URL?
var body: some View {
KFImage(url)
.placeholder { ProgressView() }
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 180)
.clipped()
}
}
KFImage caches images in memory on first load and serves them from the cache on every subsequent scroll-back, eliminating the placeholder flash entirely. Kingfisher also supports image downsampling — rendering images at the display resolution rather than the full original resolution — which reduces memory pressure by up to 75% on image-heavy grids.
View Struct Equatability: Stop Redrawing What Hasn't Changed
SwiftUI's diffing engine is sophisticated but not telepathic. By default, whenever a parent view's state changes — say, the user adds a product to their wishlist and a heart icon updates — SwiftUI re-evaluates the body of every sibling ProductCard in the grid, even those with data that has not changed at all.
On a two-column grid of forty visible products, that is up to eighty body recomputations triggered by a single unrelated state mutation. At 60Hz, you have 16.7ms per frame. Forty redundant body calls at even 0.5ms each consume 20ms — you have already missed the frame deadline before any actual drawing has occurred.
The fix is EquatableView. By conforming your card view to Equatable and applying the .equatable() modifier, you allow SwiftUI to short-circuit the body call whenever the view's inputs are unchanged.
Majid Jabrayilov, Swift developer and creator of CardioBot, NapBot, and FastBot — and one of the most widely-read voices in the SwiftUI ecosystem via swiftwithmajid.com — has made this pattern central to his performance architecture:
EquatableView can boost performance when body computation is longer than your equality check. The pattern I use in NapBot's calendar grid — which renders hundreds of day cells simultaneously — is to extract the rendering view from the container view, implement Equatable on the render struct, and let SwiftUI skip the body entirely when the data hasn't changed. The equality check is microseconds. The body, with its layout passes and child view allocations, is not.
<div class="ltag-quote__mark ltag-quote__mark--end">
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M19.417 6.679C20.447 7.773 21 9 21 10.989C21 14.489 18.543 17.626 14.97 19.177L14.077 17.799C17.412 15.995 18.064 13.654 18.324 12.178C17.787 12.456 17.084 12.553 16.395 12.489C14.591 12.322 13.169 10.841 13.169 9C13.169 8.07174 13.5377 7.1815 14.1941 6.52513C14.8505 5.86875 15.7407 5.5 16.669 5.5C17.1823 5.50449 17.6897 5.61104 18.1614 5.81345C18.6332 6.01586 19.06 6.31008 19.417 6.679ZM9.417 6.679C10.447 7.773 11 9 11 10.989C11 14.489 8.543 17.626 4.97 19.177L4.077 17.799C7.412 15.995 8.064 13.654 8.324 12.178C7.787 12.456 7.084 12.553 6.395 12.489C4.591 12.322 3.169 10.841 3.169 9C3.169 8.07174 3.53775 7.1815 4.19413 6.52513C4.85051 5.86875 5.74074 5.5 6.669 5.5C7.18234 5.50449 7.68967 5.61104 8.16144 5.81344C8.63321 6.01585 9.06001 6.31008 9.417 6.679Z" />
</svg>
</div>
In practice, for a ProductCard struct, this looks like:
struct ProductCard: View, Equatable {
let product: Product
static func == (lhs: ProductCard, rhs: ProductCard) -> Bool {
lhs.product.id == rhs.product.id &&
lhs.product.price == rhs.product.price &&
lhs.product.isWishlisted == rhs.product.isWishlisted
}
var body: some View {
// ... card layout
}
}
// Usage with equatable modifier
LazyVGrid(columns: columns) {
ForEach(products) { product in
ProductCard(product: product)
.equatable()
}
}
This single change can eliminate the majority of unnecessary redraws in a grid with stable data — the most common case during normal browsing.
ProMotion and 120Hz: The New Performance Ceiling
ProMotion displays — available on iPhone 13 Pro and later, and all recent iPad Pros — render at up to 120Hz, halving the frame budget from 16.7ms to 8.3ms. This is not a future consideration; as of 2025, ProMotion devices represent a majority of premium iOS hardware in active use.
The frame rate relationship with engagement is not linear. Users may not consciously perceive the difference between 60Hz and 120Hz during static viewing, but the tactile quality of scrolling at 120Hz is palpable — and the data bears this out.
The grouped chart above illustrates the divergence in session metrics across frame rate tiers. The jump between 60Hz and 90Hz is meaningful; the jump from 90Hz to a full 120Hz ProMotion experience produces a further material reduction in bounce rate and increase in session depth.
For your grid to sustain 120Hz, every body evaluation, every layout pass, and every image decode triggered by a scroll event must complete within 8.3ms on a shared render thread. The optimisations discussed — LazyVGrid, equatable views, pre-cached images — are not optional refinements at this performance tier; they are prerequisites.
Profiling with Instruments: Verify, Don't Assume
Every performance claim in this article should be verified against your specific data and device targets using Xcode Instruments. There is no substitute for measurement. The SwiftUI Performance Profiling with Instruments guide on dev.to provides a practical walkthrough; below is the accelerated version for product grids specifically.
Step 1: Time Profiler. Profile on a physical ProMotion device (never the simulator). Filter the flame graph to your view module. Any body call exceeding 2ms in a hot scroll path is a red flag.
Step 2: SwiftUI instrument (Xcode 15+). The dedicated SwiftUI instrument visualises view update frequency and highlights "unnecessary" updates — these are the cells your equatability implementation has not yet caught.
Step 3: Memory Allocations. Scroll rapidly through your full catalogue catalogue. Watch the allocation counter. If it climbs indefinitely, you have a retain cycle in your cell views or a cache that is not evicting. Kingfisher's default LRU eviction policy handles the image side automatically; check your @StateObject lifecycle for the rest.
For scroll testing with XCTest, this dev.to walkthrough on UI Performance Testing with XCTest demonstrates how to establish scroll performance baselines and integrate them into CI, so regressions surface before they reach production.
Architecture Summary: The Production Checklist
| Concern | Naive Default | Production Pattern |
|---|---|---|
| Grid primitive |
VStack + ForEach
|
LazyVGrid inside ScrollView
|
| Column layout | Hardcoded fixed
|
flexible with device-aware count |
| Image loading |
AsyncImage (no persistent cache) |
KFImage (Kingfisher) or URLCache-configured AsyncImage
|
| Image resolution | Full-size from CDN | Downsampled to display size via Kingfisher processor |
| View redraws | Default SwiftUI diffing |
Equatable conformance + .equatable() modifier |
| State granularity | Single @Published array |
Per-item @Observable to isolate invalidation scope |
| Frame target | 60Hz assumed | 8.3ms budget for ProMotion 120Hz |
| Performance verification | Visual inspection | Instruments: Time Profiler + SwiftUI instrument + Memory Allocations |
| Scroll regression testing | Manual | XCTest scroll performance baselines in CI |
Going Deeper
The following resources extend each of the major topics covered in this guide:
SwiftUI Performance Optimisation — Smooth UIs, Less Recomputing on dev.to — a comprehensive catalogue of anti-patterns that cause unnecessary redraws, covering
.id()misuse, environment propagation overhead, and GPU pass optimisation.Using the Kingfisher Library in iOS Development on dev.to — hands-on integration guide with practical SwiftUI cell examples, directly applicable to product card implementations.
Discovering UI Performance Testing with XCTest — Scrolling Performance on dev.to — automation-focused guide to establishing and defending scroll FPS baselines in your release pipeline.
For the underlying rendering model, Apple's WWDC 2021 session "Demystify SwiftUI" is the canonical reference for understanding view identity, lifetime, and the dependency graph that drives body re-evaluation — essential reading for anyone optimising a grid at the architecture level.
The Akamai State of Online Retail Performance (2017, updated annually) provides the industry benchmarking data underpinning the latency-to-conversion models discussed in the business case section, with retail-specific segmentation not available in the Deloitte/Google study.
Conclusion
High-conversion product grids in SwiftUI are not an accident. They are the result of four deliberate architectural choices made before the first LazyVGrid is rendered: the right lazy primitive, a persistent image cache, equatable view structs to suppress redundant redraws, and a profiling discipline that validates performance claims against real hardware.
The business stakes are real. David Chan's cohort data and the Deloitte/Google research converge on the same message: each 100ms of friction is a measurable deduction from your conversion rate and your revenue. On ProMotion hardware, where users have been calibrated to 120Hz tactile fidelity, the margin for unoptimised code is measured in single-digit milliseconds.
Build the grid as if every frame costs money. Because statistically, the ones you drop do.
Apple Developer documentation references: LazyVGrid · AsyncImage · WWDC 2021: Demystify SwiftUI


Top comments (0)