All articles
iOS3 min read

Why Senior iOS Developers Always Use Lazy Properties (And You Should Too)

Master lazy properties to slash app startup time and memory usage

K
Karan Pal
Author

Swift Essentials

Image Generated by AI
Image Generated by AI

I’ve been reviewing iOS codebases for years, and there’s one pattern I see consistently in apps built by senior developers: strategic use of lazy properties. While junior developers tend to initialize everything upfront “just to be safe,” experienced developers understand that not everything needs to be ready immediately.

The difference is dramatic. Apps that take 3–4 seconds to launch can often be reduced to under a second just by making expensive objects lazy. Yet most developers either don’t know about lazy properties or avoid them because they seem “complicated.”

They’re not complicated. And once you understand when and how to use them, you’ll wonder how you ever built apps without them.

📺 Coming soon to Swift Pal: A complete video series on iOS performance optimization at _https://youtube.com/@swift-pal_

🤷‍♂️ What Are Lazy Properties Actually?

Most developers think lazy properties are just “variables that initialize later.” That’s like saying a Ferrari is just “a car that goes fast” — technically true, completely missing the point.

Lazy properties are Swift’s way of saying “don’t do expensive work until someone actually needs the result.” They’re calculated once, the first time you access them, then cached forever.

// Regular property - calculated at object creation
class PhotoEditor {
    let expensiveProcessor = HeavyImageProcessor() // Runs immediately
    
    init() {
        // HeavyImageProcessor() already ran by the time we get here
        print("PhotoEditor created")
    }
}

// Lazy property - calculated on first access
class PhotoEditor {
    lazy var expensiveProcessor = HeavyImageProcessor() // Runs when first accessed
    
    init() {
        print("PhotoEditor created instantly")
    }
}

let editor = PhotoEditor() // Fast!
// HeavyImageProcessor not created yet...

editor.expensiveProcessor.process(image) // NOW it gets created

The key insight: lazy properties let you separate object creation from expensive computation. Your objects initialize fast, but heavy work only happens when actually needed.

🚀 Real iOS Scenarios Where Lazy Properties Shine

Let me show you the exact scenarios where lazy properties transform app performance:

Expensive UI Components

class CustomViewController: UIViewController {
    // ❌ Bad: Creates complex view immediately
    let chartView = ComplexChartView()
    let animationView = LottieAnimationView()
    let customDrawingView = ExpensiveCustomView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // All three views already created, even if user never sees them
    }
}

// ✅ Better: Create views only when needed
class CustomViewController: UIViewController {
    lazy var chartView: ComplexChartView = {
        let view = ComplexChartView()
        view.setupComplexConfiguration()
        return view
    }()
    
    lazy var animationView: LottieAnimationView = {
        let view = LottieAnimationView(name: "animation")
        view.loopMode = .loop
        view.contentMode = .scaleAspectFit
        return view
    }()
    
    lazy var customDrawingView: ExpensiveCustomView = {
        let view = ExpensiveCustomView()
        view.setupExpensiveDrawingCode()
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Views created only when accessed
    }
    
    @IBAction func showChart(_ sender: UIButton) {
        view.addSubview(chartView) // Created here, not at viewDidLoad
        chartView.animateIn()
    }
}

Network and Data Managers

class AppCoordinator {
    // ❌ Bad: Creates all managers at app launch
    let networkManager = NetworkManager()
    let cacheManager = ImageCacheManager()
    let analyticsManager = AnalyticsManager()
    let locationManager = LocationManager()
    
    init() {
        // All managers created even if app never uses them
        // Location permission popup shows immediately
        // Network connections established unnecessarily
    }
}

// ✅ Better: Create managers on demand
class AppCoordinator {
    lazy var networkManager: NetworkManager = {
        let manager = NetworkManager()
        manager.configure(with: APIConfiguration.production)
        return manager
    }()
    
    lazy var cacheManager: ImageCacheManager = {
        let manager = ImageCacheManager()
        manager.setupDiskCache()
        return manager
    }()
    
    lazy var analyticsManager: AnalyticsManager = {
        let manager = AnalyticsManager()
        manager.configure(apiKey: "your-key")
        return manager
    }()
    
    lazy var locationManager: LocationManager = {
        let manager = LocationManager()
        manager.requestPermissions() // Only when actually needed
        return manager
    }()
    
    func startNetworking() {
        networkManager.start() // Created here
    }
    
    func trackEvent(_ event: String) {
        analyticsManager.track(event) // Created here
    }
}

Core Data and Database Setup

class DataController {
    // ❌ Bad: Sets up Core Data stack immediately
    let persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "DataModel")
        container.loadPersistentStores { _, error in
            if let error = error {
                fatalError("Core Data error: \(error)")
            }
        }
        return container
    }()
    
    init() {
        // Core Data stack loads during init - blocks app launch
    }
}

