Storage Performance Optimization on iOS

Techniques for optimizing local storage read/write performance on iOS, including caching strategies, batch operations, and lazy loading patterns used in KlivvrStorageKit.

technical6 min readBy Klivvr Engineering
Share:

Storage performance directly affects perceived app speed. When a user opens their banking app and expects to see their recent transactions, the difference between 50ms and 500ms is the difference between feeling instant and feeling sluggish. Keychain operations, file I/O, and JSON serialization all contribute to storage latency. Understanding where time is spent and how to reduce it is essential for building responsive mobile apps.

This article covers the performance optimization techniques used in KlivvrStorageKit, from in-memory caching to batch operations to lazy loading patterns.

Measuring Storage Performance

Before optimizing, you need to measure. KlivvrStorageKit includes built-in performance instrumentation that tracks operation latency.

final class StoragePerformanceMonitor {
    static let shared = StoragePerformanceMonitor()
 
    private var metrics: [String: [TimeInterval]] = [:]
    private let queue = DispatchQueue(label: "perf-monitor", attributes: .concurrent)
 
    func measure<T>(
        operation: String,
        block: () throws -> T
    ) rethrows -> T {
        let start = CFAbsoluteTimeGetCurrent()
        let result = try block()
        let duration = CFAbsoluteTimeGetCurrent() - start
 
        queue.async(flags: .barrier) {
            if self.metrics[operation] == nil {
                self.metrics[operation] = []
            }
            self.metrics[operation]?.append(duration)
        }
 
        if duration > 0.1 {  // Log slow operations
            print("[StorageKit] Slow operation: \(operation) took \(duration * 1000)ms")
        }
 
        return result
    }
 
    func report() -> [String: PerformanceStats] {
        queue.sync {
            metrics.mapValues { durations in
                PerformanceStats(
                    count: durations.count,
                    avgMs: durations.reduce(0, +) / Double(durations.count) * 1000,
                    maxMs: (durations.max() ?? 0) * 1000,
                    p95Ms: percentile(durations, 0.95) * 1000
                )
            }
        }
    }
 
    private func percentile(_ values: [TimeInterval], _ p: Double) -> TimeInterval {
        let sorted = values.sorted()
        let index = Int(Double(sorted.count) * p)
        return sorted[min(index, sorted.count - 1)]
    }
}
 
struct PerformanceStats {
    let count: Int
    let avgMs: Double
    let maxMs: Double
    let p95Ms: Double
}

In-Memory Cache Layer

The fastest storage operation is one that never hits disk. KlivvrStorageKit uses an in-memory cache as the first layer, backed by the NSCache API which automatically evicts entries under memory pressure.

final class CachedStorage {
    private let cache = NSCache<NSString, CacheEntry>()
    private let backingStore: SecureStorage
 
    init(backingStore: SecureStorage, cacheLimit: Int = 100) {
        self.backingStore = backingStore
        cache.countLimit = cacheLimit
    }
 
    func retrieve<T: Codable>(
        _ type: T.Type,
        forKey key: String,
        security: SecureStorage.SecurityLevel
    ) throws -> T? {
        // Check cache first
        if let entry = cache.object(forKey: key as NSString) {
            if !entry.isExpired {
                return entry.value as? T
            }
            cache.removeObject(forKey: key as NSString)
        }
 
        // Fall through to backing store
        let value = try backingStore.retrieve(type, forKey: key, security: security)
 
        // Populate cache
        if let value {
            let entry = CacheEntry(
                value: value,
                expiresAt: Date().addingTimeInterval(300)  // 5-minute TTL
            )
            cache.setObject(entry, forKey: key as NSString)
        }
 
        return value
    }
 
    func store<T: Codable>(
        _ value: T,
        forKey key: String,
        security: SecureStorage.SecurityLevel
    ) throws {
        // Write through: update both cache and backing store
        try backingStore.store(value, forKey: key, security: security)
 
        let entry = CacheEntry(
            value: value,
            expiresAt: Date().addingTimeInterval(300)
        )
        cache.setObject(entry, forKey: key as NSString)
    }
 
    func invalidate(forKey key: String) {
        cache.removeObject(forKey: key as NSString)
    }
 
    func invalidateAll() {
        cache.removeAllObjects()
    }
}
 
final class CacheEntry: NSObject {
    let value: Any
    let expiresAt: Date
 
