Back to Articles
UIKit Performance Guide: 60-240 FPS Native iOS UI

UIKit Performance Guide: 60-240 FPS Native iOS UI

Comprehensive practices for building 60-240 FPS UI with Native iOS UIKit


Table of Contents

  1. Frame Budget & Rendering Pipeline
  2. UITableView Optimization
  3. UICollectionView Optimization
  4. Prefetching & Data Loading
  5. Cell Optimization
  6. Auto Layout Performance
  7. Core Animation & CALayer
  8. Image Loading & Caching
  9. Offscreen Rendering
  10. Animations
  11. Threading & GCD
  12. Memory Management
  13. Profiling & Debugging
  14. High Refresh Rate (120Hz+) Specifics
  15. Core Animation Pipeline & Hitches
  16. Auto Layout Cost & Alternative Engines
  17. GPU vs CPU Rendering
  18. Off-Main-Thread Work & Texture
  19. Advanced 240fps Techniques
  20. Metal Integration
  21. Quick Reference

1. Frame Budget & Rendering Pipeline

Frame Timing

DisplayRefresh RateFrame Budget
Standard60 Hz16.67ms
ProMotion120 Hz8.33ms
Always-On1-120 HzVariable
External/Future*240 Hz4.17ms

*Note: No current iOS devices support 240Hz natively. This budget is included for external high-refresh displays (via USB-C/HDMI) and future device compatibility.

iOS Rendering Pipeline

┌─────────────┐   ┌─────────────┐   ┌─────────────┐   ┌─────────────┐
│   LAYOUT    │ → │   DISPLAY   │ → │   PREPARE   │ → │   COMMIT    │
│ (Constraints)│   │ (drawRect)  │   │ (Decode)    │   │ (GPU)       │
└─────────────┘   └─────────────┘   └─────────────┘   └─────────────┘
       ↓                 ↓                 ↓                 ↓
   Measure &         Core Graphics     Image decode     CALayer tree
   Position          CPU drawing       decompression    sent to GPU

Run Loop & Rendering

// Core Animation commits at end of run loop
// All layout/display changes batched together

// Force immediate layout if needed
view.setNeedsLayout()
view.layoutIfNeeded()  // Forces synchronous layout

// Prefer asynchronous layout
view.setNeedsLayout()  // Batched with next run loop
class FrameSyncController {
    private var displayLink: CADisplayLink?

    func start() {
        displayLink = CADisplayLink(target: self, selector: #selector(handleFrame))
        displayLink?.preferredFrameRateRange = CAFrameRateRange(
            minimum: 60,
            maximum: 120,
            preferred: 120
        )
        displayLink?.add(to: .main, forMode: .common)
    }

    @objc private func handleFrame(_ displayLink: CADisplayLink) {
        let frameDuration = displayLink.targetTimestamp - displayLink.timestamp
        // Update animations synchronized with display
    }

    func stop() {
        displayLink?.invalidate()
        displayLink = nil
    }
}

2. UITableView Optimization

Essential Setup

class OptimizedTableViewController: UITableViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // ✅ Register cells for reuse
        tableView.register(CustomCell.self, forCellReuseIdentifier: "cell")

        // ✅ Estimated heights for faster initial layout
        tableView.estimatedRowHeight = 80
        tableView.rowHeight = UITableView.automaticDimension

        // ✅ Prefetching
        tableView.prefetchDataSource = self

        // ✅ Disable unnecessary features
        tableView.separatorStyle = .none  // If custom separators
    }
}

Cell Reuse (Critical)

override func tableView(_ tableView: UITableView,
                        cellForRowAt indexPath: IndexPath) -> UITableViewCell {

    // ✅ Always dequeue - NEVER create new cells
    let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CustomCell

    let item = items[indexPath.row]

    // ✅ Configure with data
    cell.configure(with: item)

    return cell
}

Diffable Data Source (iOS 13+)

class ModernTableViewController: UIViewController {

    enum Section { case main }

    private var dataSource: UITableViewDiffableDataSource<Section, Item>!
    private var tableView: UITableView!

    func setupDataSource() {
        dataSource = UITableViewDiffableDataSource(tableView: tableView) {
            tableView, indexPath, item in

            let cell = tableView.dequeueReusableCell(
                withIdentifier: "cell",
                for: indexPath
            ) as! CustomCell
            cell.configure(with: item)
            return cell
        }
    }

    func updateData(_ items: [Item], animated: Bool = true) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.main])
        snapshot.appendItems(items)

        // ✅ iOS 15+: Apply without animation uses diff, not reloadData
        dataSource.apply(snapshot, animatingDifferences: animated)
    }

    // ✅ iOS 15+: Reconfigure without full cell reload
    func updateItem(_ item: Item) {
        var snapshot = dataSource.snapshot()
        snapshot.reconfigureItems([item])  // Only updates content, not layout
        dataSource.apply(snapshot)
    }
}

Height Calculation

// ❌ BAD: Calculating height in heightForRowAt
override func tableView(_ tableView: UITableView,
                        heightForRowAt indexPath: IndexPath) -> CGFloat {
    let item = items[indexPath.row]
    return calculateHeight(for: item)  // Called frequently during scroll!
}

// ✅ GOOD: Cache heights
private var heightCache: [IndexPath: CGFloat] = [:]

override func tableView(_ tableView: UITableView,
                        heightForRowAt indexPath: IndexPath) -> CGFloat {
    if let cached = heightCache[indexPath] {
        return cached
    }

    let height = calculateHeight(for: items[indexPath.row])
    heightCache[indexPath] = height
    return height
}

// Clear cache when data changes
func updateData(_ newItems: [Item]) {
    heightCache.removeAll()
    items = newItems
    tableView.reloadData()
}