// ✅ Better: Load Core Data when first database operation happens
class DataController {
    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "DataModel")
        container.loadPersistentStores { _, error in
            if let error = error {
                fatalError("Core Data error: \(error)")
            }
        }
        return container
    }()
    
    var context: NSManagedObjectContext {
        return persistentContainer.viewContext // Core Data loads here
    }
    
    func saveContext() {
        if context.hasChanges {
            try? context.save()
        }
    }
}

📊 Performance Impact

Here’s what lazy properties do for your app:

Memory Usage

// Eager initialization - all objects created immediately
class EagerViewController: UIViewController {
    let imageProcessor = HeavyImageProcessor()    // Created at init
    let dataParser = ComplexDataParser()          // Created at init
    let mlModel = MLModelLoader()                 // Created at init
    
    // All three objects consume memory immediately
}

// Lazy initialization - objects created when first accessed
class LazyViewController: UIViewController {
    lazy var imageProcessor = HeavyImageProcessor()    // Created when needed
    lazy var dataParser = ComplexDataParser()          // Created when needed
    lazy var mlModel = MLModelLoader()                 // Created when needed
    
    // Objects only consume memory when actually used
}

Startup Time

The more expensive objects you create during app launch, the slower your startup becomes. Lazy properties defer this work until the objects are actually needed.

📝 Advanced Lazy Property Patterns

Once you master basic lazy properties, here are the advanced patterns that separate good developers from great ones:

Lazy Computed Properties

class UserProfile {
    let firstName: String
    let lastName: String
    let email: String
    let joinDate: Date
    
    // Regular computed property - calculated every time
    var displayName: String {
        return "\(firstName) \(lastName)"
    }
    
    // Lazy stored property - calculated once, cached forever
    lazy var formattedJoinDate: String = {
        let formatter = DateFormatter()
        formatter.dateStyle = .long
        formatter.timeStyle = .none
        return formatter.string(from: joinDate)
    }()
    
    // Complex lazy computation
    lazy var userThumbnail: UIImage = {
        return generateUserThumbnail(
            name: displayName,
            email: email,
            size: CGSize(width: 40, height: 40)
        )
    }()
    
    init(firstName: String, lastName: String, email: String, joinDate: Date) {
        self.firstName = firstName
        self.lastName = lastName
        self.email = email
        self.joinDate = joinDate
    }
}

Lazy Dependency Injection

protocol NetworkServiceProtocol {
    func fetchData() -> Data?
}

class NetworkService: NetworkServiceProtocol {
    func fetchData() -> Data? {
        // Actual network implementation
        return nil
    }
}

class MockNetworkService: NetworkServiceProtocol {
    func fetchData() -> Data? {
        // Mock data for testing
        return Data()
    }
}

class DataManager {
    // Lazy dependency injection - service created when first used
    lazy var networkService: NetworkServiceProtocol = {
        #if DEBUG
        return MockNetworkService()
        #else
        return NetworkService()
        #endif
    }()
    
    func loadUserData() {
        let data = networkService.fetchData() // Service created here
        // Process data
    }
}

Lazy Collections and Sequences

class ImageGallery {
    let imageURLs: [URL]
    
    // Lazy loading of actual images
    lazy var images: [UIImage] = {
        return imageURLs.compactMap { url in
            return UIImage(contentsOfFile: url.path)
        }
    }()
    
    // Lazy filtered collections
    lazy var thumbnails: [UIImage] = {
        return images.map { image in
            return image.resized(to: CGSize(width: 100, height: 100))
        }
    }()
    
    // Lazy expensive computations
    lazy var imageMetadata: [ImageMetadata] = {
        return images.map { image in
            return ImageMetadata(
                size: image.size,
                colorProfile: image.colorSpace,
                orientation: image.imageOrientation
            )
        }
    }()
    
    init(imageURLs: [URL]) {
        self.imageURLs = imageURLs
        // No images loaded yet!
    }
}

Lazy Singletons (The Right Way)

// ❌ Don't do this - not thread safe
class BadSingleton {
    static var shared: BadSingleton?
    
    static func getInstance() -> BadSingleton {
        if shared == nil {
            shared = BadSingleton()
        }
        return shared!
    }
}

// ✅ Better - thread safe lazy singleton
class AudioManager {
    static let shared = AudioManager() // Lazy by default in Swift
    
    private lazy var audioEngine: AVAudioEngine = {
        let engine = AVAudioEngine()
        engine.prepare()
        return engine
    }()
    
    private lazy var audioSession: AVAudioSession = {
        let session = AVAudioSession.sharedInstance()
        try? session.setCategory(.playback)
        return session
    }()
    
    private init() {} // Prevent external instantiation
}

// ✅ Even better - lazy service locator
class ServiceLocator {
    static let shared = ServiceLocator()
    
    lazy var analyticsService: AnalyticsService = {
        return AnalyticsService(apiKey: Configuration.analyticsKey)
    }()
    