    var isExpired: Bool { Date() > expiresAt }
 
    init(value: Any, expiresAt: Date) {
        self.value = value
        self.expiresAt = expiresAt
    }
}

Batch Operations

Reading or writing multiple items one at a time incurs the overhead of Keychain or file system access for each operation. Batch operations amortize this overhead by grouping multiple reads or writes into a single logical operation.

extension SecureStorage {
    func batchRetrieve<T: Codable>(
        _ type: T.Type,
        forKeys keys: [String],
        security: SecurityLevel
    ) throws -> [String: T] {
        var results: [String: T] = [:]
        results.reserveCapacity(keys.count)
 
        for key in keys {
            if let value = try retrieve(type, forKey: key, security: security) {
                results[key] = value
            }
        }
 
        return results
    }
 
    func batchStore<T: Codable>(
        _ items: [(key: String, value: T)],
        security: SecurityLevel
    ) throws {
        // Encode all items first to fail fast on serialization errors
        let encoded: [(key: String, data: Data)] = try items.map { item in
            let data = try JSONEncoder().encode(item.value)
            return (key: item.key, data: data)
        }
 
        // Write all items
        for item in encoded {
            try storeRaw(item.data, forKey: item.key, security: security)
        }
    }
}
 
// Usage: load all recent transactions in one batch
let transactionKeys = (0..<50).map { "txn_\($0)" }
let transactions = try storage.batchRetrieve(
    Transaction.self,
    forKeys: transactionKeys,
    security: .private
)

Lazy Loading with Pagination

For large datasets like transaction history, loading everything into memory at once is wasteful. Lazy loading fetches data in pages as the user scrolls.

final class PaginatedStorage<T: Codable & Identifiable> {
    private let storage: CachedStorage
    private let pageSize: Int
    private let keyPrefix: String
    private let security: SecureStorage.SecurityLevel
 
    init(
        storage: CachedStorage,
        keyPrefix: String,
        pageSize: Int = 20,
        security: SecureStorage.SecurityLevel = .private
    ) {
        self.storage = storage
        self.keyPrefix = keyPrefix
        self.pageSize = pageSize
        self.security = security
    }
 
    func loadPage(_ page: Int) throws -> [T] {
        let index = try loadIndex()
        let start = page * pageSize
        let end = min(start + pageSize, index.count)
 
        guard start < index.count else { return [] }
 
        let pageKeys = index[start..<end]
 
        return try pageKeys.compactMap { id in
            try storage.retrieve(
                T.self,
                forKey: "\(keyPrefix)_\(id)",
                security: security
            )
        }
    }
 
    var totalPages: Int {
        let index = (try? loadIndex()) ?? []
        return (index.count + pageSize - 1) / pageSize
    }
 
    private func loadIndex() throws -> [String] {
        try storage.retrieve(
            [String].self,
            forKey: "\(keyPrefix)_index",
            security: .standard
        ) ?? []
    }
}
 
// Usage in a SwiftUI view model
class TransactionListViewModel: ObservableObject {
    @Published var transactions: [Transaction] = []
    @Published var isLoading = false
 
    private let paginatedStorage: PaginatedStorage<Transaction>
    private var currentPage = 0
 
    func loadNextPage() {
        guard !isLoading else { return }
        isLoading = true
 
        Task { @MainActor in
            let page = try? paginatedStorage.loadPage(currentPage)
            if let page, !page.isEmpty {
                transactions.append(contentsOf: page)
                currentPage += 1
            }
            isLoading = false
        }
    }
}

Conclusion

Storage performance optimization on iOS is about reducing latency at every layer: in-memory caching eliminates disk I/O for frequently accessed data, batch operations amortize per-item overhead, and lazy loading avoids loading data the user has not requested. KlivvrStorageKit applies these patterns transparently so that app developers get fast storage without managing caches and pagination manually. The guiding principle is simple: the best storage operation is the one that does not happen, and when it must happen, make it as cheap as possible.

Related Articles

business

Building Internal SDKs That Developers Want to Use

Lessons from building KlivvrStorageKit on creating internal SDKs with great developer experience — covering API design, documentation, migration support, and adoption strategies.

6 min read
technical

iOS Keychain Services: A Comprehensive Guide

Master iOS Keychain Services with this comprehensive guide covering item classes, access control, sharing, migration, and common pitfalls in Swift.

8 min read