Self-Sizing Cells

// In cell class
override func systemLayoutSizeFitting(
    _ targetSize: CGSize,
    withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority,
    verticalFittingPriority: UILayoutPriority
) -> CGSize {

    // ✅ Cache calculated size
    let size = super.systemLayoutSizeFitting(
        targetSize,
        withHorizontalFittingPriority: horizontalFittingPriority,
        verticalFittingPriority: verticalFittingPriority
    )

    return size
}

3. UICollectionView Optimization

Modern Compositional Layout

func createLayout() -> UICollectionViewLayout {
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100)
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)

    let groupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .estimated(100)
    )
    let group = NSCollectionLayoutGroup.horizontal(
        layoutSize: groupSize,
        subitems: [item]
    )

    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = 8
    section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)

    return UICollectionViewCompositionalLayout(section: section)
}

Cell Registration (iOS 14+)

// ✅ Modern cell registration
let cellRegistration = UICollectionView.CellRegistration<CustomCell, Item> { cell, indexPath, item in
    cell.configure(with: item)
}

// In data source
dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) {
    collectionView, indexPath, item in

    return collectionView.dequeueConfiguredReusableCell(
        using: cellRegistration,
        for: indexPath,
        item: item
    )
}

Orthogonal Scrolling Sections

// Horizontal section within vertical collection view
section.orthogonalScrollingBehavior = .continuous

// With paging
section.orthogonalScrollingBehavior = .groupPaging

// With visible items changed callback
section.visibleItemsInvalidationHandler = { items, offset, environment in
    // Handle visible items for parallax effects, etc.
}

4. Prefetching & Data Loading

UITableViewDataSourcePrefetching

extension TableViewController: UITableViewDataSourcePrefetching {

    func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
        // ✅ Start async loading for upcoming cells
        for indexPath in indexPaths {
            let item = items[indexPath.row]

            // Start image prefetch
            if let url = item.imageURL {
                imageLoader.prefetch(url: url)
            }

            // Start data prefetch
            if !item.detailsLoaded {
                dataLoader.prefetchDetails(for: item.id)
            }
        }
    }

    func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
        // ✅ Cancel prefetch for cells no longer needed
        for indexPath in indexPaths {
            let item = items[indexPath.row]

            if let url = item.imageURL {
                imageLoader.cancelPrefetch(url: url)
            }
        }
    }
}

UICollectionViewDataSourcePrefetching

extension CollectionViewController: UICollectionViewDataSourcePrefetching {

    func collectionView(_ collectionView: UICollectionView,
                        prefetchItemsAt indexPaths: [IndexPath]) {

        // ✅ Batch prefetch requests
        let urls = indexPaths.compactMap { items[$0.item].imageURL }
        imageLoader.prefetchBatch(urls: urls)
    }

    func collectionView(_ collectionView: UICollectionView,
                        cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {

        let urls = indexPaths.compactMap { items[$0.item].imageURL }
        imageLoader.cancelBatch(urls: urls)
    }
}

iOS 15+ adds automatic cell prefetching: build with the iOS 15 SDK and cells are prepared during idle time between frames, granting up to 2x preparation time without hitches. This is separate from the data-source prefetching above, which you still implement for network and image work.

Adaptive Prefetching

class SmartPrefetchController {
    private var operationQueue: OperationQueue = {
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 4  // Limit concurrent fetches
        queue.qualityOfService = .userInitiated
        return queue
    }()

    private var prefetchOperations: [IndexPath: Operation] = [:]

    func prefetch(at indexPaths: [IndexPath], using loader: DataLoader) {
        for indexPath in indexPaths {
            guard prefetchOperations[indexPath] == nil else { continue }

            let operation = loader.loadOperation(for: indexPath)
            prefetchOperations[indexPath] = operation
            operationQueue.addOperation(operation)
        }
    }

    func cancelPrefetch(at indexPaths: [IndexPath]) {
        for indexPath in indexPaths {
            prefetchOperations[indexPath]?.cancel()
            prefetchOperations[indexPath] = nil
        }
    }
}

5. Cell Optimization

Lightweight Cell Setup

class OptimizedCell: UITableViewCell {

    // ✅ Lazy subview creation
    private lazy var customImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        imageView.translatesAutoresizingMaskIntoConstraints = false
        return imageView
    }()

    private lazy var titleLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 16, weight: .medium)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    // ✅ One-time setup
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupViews()
        setupConstraints()
    }

    private func setupViews() {
        contentView.addSubview(customImageView)
        contentView.addSubview(titleLabel)
    }

    private func setupConstraints() {
        NSLayoutConstraint.activate([
            customImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
            customImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            customImageView.widthAnchor.constraint(equalToConstant: 48),
            customImageView.heightAnchor.constraint(equalToConstant: 48),

            titleLabel.leadingAnchor.constraint(equalTo: customImageView.trailingAnchor, constant: 12),
            titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
            titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
        ])
    }

    // ✅ Fast configuration
    func configure(with item: Item) {
        titleLabel.text = item.title

        // Async image loading
        customImageView.image = nil  // Clear previous
        if let url = item.imageURL {
            ImageLoader.shared.load(url: url) { [weak self] image in
                self?.customImageView.image = image
            }
        }
    }

    // ✅ Cancel ongoing work on reuse
    override func prepareForReuse() {
        super.prepareForReuse()
        customImageView.image = nil
        ImageLoader.shared.cancel(for: customImageView)
    }
}

Avoid in cellForRow

// ❌ BAD: Heavy operations in cellForRow
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)

    // ❌ Synchronous image loading
    if let data = try? Data(contentsOf: imageURL) {
        cell.imageView?.image = UIImage(data: data)
    }

    // ❌ Heavy text calculation
    let attributedText = createComplexAttributedString(item.description)
    cell.textLabel?.attributedText = attributedText

    // ❌ Date formatting
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    cell.detailTextLabel?.text = formatter.string(from: item.date)

    return cell
}

// ✅ GOOD: Pre-calculate and cache
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CustomCell

    let viewModel = viewModels[indexPath.row]  // Pre-calculated
    cell.configure(with: viewModel)

    return cell
}

// Pre-calculate view models
struct ItemViewModel {
    let title: String
    let formattedDate: String  // Pre-formatted
    let attributedDescription: NSAttributedString  // Pre-calculated
    let imageURL: URL?
}

Specifically, never do JSON parsing, data transformation, image decoding or resizing, network requests, complex constraint mutations, or heavy object allocation inside cellForRowAt / cellForItemAt. Each of these runs on the main thread directly in the scroll path.


6. Auto Layout Performance

Constraint Priorities

// ✅ Use priorities to reduce constraint conflicts
let heightConstraint = view.heightAnchor.constraint(equalToConstant: 100)
heightConstraint.priority = .defaultHigh  // Allows flexibility
heightConstraint.isActive = true

// ✅ Required constraints should be minimal
let requiredConstraint = view.widthAnchor.constraint(greaterThanOrEqualToConstant: 44)
requiredConstraint.priority = .required
requiredConstraint.isActive = true

Batch Constraint Updates

// ❌ BAD: Individual constraint updates
constraint1.constant = 10
constraint2.constant = 20
constraint3.constant = 30
// Triggers 3 layout passes!

// ✅ GOOD: Batch updates
NSLayoutConstraint.deactivate([constraint1, constraint2, constraint3])
constraint1.constant = 10
constraint2.constant = 20
constraint3.constant = 30
NSLayoutConstraint.activate([constraint1, constraint2, constraint3])
// Single layout pass

Intrinsic Content Size

class OptimizedLabel: UILabel {

    // ✅ Cache intrinsic content size
    private var cachedIntrinsicSize: CGSize?

    override var intrinsicContentSize: CGSize {
        if let cached = cachedIntrinsicSize {
            return cached
        }
        let size = super.intrinsicContentSize
        cachedIntrinsicSize = size
        return size
    }

    override var text: String? {
        didSet {
            cachedIntrinsicSize = nil  // Invalidate cache
        }
    }

    override var font: UIFont! {
        didSet {
            cachedIntrinsicSize = nil
        }
    }
}

Stack Views vs Manual Layout

// Stack views: Convenient but add overhead
// For performance-critical cells, consider manual layout

// ✅ Manual layout for maximum performance
class ManualLayoutCell: UITableViewCell {

    override func layoutSubviews() {
        super.layoutSubviews()

        let padding: CGFloat = 16
        let imageSize: CGFloat = 48

        imageView?.frame = CGRect(
            x: padding,
            y: (contentView.bounds.height - imageSize) / 2,
            width: imageSize,
            height: imageSize
        )

        let textX = padding + imageSize + 12
        textLabel?.frame = CGRect(
            x: textX,
            y: padding,
            width: contentView.bounds.width - textX - padding,
            height: contentView.bounds.height - 2 * padding
        )
    }
}

7. Core Animation & CALayer

GPU-Accelerated Properties

// ✅ GPU-accelerated (fast)
view.layer.transform = CATransform3DMakeScale(1.5, 1.5, 1.0)
view.layer.opacity = 0.5
view.layer.position = CGPoint(x: 100, y: 100)

// ❌ Triggers layout (slow)
view.frame.size = CGSize(width: 200, height: 200)
view.bounds = CGRect(x: 0, y: 0, width: 200, height: 200)

Shadow Optimization

// ❌ BAD: Dynamic shadow calculation
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOffset = CGSize(width: 0, height: 2)
view.layer.shadowOpacity = 0.3
view.layer.shadowRadius = 4
// Shadow shape calculated every frame!

// ✅ GOOD: Pre-defined shadow path
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOffset = CGSize(width: 0, height: 2)
view.layer.shadowOpacity = 0.3
view.layer.shadowRadius = 4
view.layer.shadowPath = UIBezierPath(
    roundedRect: view.bounds,
    cornerRadius: view.layer.cornerRadius
).cgPath  // Cached path

Rasterization

// ✅ Rasterize complex layer hierarchies
complexView.layer.shouldRasterize = true
complexView.layer.rasterizationScale = UIScreen.main.scale

// Use for:
// - Static complex views
// - Views with many sublayers
// - Views with effects (shadows, gradients)

// Don't use for:
// - Frequently changing content
// - Animated views
// - Views larger than screen

Corner Radius Performance

// ✅ cornerRadius is GPU-accelerated
view.layer.cornerRadius = 8

// ✅ Use cornerCurve for continuous corners (iOS 13+)
view.layer.cornerCurve = .continuous

// ❌ Avoid masksToBounds with shadows
view.layer.cornerRadius = 8
view.layer.masksToBounds = true  // Triggers offscreen render
view.layer.shadowOpacity = 0.5  // Won't be visible!

// ✅ Use separate shadow layer
let shadowLayer = CALayer()
shadowLayer.shadowPath = ...
shadowLayer.shadowOpacity = 0.5
view.layer.insertSublayer(shadowLayer, at: 0)

view.layer.cornerRadius = 8
view.layer.masksToBounds = true  // Only clips content

Layer Opacity

// ✅ Set opaque when possible
view.layer.isOpaque = true  // No alpha blending needed
view.backgroundColor = .white  // Not .clear!

// ✅ Reduce blending
imageView.layer.isOpaque = true
imageView.backgroundColor = .white  // Match parent

8. Image Loading & Caching

Efficient Image Loader

class ImageLoader {
    static let shared = ImageLoader()

    private let cache = NSCache<NSURL, UIImage>()
    private let session: URLSession
    private var tasks: [URL: URLSessionDataTask] = [:]
    private let queue = DispatchQueue(label: "imageLoader", attributes: .concurrent)

    init() {
        cache.countLimit = 100
        cache.totalCostLimit = 50 * 1024 * 1024  // 50 MB

        let config = URLSessionConfiguration.default
        config.urlCache = URLCache(
            memoryCapacity: 20 * 1024 * 1024,
            diskCapacity: 100 * 1024 * 1024
        )
        session = URLSession(configuration: config)
    }

    func load(url: URL, completion: @escaping (UIImage?) -> Void) {
        // Check memory cache
        if let cached = cache.object(forKey: url as NSURL) {
            DispatchQueue.main.async { completion(cached) }
            return
        }

        // Fetch from network
        let task = session.dataTask(with: url) { [weak self] data, _, error in
            guard let data = data, error == nil else {
                DispatchQueue.main.async { completion(nil) }
                return
            }

            // Decode on background thread
            self?.queue.async {
                guard let image = UIImage(data: data) else {
                    DispatchQueue.main.async { completion(nil) }
                    return
                }

                // Cache
                self?.cache.setObject(image, forKey: url as NSURL, cost: data.count)

                DispatchQueue.main.async { completion(image) }
            }
        }

        queue.async(flags: .barrier) {
            self.tasks[url] = task
        }

        task.resume()
    }

    func cancel(url: URL) {
        queue.async(flags: .barrier) {
            self.tasks[url]?.cancel()
            self.tasks[url] = nil
        }
    }

    func prefetch(url: URL) {
        guard cache.object(forKey: url as NSURL) == nil else { return }

        let task = session.dataTask(with: url) { [weak self] data, _, _ in
            guard let data = data, let image = UIImage(data: data) else { return }
            self?.cache.setObject(image, forKey: url as NSURL, cost: data.count)
        }

        queue.async(flags: .barrier) {
            self.tasks[url] = task
        }

        task.resume()
    }
}

Image Downsampling

extension UIImage {

    // ✅ Downsample large images to target size
    static func downsample(url: URL, to pointSize: CGSize, scale: CGFloat = UIScreen.main.scale) -> UIImage? {

        let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else {
            return nil
        }

        let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
        let downsampleOptions = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceShouldCacheImmediately: true,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
        ] as CFDictionary

        guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else {
            return nil
        }

        return UIImage(cgImage: downsampledImage)
    }
}

// Usage
DispatchQueue.global(qos: .userInitiated).async {
    let image = UIImage.downsample(url: imageURL, to: CGSize(width: 100, height: 100))
    DispatchQueue.main.async {
        imageView.image = image
    }
}

Background Decoding (iOS 15+)

// ✅ prepareForDisplay decodes off the main thread
let fullImage = UIImage(contentsOfFile: path)!
fullImage.prepareForDisplay { prepared in
    DispatchQueue.main.async { imageView.image = prepared }
}

// ✅ Or prepare a thumbnail at the display size
fullImage.prepareThumbnail(of: targetSize) { thumbnail in
    DispatchQueue.main.async { imageView.image = thumbnail }
}

Prepared images hold raw, decompressed pixel data — far larger than the compressed original. Cache them sparingly to avoid memory warnings.

SDWebImage / Kingfisher Integration

// SDWebImage
imageView.sd_setImage(
    with: url,
    placeholderImage: placeholder,
    options: [.scaleDownLargeImages, .avoidAutoSetImage],
    context: [.imageThumbnailPixelSize: CGSize(width: 200, height: 200)],
    progress: nil
) { [weak self] image, error, cacheType, url in
    self?.imageView.image = image
}

// Kingfisher
imageView.kf.setImage(
    with: url,
    placeholder: placeholder,
    options: [
        .processor(DownsamplingImageProcessor(size: CGSize(width: 200, height: 200))),
        .scaleFactor(UIScreen.main.scale),
        .cacheOriginalImage
    ]
)

9. Offscreen Rendering

What Triggers Offscreen Rendering

// ❌ Triggers offscreen rendering

// 1. Masks
view.layer.mask = maskLayer

// 2. masksToBounds with corner radius (on certain views)
view.layer.cornerRadius = 8
view.layer.masksToBounds = true

// 3. Shadow without shadowPath
view.layer.shadowOpacity = 0.5
// No shadowPath set

// 4. Group opacity
view.layer.allowsGroupOpacity = true
view.alpha = 0.5

// 5. Rasterization (creates offscreen buffer)
view.layer.shouldRasterize = true

Detecting Offscreen Rendering

// Debug: Color Offscreen-Rendered Yellow in Simulator
// Or use Instruments → Core Animation

// Check if view triggers offscreen render
func checkOffscreenRendering(_ view: UIView) {
    let layer = view.layer

    if layer.mask != nil {
        print("⚠️ Mask triggers offscreen render")
    }

    if layer.shadowOpacity > 0 && layer.shadowPath == nil {
        print("⚠️ Shadow without path triggers offscreen render")
    }

    if layer.cornerRadius > 0 && layer.masksToBounds && !layer.contents.isNil {
        print("⚠️ Clipping with content may trigger offscreen render")
    }
}

Avoiding Offscreen Rendering

// ✅ Pre-render shadows using shadowPath
let path = UIBezierPath(roundedRect: view.bounds, cornerRadius: 8)
view.layer.shadowPath = path.cgPath

// ✅ Use pre-rendered images for complex shapes
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { context in
    // Draw complex shape once
}
imageView.image = image

// ✅ Separate shadow and clipping layers
class CardView: UIView {
    private let shadowLayer = CALayer()
    private let contentLayer = CALayer()

    override init(frame: CGRect) {
        super.init(frame: frame)

        // Shadow layer (no clipping)
        shadowLayer.shadowColor = UIColor.black.cgColor
        shadowLayer.shadowOffset = CGSize(width: 0, height: 2)
        shadowLayer.shadowRadius = 4
        shadowLayer.shadowOpacity = 0.2
        layer.insertSublayer(shadowLayer, at: 0)

        // Content layer (with clipping)
        contentLayer.masksToBounds = true
        contentLayer.cornerRadius = 8
        layer.addSublayer(contentLayer)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        shadowLayer.frame = bounds
        shadowLayer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 8).cgPath
        contentLayer.frame = bounds
    }
}

10. Animations

UIView Animations

// ✅ Spring animations for natural feel
UIView.animate(
    withDuration: 0.3,
    delay: 0,
    usingSpringWithDamping: 0.7,
    initialSpringVelocity: 0.5,
    options: [.allowUserInteraction],
    animations: {
        view.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
    }
)

// ✅ Use .allowUserInteraction for responsive UI
UIView.animate(
    withDuration: 0.3,
    delay: 0,
    options: [.allowUserInteraction, .beginFromCurrentState],
    animations: {
        view.alpha = 1.0
    }
)

Core Animation

// ✅ CABasicAnimation for performance
let animation = CABasicAnimation(keyPath: "transform.scale")
animation.fromValue = 1.0
animation.toValue = 1.2
animation.duration = 0.3
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
view.layer.add(animation, forKey: "scale")

// ✅ CASpringAnimation for physics-based
let spring = CASpringAnimation(keyPath: "transform.scale")
spring.fromValue = 1.0
spring.toValue = 1.2
spring.damping = 10
spring.stiffness = 100
spring.mass = 1
spring.duration = spring.settlingDuration
view.layer.add(spring, forKey: "scale")

UIViewPropertyAnimator (iOS 10+)

// ✅ Interruptible, reversible animations
let animator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 0.7) {
    view.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
}

animator.addCompletion { position in
    if position == .end {
        // Animation completed
    }
}

animator.startAnimation()

// Pause, reverse, scrub
animator.pauseAnimation()
animator.isReversed = true
animator.fractionComplete = 0.5
animator.continueAnimation(withTimingParameters: nil, durationFactor: 1)

Animating GPU Properties Only

// ✅ GPU-accelerated properties (fast)
UIView.animate(withDuration: 0.3) {
    view.transform = CGAffineTransform(translationX: 100, y: 0)
    view.alpha = 0.5
}

// ❌ Layout-triggering properties (slower)
UIView.animate(withDuration: 0.3) {
    view.frame.origin.x += 100  // Triggers layout
    view.bounds.size = newSize   // Triggers layout
}

11. Threading & GCD

Main Thread Safety

// ✅ Always update UI on main thread
DispatchQueue.global(qos: .userInitiated).async {
    let result = heavyComputation()

    DispatchQueue.main.async {
        self.updateUI(with: result)
    }
}

// ✅ Check if already on main
func updateUI(with data: Data) {
    if Thread.isMainThread {
        performUpdate(data)
    } else {
        DispatchQueue.main.async {
            self.performUpdate(data)
        }
    }
}

Quality of Service

// QoS levels (highest to lowest priority)
DispatchQueue.global(qos: .userInteractive)  // UI animations
DispatchQueue.global(qos: .userInitiated)    // User-triggered tasks
DispatchQueue.global(qos: .default)          // Normal
DispatchQueue.global(qos: .utility)          // Long-running tasks
DispatchQueue.global(qos: .background)       // Prefetching, backup

// ✅ Use appropriate QoS
func loadImage() {
    DispatchQueue.global(qos: .userInitiated).async {
        let image = self.processImage()
        DispatchQueue.main.async {
            self.imageView.image = image
        }
    }
}

Operation Queues for Complex Tasks

class DataLoader {
    private let operationQueue: OperationQueue = {
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 4
        queue.qualityOfService = .userInitiated
        return queue
    }()

    func loadItems(_ items: [Item], completion: @escaping ([Result]) -> Void) {
        let operations = items.map { item in
            LoadOperation(item: item)
        }

        let completionOperation = BlockOperation {
            let results = operations.map { $0.result }
            DispatchQueue.main.async {
                completion(results)
            }
        }

        operations.forEach { completionOperation.addDependency($0) }

        operationQueue.addOperations(operations, waitUntilFinished: false)
        operationQueue.addOperation(completionOperation)
    }
}

12. Memory Management

Weak References in Closures

// ✅ Avoid retain cycles
imageLoader.load(url: url) { [weak self] image in
    guard let self = self else { return }
    self.imageView.image = image
}

// ✅ With multiple captures
networkService.fetch { [weak self, weak delegate] result in
    self?.process(result)
    delegate?.didComplete()
}

Cell Reuse Cleanup

class CustomCell: UITableViewCell {
    private var imageTask: URLSessionTask?

    func configure(with item: Item) {
        // Cancel previous task
        imageTask?.cancel()

        imageTask = ImageLoader.shared.load(url: item.imageURL) { [weak self] image in
            self?.imageView.image = image
        }
    }

    override func prepareForReuse() {
        super.prepareForReuse()

        // ✅ Clean up
        imageTask?.cancel()
        imageTask = nil
        imageView.image = nil
    }
}

Autoreleasepool for Loops

// ✅ Reduce memory spikes in loops
func processLargeDataset(_ items: [Data]) -> [UIImage] {
    var images: [UIImage] = []

    for item in items {
        autoreleasepool {
            if let image = UIImage(data: item) {
                images.append(image)
            }
        }
    }

    return images
}

13. Profiling & Debugging

Instruments Templates

TemplateUse Case
Time ProfilerCPU usage, method timing
Core AnimationFPS, GPU usage
AllocationsMemory usage
LeaksMemory leaks
System TraceSystem-wide analysis

Core Animation Debugging

// Simulator Debug Options:
// - Color Blended Layers (red = blending)
// - Color Offscreen-Rendered Yellow
// - Color Hits Green and Misses Red (rasterization cache)
// - Flash Updated Regions

FPS Counter

class FPSCounter {
    private var displayLink: CADisplayLink?
    private var lastTimestamp: CFTimeInterval = 0
    private var frameCount: Int = 0

    var fpsLabel: UILabel?

    func start() {
        displayLink = CADisplayLink(target: self, selector: #selector(handleFrame))
        displayLink?.add(to: .main, forMode: .common)
    }

    @objc private func handleFrame(_ link: CADisplayLink) {
        if lastTimestamp == 0 {
            lastTimestamp = link.timestamp
            return
        }

        frameCount += 1
        let elapsed = link.timestamp - lastTimestamp

        if elapsed >= 1.0 {
            let fps = Double(frameCount) / elapsed
            fpsLabel?.text = String(format: "%.1f FPS", fps)

            frameCount = 0
            lastTimestamp = link.timestamp
        }
    }

    func stop() {
        displayLink?.invalidate()
        displayLink = nil
    }
}

14. High Refresh Rate (120Hz+) Specifics

Hardware reality check: No current iOS device runs above 120Hz. ProMotion (120Hz) ships on iPad Pro (since 2017) and iPhone 13 Pro and later Pro/Pro Max. A 240Hz panel remains hypothetical. Your real high-refresh target is 120fps (8.33ms/frame); at a theoretical 240Hz you’d have only ~2-3ms of usable work per frame, which makes every technique in sections 14-20 effectively mandatory.

Enabling 120Hz on iPhone (Required Opt-In)

iPhone apps are capped at 60Hz by default. Opt into ProMotion by adding to Info.plist:

<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>

This is not needed on iPad Pro, where ProMotion is active without opt-in.

Per-Animation CAFrameRateRange (iOS 15+)

CAFrameRateRange replaces the deprecated preferredFramesPerSecond and can be set per display link and per animation:

let anim = CABasicAnimation(keyPath: "position")
anim.preferredFrameRateRange = CAFrameRateRange(
    minimum: 80,
    maximum: Float(UIScreen.main.maximumFramesPerSecond),
    preferred: Float(UIScreen.main.maximumFramesPerSecond)
)

ProMotion infers desired frame rates from content. A subtle fade or small movement may be served at only 30Hz. Force a higher rate for the duration of an animation with an otherwise-empty display link:

class FrameRateRequest {
    private var displayLink: CADisplayLink?

    func start(preferredRate: Float, duration: TimeInterval) {
        displayLink = CADisplayLink(target: self, selector: #selector(tick))
        displayLink?.preferredFrameRateRange = CAFrameRateRange(
            minimum: 30,
            maximum: Float(UIScreen.main.maximumFramesPerSecond),
            preferred: preferredRate
        )
        displayLink?.add(to: .main, forMode: .common)
        DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in
            self?.displayLink?.invalidate()
            self?.displayLink = nil
        }
    }

    @objc private func tick() { }
}

Caveats

  • Low Power Mode caps the display at 60Hz regardless of opt-in or frame-rate requests.
  • A CADisplayLink’s duration reflects one frame’s duration, not the callback interval.
  • Some devices (iPhone 15 Pro) have been observed capping at 90Hz — a known bug.

15. Core Animation Pipeline & Hitches

The Five-Phase Pipeline

Beyond the four in-process phases (Layout → Display → Prepare → Commit), the full path through the render server is:

  1. Event — the app handles touch events
  2. Commit (app process) — Layout → Display → Prepare → Commit the layer tree to the render server
  3. Render Prepare (render server) — decodes the layer tree
  4. Render Execute (GPU) — draws via Metal
  5. Display — the frame is presented

The system is double-buffered, falling back to triple buffering under load.

Commit Hitch vs Render Hitch

  • Commit hitch: your app’s commit phase exceeds the VSYNC deadline (too much main-thread work).
  • Render hitch: the render server’s execute phase exceeds VSYNC — caused by overly complex layer trees, not your code’s CPU time.

Distinguishing the two tells you whether to optimize main-thread work or to flatten/simplify the layer hierarchy.

drawsAsynchronously

layer.drawsAsynchronously = true

Defers CGContext drawing to a background thread. The drawing code must be thread-safe. Useful when a layer’s draw work is non-trivial but already correct on a background queue.

Disabling Implicit Animations

CATransaction.begin()
CATransaction.setDisableActions(true)
layer.position = newPosition
layer.opacity = 1.0
CATransaction.commit()

Standalone CALayers apply implicit 0.25s animations on property changes. Disable them during scrolling or rapid programmatic updates.

allowsGroupOpacity

layer.allowsGroupOpacity = false

Prevents compositing sublayers against the parent’s opacity, reducing offscreen rendering when the parent is partially transparent.


16. Auto Layout Cost & Alternative Engines

The Reality: 8-12x Slower Than Manual

FrameworkSpeed
PinLayout, FlexLayout, LayoutKitEqual or faster than manual
Manual frame layoutBaseline
Auto Layout, UIStackView8-12x slower

iOS 12+ rewrote the engine to scale linearly with constraint count, but the constant factor is still high. In a hot scroll path, this is the difference between a smooth and a hitching list.

Avoiding Layout Thrashing

// ❌ BAD: two immediate solves
view.addConstraint(c1)
view.layoutIfNeeded()
view.addConstraint(c2)
view.layoutIfNeeded()

// ✅ GOOD: batch, single deferred solve
view.addConstraint(c1)
view.addConstraint(c2)
view.setNeedsLayout()

Alternative Layout Engines

  • PinLayout — CSS absolute-positioning style, frame-based, ~8-12x faster than Auto Layout.
  • FlexLayout — Yoga/flexbox wrapper, supports multithreaded layout.
  • Texture’s ASLayoutSpec — fully off-main-thread layout (see section 18).

For scrolling cells, prefer frame-based layoutSubviews (section 6) or one of these engines over Auto Layout.


17. GPU vs CPU Rendering

Stays on GPU (Fast)

  • Standard CALayer property changes (position, bounds, transform, opacity)
  • CAShapeLayer path rendering
  • CAGradientLayer, CATextLayer
  • Image display via layer.contents
  • CAAnimation — runs in the render server with zero main-thread cost

Falls to CPU (Slow)

  • Any draw(_ rect:) override — even an empty one allocates a backing store
  • Core Graphics drawing
  • shouldRasterize rasterization passes
  • Dynamic shadow calculation (without shadowPath)
  • masksToBounds + cornerRadius
  • NSAttributedString text sizing and rendering

CAShapeLayer vs drawRect

// ✅ GPU (preferred)
let shape = CAShapeLayer()
shape.path = UIBezierPath(roundedRect: bounds, cornerRadius: 8).cgPath
shape.fillColor = UIColor.blue.cgColor
layer.addSublayer(shape)

// ❌ CPU (avoid)
override func draw(_ rect: CGRect) {
    UIBezierPath(roundedRect: bounds, cornerRadius: 8).fill()
}

Benchmark: GPU-based draw(layer:ctx:) holds a steady 120 FPS at 10-11% CPU, whereas an equivalent CPU drawRect path drops below 20 FPS.

Pixel-Aligned Frames

// ❌ BAD: subpixel anti-aliasing forces GPU interpolation
view.frame = CGRect(x: 10.3, y: 20.7, width: 100.5, height: 50.2)

// ✅ GOOD: snap to pixel boundaries
let s = UIScreen.main.scale
view.frame = CGRect(
    x: round(10.3 * s) / s,
    y: round(20.7 * s) / s,
    width: round(100.5 * s) / s,
    height: round(50.2 * s) / s
)

Debug with Color Misaligned Images — magenta marks subpixel placement, yellow marks stretching.


18. Off-Main-Thread Work & Texture

Background Text Sizing

NSAttributedString sizing is one of the most expensive main-thread operations in a scroll. Compute it off-thread and cache the result:

DispatchQueue.global(qos: .userInitiated).async {
    let rect = attributedString.boundingRect(
        with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
        options: [.usesLineFragmentOrigin, .usesFontLeading],
        context: nil
    )
    let height = ceil(rect.height)
    DispatchQueue.main.async {
        self.cachedHeights[indexPath] = height
    }
}

Pre-Rendering Text as Images

DispatchQueue.global(qos: .userInitiated).async {
    let renderer = UIGraphicsImageRenderer(size: targetSize)
    let textImage = renderer.image { _ in
        attributedString.draw(in: CGRect(origin: .zero, size: targetSize))
    }
    DispatchQueue.main.async {
        textLayer.contents = textImage.cgImage
    }
}

This is the core technique behind Texture/AsyncDisplayKit.

Texture (AsyncDisplayKit)

UIKit runs layout, drawing, and display on the main thread. Texture distributes that work across cores:

UIKit:    Layout → Drawing → Display  [all in 8.33ms on one thread]

Texture:  Background Thread 1: Layout computation
          Background Thread 2: Text rendering
          Background Thread 3: Image decoding
          Main Thread: Only final display (minimal work)
  • Replaces UIView with ASDisplayNode (which wraps UIView/CALayer)
  • Layout runs off the main thread via layoutSpecThatFits:
  • No cell reuse — nodes are pre-built and ready
  • Replaces Auto Layout entirely with composable ASLayoutSpec

Trade-offs: no Auto Layout or Interface Builder support; ASDisplayNode.init runs off-main, so no UIKit access there; project maintenance has slowed since Pinterest reduced investment.


19. Advanced 240fps Techniques

RunLoop Observers for Idle-Time Work

let observer = CFRunLoopObserverCreateWithHandler(
    kCFAllocatorDefault,
    CFRunLoopActivity.beforeWaiting.rawValue,
    true, 0
) { _, _ in
    self.processIdleQueue()  // Pre-render cells, warm caches, pre-compute layouts
}
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .defaultMode)

Core Animation itself uses this mechanism to commit implicit transactions.

UITrackingRunLoopMode

// Fire during scrolling too
displayLink.add(to: .main, forMode: .common)

// Only fire when NOT scrolling
timer.add(to: .main, forMode: .default)

During scrolling the run loop switches to tracking mode; .common fires in both modes.

Pre-Rendering Entire Cells

func preRender(model: CellModel, size: CGSize) -> UIImage {
    let renderer = UIGraphicsImageRenderer(size: size)
    return renderer.image { _ in
        avatarImage.draw(in: avatarRect)
        titleString.draw(in: titleRect)
        UIBezierPath(roundedRect: cardRect, cornerRadius: 8).fill()
    }
}
// Then: cell.contentImageView.image = preRenderedImage

Object Pooling

class Pool<T> {
    private var available: [T] = []
    private let factory: () -> T
    private let lock = NSLock()

    init(factory: @escaping () -> T) { self.factory = factory }

    func acquire() -> T {
        lock.lock(); defer { lock.unlock() }
        return available.isEmpty ? factory() : available.removeLast()
    }

    func release(_ object: T) {
        lock.lock(); defer { lock.unlock() }
        available.append(object)
    }
}
// Use for DateFormatters, NSParagraphStyle, and other expensive-to-create objects

View Hierarchy Flattening

ApproachProsCons
Many UIViewsModular, accessibleMore compositing, blending
Flattened drawRectFewer layers, less compositingLoses interactivity
Layer-onlyMiddle groundManual hit-testing

Flatten selectively — only the expensive read-only parts:

class FlatContentView: UIView {
    var model: ContentModel? { didSet { setNeedsDisplay() } }
    override func draw(_ rect: CGRect) {
        // Single pass: avatar + text + decorations replaces 5-10 subviews
    }
}

class ComplexCell: UICollectionViewCell {
    let flatContent = FlatContentView()   // Flattened
    let button = UIButton()               // Kept: needs tap handling
}

20. Metal Integration

CAMetalLayer for Custom Rendering

class MetalView: UIView {
    override class var layerClass: AnyClass { CAMetalLayer.self }
    var metalLayer: CAMetalLayer { layer as! CAMetalLayer }

    override func didMoveToWindow() {
        super.didMoveToWindow()
        metalLayer.device = MTLCreateSystemDefaultDevice()
        metalLayer.pixelFormat = .bgra8Unorm
        metalLayer.framebufferOnly = true
        metalLayer.contentsScale = window?.screen.scale ?? 2.0
    }
}

When mixing Metal content with UIKit overlays, set metalLayer.presentsWithTransaction = true to synchronize Metal frames with the UIKit layer tree and avoid one-frame desync.


21. Quick Reference

60-120fps Baseline Checklist

  • Cell reuse implemented correctly
  • Prefetching enabled for table/collection views
  • Diffable data source used (iOS 13+); reconfigureItems over reloadItems
  • Shadow paths set for all shadows
  • Images downsampled before display
  • Async image loading with cancellation
  • No offscreen rendering (check with Instruments)
  • Background thread for heavy operations
  • Memory properly managed (weak references)
  • Profiled with Instruments

High Refresh Rate / 240fps Additional Checklist

  • CADisableMinimumFrameDurationOnPhone added to Info.plist
  • Per-animation CAFrameRateRange set; dummy DisplayLink used to force rate where ProMotion under-delivers
  • Aware that Low Power Mode caps at 60Hz
  • Commit hitch vs render hitch identified before optimizing
  • drawsAsynchronously / off-main text sizing for expensive drawing
  • Frame-based layout (or PinLayout/FlexLayout) in scrolling cells — not Auto Layout
  • Rendering kept on GPU (CAShapeLayer over drawRect; no draw override unless required)
  • All frames pixel-aligned
  • Texture/AsyncDisplayKit for the most demanding scroll scenarios
  • RunLoop observer pre-renders cells / warms caches during idle time
  • Object pooling for expensive-to-create objects
  • View hierarchy flattened where read-only
  • Metal (CAMetalLayer, presentsWithTransaction) for intensive custom graphics
  • Hitch Time Ratio < 5ms/s in the Animation Hitches instrument

Priority Order (Highest Impact First)

  1. Add CADisableMinimumFrameDurationOnPhone to Info.plist
  2. Move image decoding off the main thread (prepareThumbnail / prepareForDisplay)
  3. Eliminate offscreen rendering (shadowPath; no masksToBounds + cornerRadius)
  4. Set non-transparent views opaque with solid backgrounds
  5. Frame-based layout in scrolling cells (or PinLayout/FlexLayout)
  6. Cache cell heights and pre-compute text layouts
  7. Downsample images to display size before assigning
  8. Pixel-align all frames
  9. Disable implicit animations during batch updates
  10. Use Texture/AsyncDisplayKit for the most demanding scroll scenarios
  11. Profile with the Animation Hitches instrument — target < 5ms/s hitch ratio

Common Pitfalls

PitfallSymptomFix
No cell reuseMemory spike, jankdequeueReusableCell
Sync image loadFrozen scrollAsync loading
Shadow without pathDropped framesSet shadowPath
Deep Auto LayoutSlow layoutFlatten or manual
Main thread I/OHitchesGCD background
Missing prepareForReuseWrong contentCancel + clear

Frame Time Budgets

DisplayFPSBudgetUsable Time
Standard6016.67ms~12ms
ProMotion1208.33ms~5-6ms
Hypothetical 240Hz2404.17ms~2-3ms

At 240Hz, off-main-thread rendering (Texture-style), manual frame-based layout, and pre-rendering of all complex content become mandatory, not optional.

Advanced Instruments

InstrumentSignalThreshold
Animation Hitches — Hitch Time RatioTotal Hitch Time / Interaction Duration< 5ms/s good, 5-10 warning, > 10 critical
GPU Driver — Renderer UtilizationGPU-bound when high> 95% = GPU-bound
GPU Driver — Tiler UtilizationToo many layers / overdraw> 95% = too many layers
func testScrollingPerformance() {
    let app = XCUIApplication()
    app.launch()
    measure(metrics: [
        XCTOSSignpostMetric.scrollDecelerationMetric,
        XCTOSSignpostMetric.scrollDraggingMetric
    ]) {
        app.scrollViews.firstMatch.swipeUp(velocity: .fast)
    }
}

Instruments Shortcuts

ShortcutAction
⌘RRun with Instruments
⌘IProfile
⌘⇧RRecord
SpacePause/Resume