跪拜 Guibai
← Back to the summary

How a Feed App Survives: Two-Level Caching, Weak-Network Degradation, and OOM Defense

From Stuttering and Blank Images to Stable and Usable: How I Implemented Two-Level Caching, Weak Network Degradation, and OOM Management in a Feed App

When building a feed-based app, the most common problems almost always appear together:

On the surface, these problems seem to belong to separate domains like networking, caching, UI, and memory, but in reality, they are different links in the same chain.

In this article, I will combine a UIKit feed project to systematically lay out a set of solutions I consider practical:

This set of solutions does not pursue "flashy techniques"; the focus is on being implementable, stable, and able to clearly explain why it is done this way.


1. Where a Feed Project Is Most Likely to Die

Let's start with the conclusion: The real difficulty in a feed-based app is not loading data, but controlling peak resource usage.

Common problems fall into these categories:

Therefore, optimizing a feed is never just about "adding a cache"; it is a whole set of coordinated mechanisms.


2. Establish the Architectural Boundaries First

In this project, I adopted a fairly standard layered approach:

The core benefit of this separation can be summed up in one sentence:

The UI only handles display, the business logic only handles orchestration, and the infrastructure only provides capabilities.

This directly impacts subsequent performance management, because when image loading, weak network degradation, and caching strategies are not scattered across ViewControllers, optimization becomes much easier.


3. Two-Level Feed Data Caching: Get Content Displayed as Soon as Possible

The first principle of the list content experience is not "always the latest," but to have content as soon as possible, then calibrate asynchronously.

So, for Feed data, I implemented two data sources:

The cold start flow is:

  1. First, try to read the cache from SQLite.
  2. If data exists, display it first.
  3. Then, asynchronously initiate a refresh to fetch the latest data and overwrite.
  4. After success, write back to SQLite.

Core code:

func loadInitial() async {
    if featureFlags.bool(.diskPostCacheEnabled) {
        if let cached = try? store.fetchLatest(limit: 50), !cached.isEmpty {
            posts = cached
            analytics.track(.feedCacheHit, properties: ["count": cached.count])
            onStateChanged?(self)
        }
    }
    await refresh()
}

This strategy is very suitable for feeds:


4. Two-Level Image Caching: The Key to Scrolling Experience

For image caching, I split it into two levels:

1) L1 Hit Returns Immediately

If the same image at the same size is already in memory, return the UIImage directly. This path is the fastest.

if let cached = memoryCache.value(forKey: key) {
    log.debug("memory cache hit key=\(key, privacy: .public)")
    AnalyticsTracker.shared.track(.imageCacheHit, properties: nil)
    completion(.success(cached))
    return token
}

2) Network Request Only on L2 Miss

URLSession with URLCache.shared will prioritize the system cache and only make a network request on a miss.

let config = URLSessionConfiguration.default
config.requestCachePolicy = .useProtocolCachePolicy
config.urlCache = .shared
config.httpMaximumConnectionsPerHost = 8
session = URLSession(configuration: config)

3) Downsample Before Writing Back to L1

Note that this is critical: what is written into L1 is not the original data, but a UIImage downsampled to the target size.

This step is essentially about controlling bitmap memory.


5. Why "Images Are White While Scrolling, and Only Appear When You Stop"

Many people seeing this phenomenon for the first time assume the network is slow.

In fact, what is slow is often not the network, but that decoding is actively delayed.

In this project, the downloaded data is not decoded immediately. Instead, it is first thrown into RunLoopIdleWorkScheduler, waiting for the main thread to enter an idle point, and then the task is dispatched to a background decoding queue.

Core code:

RunLoopIdleWorkScheduler.shared.enqueue { [weak self] in
    guard let self else { return }
    self.decodeQueue.async {
        let image = ImageDownsampler.downsample(
            data: data,
            to: targetPixelSize,
            scale: UIScreen.main.scale
        )
        if let image {
            let cost = ImageLoader.approxCost(of: image)
            self.memoryCache.setValue(image, forKey: key, cost: cost)
            self.finish(key: key, result: .success(image))
        } else {
            self.finish(key: key, result: .failure(NSError(domain: "ImageLoader", code: -2)))
        }
    }
}

RunLoop observation point:

let obs = CFRunLoopObserverCreate(
    kCFAllocatorDefault,
    CFRunLoopActivity.beforeWaiting.rawValue,
    true,
    0,
    { _, _, info in
        guard let info else { return }
        let scheduler = Unmanaged<RunLoopIdleWorkScheduler>.fromOpaque(info).takeUnretainedValue()
        scheduler.drain(maxCount: 2)
    },
    &context
)

What does this mean?

This is not a bug, but a typical performance trade-off:

It is better to delay image display slightly than to drop frames while scrolling.


6. Request Deduplication: Saving More Than Just Bandwidth

Another easily overlooked problem in feeds is duplicate requests for the same image.

For example:

Without deduplication, the problems are very obvious:

So, the project added a layer of inFlight merging:

if var inflight = inFlight[key] {
    inflight.tokens.insert(token)
    inflight.completions.append(completion)
    inFlight[key] = inflight
    lock.unlock()
    return token
}

The benefits of this design are huge:


7. Why LRUCache Must Evict by Cost

Image caching cannot use a "number of images" limit, because the memory footprint of a small image and a large image are not in the same order of magnitude.

Therefore, the cache must evict by cost.

Here, I used a custom LRUCache with the internal structure:

Core code:

func setValue(_ value: Value, forKey key: Key, cost: Int) {
    lock.lock()
    defer { lock.unlock() }

    if let node = dict[key] {
        totalCost -= node.cost
        node.value = value
        node.cost = max(0, cost)
        totalCost += node.cost
        moveToHead(node)
    } else {
        let node = Node(key: key, value: value, cost: max(0, cost))
        dict[key] = node
        insertAtHead(node)
        totalCost += node.cost
    }

    evictIfNeeded()
}

Bitmap cost estimation:

private static func approxCost(of image: UIImage) -> Int {
    guard let cg = image.cgImage else { return 1 }
    return cg.bytesPerRow * cg.height
}

This is a more reasonable approach for image caching in feeds.


8. Weak Network Degradation: Not Just a "No Network" Alert

In many projects, handling a weak network is just showing an alert box.

But for a Feed, truly useful weak network degradation should be:

The degradation logic in this project is:

if featureFlags.bool(.weakNetworkDegradeEnabled), networkMonitor.isOnline == false {
    if posts.isEmpty {
        onError?("Currently offline, downgraded to display local cache only")
    }
    return
}

Combined with the SQLite cold start backfill, the user experience in weak network or offline scenarios becomes much more stable:


9. The Root Cause of OOM Is Not "Cache Too Large," but "Uncontrolled Peaks"

When many people encounter OOM, their first reaction is to reduce the cache size.

This certainly helps, but the more common real cause is: the instantaneous memory peak is too high.

For example, a typical scenario:

So, what OOM management really needs to do is:


10. After a Memory Warning, Why Just Clearing the Cache Is Not Enough

Many projects only do one thing on a memory warning:

cache.removeAll()

But in a feed, this is usually not enough.

Because right after you clear it, background downloads are still ongoing, decoding tasks in the idle queue are still ongoing, and memory will spike back up the next second.

So, the MemoryGuard in this project performs a whole set of actions:

@objc private func didReceiveMemoryWarning() {
    let cacheCost = ImageLoader.shared.currentCacheCost
    let inFlightCount = ImageLoader.shared.currentInFlightCount
    log.warning("memory warning cache_cost=\(cacheCost, privacy: .public) inflight=\(inFlightCount, privacy: .public)")
    AnalyticsTracker.shared.track(.memoryWarning, properties: [
        "cache_cost": cacheCost,
        "inflight": inFlightCount
    ])

    FeatureFlagCenter.shared.set(false, for: .imagePrefetchEnabled)
    imageCache?.removeAll()
    ImageLoader.shared.cancelAllLoads()
}

Combined with:

func cancelAllLoads() {
    lock.lock()
    let tasks = inFlight.values.map(\.task)
    tokenToKey.removeAll()
    inFlight.removeAll()
    lock.unlock()

    tasks.forEach { $0.cancel() }
    RunLoopIdleWorkScheduler.shared.removeAll()
}

The significance of this strategy is:

This is the real "stop the bleeding."


11. Cancellation Strategy: Don't Wait for Reuse to Cancel

Many people only cancel image tasks in prepareForReuse().

This has a problem:

So, a more robust approach is a three-layer cancellation:

1) Cancel on Cell Reuse

override func prepareForReuse() {
    super.prepareForReuse()
    cancelImageLoading()
    imageViews.forEach { $0.image = nil }
    lastPostID = nil
}

2) Cancel When Cell Goes Off-Screen

func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    (cell as? FeedPostCell)?.cancelImageLoading()
    prefetchTokensByIndexPath.removeValue(forKey: indexPath)?.forEach { imageLoader.cancelLoad($0) }
}

3) Cancel Prefetching

func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
    for indexPath in indexPaths {
        guard let tokens = prefetchTokensByIndexPath.removeValue(forKey: indexPath) else { continue }
        tokens.forEach { imageLoader.cancelLoad($0) }
    }
}

Only by layering these three can you truly avoid "meaningless loading."


12. Observability: Without Data, There Is No Real Optimization

What performance optimization for feeds fears most is "going by feel."

So, in this project, I added several key event types:

This allows answering these questions later:

This information is critical for locating online OOMs, because many OOMs don't produce a usable crash stack at all.


13. Complete Link Flowchart

flowchart TD
  A[Enter Feed] --> B[SQLite Cold Start Backfill]
  A --> C[cellForRowAt -> FeedPostCell.configure]
  C --> D[ImageLoader.loadImage]

  D --> E{L1 Memory Cache Hit?}
  E -->|Yes| F[Display Image on Main Thread]
  E -->|No| G{Existing inFlight Request?}
  G -->|Yes| H[Reuse Same Task]
  G -->|No| I[Start URLSession Request]

  I --> J{URLCache Hit?}
  J -->|Yes| K[Return Data Directly]
  J -->|No| L[Network Download]
  L --> K

  K --> M[RunLoopIdleWorkScheduler.enqueue]
  M --> N{Main Thread Idle?}
  N -->|No| O[Wait for Idle Point]
  N -->|Yes| P[Background Downsample on decodeQueue]

  P --> Q[Write to LRUCache]
  Q --> R[Main Thread Completion]
  R --> F

  F --> S{Cell Off-screen / Fast Scrolled?}
  S -->|Yes| T[didEndDisplaying Cancel]
  S -->|Yes| U[cancelPrefetching Cancel Prefetch]

  F --> V{Memory Warning?}
  V -->|Yes| W[Log cache_cost / inflight]
  W --> X[Report memoryWarning]
  X --> Y[Disable imagePrefetchEnabled]
  Y --> Z[Clear LRU + cancelAllLoads]

14. Actual Benefits Brought by This Solution

For Users

For Development


15. Final Summary

Performance optimization for feeds is not a single small trick, but a system design.

If you only do caching without request deduplication, the effect is limited. If you only do prefetching without cancellation, you might actually blow up the memory. If you only clear the cache without stopping background tasks, memory will rebound after a memory warning. Without analytics and logging, you don't even know if the optimization had any effect.

The truly reliable approach is:

In one sentence:

The performance stability of a feed-based app is essentially resource lifecycle management.

Project