    lazy var crashReportingService: CrashReportingService = {
        return CrashReportingService(apiKey: Configuration.crashlyticsKey)
    }()
    
    lazy var userDefaultsService: UserDefaultsService = {
        return UserDefaultsService(suiteName: Configuration.appGroup)
    }()
    
    private init() {}
}

⚠️ When Lazy Properties Go Wrong

Lazy properties aren’t magic — they can cause problems if you’re not careful:

Thread Safety Issues

class ThreadUnsafeExample {
    lazy var expensiveResource = ExpensiveResource()
    
    func accessFromMultipleThreads() {
        DispatchQueue.global().async {
            _ = self.expensiveResource // Thread 1
        }
        
        DispatchQueue.global().async {
            _ = self.expensiveResource // Thread 2
        }
        
        // Race condition! ExpensiveResource might be created twice
    }
}

// ✅ Thread-safe alternatives
class ThreadSafeExample {
    private let queue = DispatchQueue(label: "lazy-init")
    private var _expensiveResource: ExpensiveResource?
    
    var expensiveResource: ExpensiveResource {
        return queue.sync {
            if _expensiveResource == nil {
                _expensiveResource = ExpensiveResource()
            }
            return _expensiveResource!
        }
    }
}

// ✅ Or use a different pattern entirely
class ThreadSafeAlternative {
    static let expensiveResource = ExpensiveResource() // Thread-safe by default
}

When NOT to Use Lazy Properties

// ❌ Don't use lazy for simple values
class BadExample {
    lazy var userName = "DefaultUser" // Pointless - String is cheap
    lazy var isEnabled = true         // Pointless - Bool is cheap
    lazy var maxCount = 100          // Pointless - Int is cheap
}

// ❌ Don't use lazy for things you always need
class ViewController: UIViewController {
    lazy var titleLabel = UILabel() // Bad - you always need this
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(titleLabel) // Always accessed anyway
    }
}

// ❌ Don't use lazy for things that must be ready immediately
class AudioPlayer {
    lazy var audioSession = AVAudioSession.sharedInstance() // Bad!
    
    func playEmergencyAlert() {
        // Too late to set up audio session now!
        audioSession.setActive(true)
    }
}

// ✅ Use lazy for expensive, conditional, or optional operations
class GoodExample {
    lazy var complexCalculation: Double = {
        return (0...1000000).reduce(0) { $0 + sin(Double($1)) }
    }()
    
    lazy var optionalFeature: AdvancedFeature? = {
        guard Configuration.advancedFeaturesEnabled else { return nil }
        return AdvancedFeature()
    }()
}

Memory Leaks and Retain Cycles

class LeakyExample {
    lazy var processor: DataProcessor = {
        let processor = DataProcessor()
        processor.delegate = self // Retain cycle!
        return processor
    }()
}

// ✅ Fixed version
class FixedExample {
    lazy var processor: DataProcessor = {
        let processor = DataProcessor()
        processor.delegate = self
        return processor
    }()
    
    deinit {
        processor.delegate = nil // Break the cycle
    }
}

// ✅ Better - use weak references
class BetterExample {
    lazy var processor: DataProcessor = {
        let processor = DataProcessor()
        processor.weakDelegate = self // Weak reference
        return processor
    }()
}

🛠️ Practical Patterns You Can Use Today

Here are some copy-paste patterns that work great in real apps:

Lazy Table View Cells

class ComplexTableViewCell: UITableViewCell {
    lazy var customImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        imageView.layer.cornerRadius = 8
        return imageView
    }()
    
    lazy var gradientLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.colors = [UIColor.clear.cgColor, UIColor.black.cgColor]
        layer.locations = [0.0, 1.0]
        return layer
    }()
    
    override func prepareForReuse() {
        super.prepareForReuse()
        customImageView.image = nil
        // Lazy properties stay initialized but content gets reset
    }
}

Lazy Configuration Objects

class AppConfiguration {
    lazy var developmentConfig: Configuration = {
        var config = Configuration()
        config.apiBaseURL = "https://dev-api.example.com"
        config.loggingLevel = .verbose
        config.analyticsEnabled = false
        return config
    }()
    
    lazy var productionConfig: Configuration = {
        var config = Configuration()
        config.apiBaseURL = "https://api.example.com"
        config.loggingLevel = .error
        config.analyticsEnabled = true
        return config
    }()
    
    var currentConfig: Configuration {
        #if DEBUG
        return developmentConfig
        #else
        return productionConfig
        #endif
    }
}

Lazy properties are a straightforward way to improve app performance. By deferring expensive object creation until it’s actually needed, you can reduce startup time and memory usage.

The concept is simple: if an object is expensive to create and might not be used immediately, make it lazy. Your app will launch faster and use less memory.

Look through your existing code for heavy objects that don’t need to be ready at startup. Convert them to lazy properties and see the improvement.

📺 For more iOS performance tips, check out Swift Pal: _https://youtube.com/@swift-pal_

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading