Memory Management in Mobile Apps

By Ishan Khanna LinkedInUpdated May 25, 2026
mobile-system-designperformancememory-managementiosandroid

I once shipped a feature at a startup that looked perfect in QA. The image gallery scrolled smoothly, transitions were fluid, everything worked. Then we pushed it to production and started getting crash reports — hundreds of them — all from the same screen. The app was eating through 1.2GB of memory because we were holding decoded full-resolution images for every cell in a collection view. iOS killed the app without ceremony.

That experience changed how I think about mobile development. On a server, if you need more memory, you scale up. On mobile, you work within a fixed budget or your app dies. There is no negotiation.

Why Memory Matters More on Mobile#

Server engineers think about memory in terms of provisioning. Mobile engineers think about it in terms of survival.

iOS gives your app roughly 1-1.5GB before jetsam (the system's out-of-memory killer) terminates it. On older devices like the iPhone SE, that limit drops to around 600-800MB. You don't get a second chance — jetsam kills your process, and the user sees a crash.

Android varies wildly by device. A budget Android phone might give your app 128MB of heap space. A flagship could offer 512MB or more. The system can kill background apps at any time to reclaim memory, and if your foreground app pushes too hard, the user gets the dreaded "App has stopped" dialog.

The key difference from server-side thinking: there is no swap file to save you. When physical memory is full, something gets killed. Often it's your app.

Interview Tip: When an interviewer asks about non-functional requirements, proactively mention memory constraints. Say something like: "On iOS, we have roughly a 1GB memory budget before the system kills the app, so for an image-heavy feed, we'll need to be intentional about how many decoded images we hold in memory at once." This immediately signals you've shipped real mobile apps.

iOS Memory Model: ARC#

iOS uses Automatic Reference Counting (ARC). Unlike garbage collection, ARC is deterministic — the compiler inserts retain and release calls at compile time. Every object has a reference count. When that count hits zero, the object is deallocated immediately. No GC pause, no unpredictable timing.

There are three reference types:

  • strong (default): Increments the reference count. The object stays alive as long as any strong reference exists.
  • weak: Does not increment the reference count. Automatically set to nil when the object is deallocated. Must be optional.
  • unowned: Does not increment the reference count. Not set to nil on deallocation — accessing a deallocated unowned reference crashes your app. Use it only when you are certain the referenced object will outlive the reference.

ARC works well until you create a retain cycle: two objects holding strong references to each other. Neither can reach a zero reference count, so neither gets deallocated. The memory is leaked for the lifetime of the app.

Here is the most common retain cycle I see in iOS code — closures capturing self:

// BUG: Retain cycle — self holds the timer, timer's closure holds self
class FeedViewController: UIViewController {
    var timer: Timer?

    override func viewDidLoad() {
        super.viewDidLoad()
        timer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in
            self.refreshFeed() // strong capture of self
        }
    }

    func refreshFeed() {
        // fetch new posts
    }

    deinit {
        timer?.invalidate()
        print("FeedViewController deallocated") // this never prints
    }
}

The view controller holds a strong reference to the timer. The timer's closure captures self strongly. Even after the user navigates away, the view controller can never be freed.

// FIX: Use [weak self] to break the cycle
class FeedViewController: UIViewController {
    var timer: Timer?

    override func viewDidLoad() {
        super.viewDidLoad()
        timer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
            guard let self else { return }
            self.refreshFeed()
        }
    }

    func refreshFeed() {
        // fetch new posts
    }

    deinit {
        timer?.invalidate()
        print("FeedViewController deallocated") // now this prints correctly
    }
}

A common mistake I see in interviews: candidates say "always use [weak self]." That's cargo culting. You don't need [weak self] when the closure doesn't create a cycle — for example, a one-shot network completion handler that the view controller doesn't store. Overusing weak adds nil-checking noise to your code for no benefit. The rule is: use [weak self] when the object holding the closure is the same object (or in the same ownership chain as) the object captured by the closure.

Another classic iOS leak is the delegate pattern done wrong:

// BUG: Strong delegate creates a retain cycle
protocol DataManagerDelegate: AnyObject {
    func didLoadData(_ data: [Item])
}

class DataManager {
    var delegate: DataManagerDelegate? // strong reference — this is the bug
}

class SettingsViewController: UIViewController, DataManagerDelegate {
    let dataManager = DataManager()

    override func viewDidLoad() {
        super.viewDidLoad()
        dataManager.delegate = self // self -> dataManager -> delegate -> self
    }

    func didLoadData(_ data: [Item]) { }
}
// FIX: Declare delegate as weak
class DataManager {
    weak var delegate: DataManagerDelegate? // weak breaks the cycle
}

This is why Apple's delegation pattern always uses weak — it's not a style choice, it's a memory management requirement.

Android Memory Model: Garbage Collection#

Android uses the Java/Kotlin garbage collector. The GC runs periodically to find and free objects that are no longer reachable from any GC root (static fields, thread stacks, JNI references). Android's ART runtime uses a generational, concurrent GC:

  • Young generation: New objects live here. Most objects die young, so collecting this generation is cheap and frequent.
  • Old generation: Objects that survive several young-generation collections get promoted here. Collecting this generation is more expensive.

The GC is concurrent in modern Android (ART runtime), so it doesn't freeze the UI thread for long pauses like Dalvik used to. But it still has costs — a GC pass takes CPU cycles, and if you're allocating objects rapidly (say, in a scroll handler), you can trigger frequent collections that cause subtle frame drops.

The biggest difference from ARC: the GC can handle reference cycles automatically. If object A references object B and B references A, but nothing else references either, the GC will collect both. This sounds like a win, but it creates a false sense of security. The most common Android leaks don't come from reference cycles — they come from GC roots holding references they shouldn't.

The classic Android leak is an Activity reference escaping to a longer-lived object:

// BUG: Static reference to Activity leaks it
class NetworkClient {
    companion object {
        // This lives for the entire app process
        var callback: ((String) -> Unit)? = null
    }
}

class ProfileActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Lambda captures 'this' (the Activity) implicitly
        NetworkClient.callback = { response ->
            updateUI(response) // 'this' is captured
        }
    }

    private fun updateUI(response: String) {
        // update views
    }
}

The companion object (static in Java terms) is a GC root. It holds the lambda, the lambda captures the Activity, and now the Activity can never be collected — even after the user rotates the device or navigates away. The Activity's entire view hierarchy, its bitmaps, its fragments — all leaked.

// FIX: Use WeakReference and clear callbacks in onDestroy
class NetworkClient {
    companion object {
        var callback: WeakReference<((String) -> Unit)>? = null
    }
}

// Or better — use lifecycle-aware components
class ProfileActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Use ViewModel + LiveData instead of static callbacks
        val viewModel = ViewModelProvider(this)[ProfileViewModel::class.java]
        viewModel.profileData.observe(this) { response ->
            updateUI(response)
        }
    }

    private fun updateUI(response: String) {
        // update views
    }
}

Interview Tip: On Android, the lifecycle-aware architecture components (ViewModel, LiveData, Lifecycle) exist specifically to solve memory management problems. If you're discussing an Android system design and you propose managing state through static callbacks or singletons holding Activity references, that's a red flag. Use ViewModels — they survive configuration changes and are scoped to the right lifecycle.

Common Memory Leaks Across Both Platforms#

1. Singletons Holding View References#

This one bites teams in both iOS and Android. A singleton (app-scoped) holds a reference to a view or view controller/activity. The view is supposed to be temporary, but the singleton keeps it alive forever.

// BUG: Singleton holds a strong reference to a view controller
class AnalyticsManager {
    static let shared = AnalyticsManager()
    var currentScreen: UIViewController? // this is a leak waiting to happen

    func trackScreen(_ vc: UIViewController) {
        currentScreen = vc // previous vc is now leaked if not nilled out
    }
}

The fix: store only the data you need (a screen name string), not the entire view hierarchy.

2. Unregistered Observers and Listeners#

On iOS, NotificationCenter observers used to be a leading source of leaks before iOS 9 (which added automatic deregistration for block-based observers). But if you use addObserver(_:selector:name:object:), you still need to remove the observer manually.

On Android, registered BroadcastReceivers, ContentObservers, and sensor listeners that are never unregistered will hold references to the registering Activity.

// BUG: Listener registered but never unregistered
class LocationActivity : AppCompatActivity() {
    private val sensorManager by lazy {
        getSystemService(Context.SENSOR_SERVICE) as SensorManager
    }

    private val listener = object : SensorEventListener {
        override fun onSensorChanged(event: SensorEvent) {
            // process sensor data
        }
        override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
    }

    override fun onResume() {
        super.onResume()
        val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
        sensorManager.registerListener(listener, accelerometer, SensorManager.SENSOR_DELAY_NORMAL)
    }

    // Missing onPause — listener is never unregistered!
}
// FIX: Always unregister in the symmetric lifecycle method
override fun onPause() {
    super.onPause()
    sensorManager.unregisterListener(listener)
}

3. Large Bitmap and Image Handling#

This is the most impactful leak category in terms of actual bytes wasted. A single image decoded into memory as a bitmap takes width x height x bytesPerPixel bytes. A 12-megapixel photo from a modern phone camera (4000 x 3000 pixels at 4 bytes per pixel) consumes 48MB of memory when fully decoded. A 4K image (3840 x 2160) is about 33MB.

If your image gallery loads 10 full-resolution photos into memory, you've used 480MB — nearly half your budget on iOS, and well over the heap limit on many Android devices.

// BUG: Loading full-resolution image into an ImageView
let image = UIImage(contentsOfFile: photoPath) // full decode into memory
imageView.image = image // 48MB for a single 12MP photo
// FIX: Downsample to the display size using ImageIO
func downsampledImage(at url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
    let maxDimension = max(pointSize.width, pointSize.height) * scale
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimension
    ]

    guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
          let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
        return nil
    }

    return UIImage(cgImage: cgImage)
}

// A 12MP photo displayed in a 375x250 point view on a 3x screen
// loads as 1125x750 = ~3.4MB instead of 48MB
// FIX: Downsample before decoding on Android
fun decodeSampledBitmap(filePath: String, reqWidth: Int, reqHeight: Int): Bitmap {
    // First pass: read dimensions only
    val options = BitmapFactory.Options().apply {
        inJustDecodeBounds = true
    }
    BitmapFactory.decodeFile(filePath, options)

    // Calculate inSampleSize
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
    options.inJustDecodeBounds = false

    return BitmapFactory.decodeFile(filePath, options)
}

fun calculateInSampleSize(
    options: BitmapFactory.Options,
    reqWidth: Int,
    reqHeight: Int
): Int {
    val (height, width) = options.outHeight to options.outWidth
    var inSampleSize = 1

    if (height > reqHeight || width > reqWidth) {
        val halfHeight = height / 2
        val halfWidth = width / 2
        while (halfHeight / inSampleSize >= reqHeight &&
               halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }
    return inSampleSize
}

Interview Tip: If you're designing an image-heavy app like Instagram or Pinterest, mention downsampling early. Saying "we'll use ImageIO on iOS and BitmapFactory with inSampleSize on Android to decode images at display resolution, not source resolution" shows you've dealt with real memory pressure.

Detecting Memory Leaks#

Knowing that leaks exist is one thing. Finding them is another.

iOS: Instruments and Xcode Memory Graph Debugger#

Xcode Memory Graph Debugger is my go-to. While the app is running in the debugger, click the memory graph button in the debug bar. It shows you every live object, its retain count, and what's holding a reference to it. If you see a view controller that should have been deallocated still sitting in the graph, follow the reference chain to find who's holding it.

Instruments (Leaks template) is the other tool. It periodically scans the heap for objects with no root reference that haven't been freed — which shouldn't happen with ARC, but does when you have retain cycles. The Allocations instrument tracks every allocation and deallocation, letting you see memory growth over time.

What to look for:

  • Persistent growth during repeated navigation: Push and pop a screen 10 times. If memory goes up each time and doesn't come back down, that screen is leaking.
  • Objects that outlive their expected scope: A view controller that should have been deallocated after dismissal.
  • Abandoned memory: Memory that's technically still referenced but will never be used again (e.g., cached data for a screen the user will never revisit).

Android: LeakCanary and Memory Profiler#

LeakCanary is a library you drop into your debug build. It automatically watches Activities and Fragments after they're destroyed. If the garbage collector can't collect them within a few seconds, LeakCanary dumps the heap, analyzes the reference chain, and shows you a notification with exactly what's holding the leaked object.

It requires zero configuration for basic leak detection — just add the dependency:

// build.gradle (app module)
dependencies {
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}

That's it. No code changes. It hooks into the Activity lifecycle automatically.

Android Studio Memory Profiler gives you real-time heap usage, allocation tracking, and heap dumps. Use it to track memory during user flows — scroll a long list, navigate between screens, open and close dialogs — and watch for memory that grows but never drops.

Large List and Collection Handling#

This topic comes up in almost every mobile system design interview because feeds, lists, and grids are everywhere.

Both iOS and Android solve the "list of 10,000 items" problem the same way: view recycling. Instead of creating 10,000 views, the system creates just enough views to fill the screen plus a small buffer. As the user scrolls, views that leave the visible area are recycled and reused for new items entering from the other side.

On iOS, UITableView and UICollectionView manage this through dequeueReusableCell. On Android, RecyclerView uses the ViewHolder pattern.

The memory implication is significant: without recycling, a list of 1,000 image cells could consume gigabytes. With recycling, it uses memory for maybe 15-20 cells regardless of list length.

The place where engineers mess this up: holding references to cells outside the list. If you store cell references in an array or dictionary for later updates, you're defeating the recycling mechanism and potentially holding views in memory that should have been reclaimed.

Interview Tip: When designing any feed-based system (social media, e-commerce catalog, messaging), explicitly mention cell/view recycling and explain that it bounds memory usage to O(visible items) rather than O(total items). This shows you understand the mechanism, not just the API.

Memory Budgeting in System Design#

When you're designing a mobile system in an interview, you need to think about where your memory budget goes. Different apps have fundamentally different memory profiles.

An image-heavy feed (Instagram, Pinterest):

  • Decoded images in visible cells: 50-80MB
  • Image cache (in-memory LRU): 50-100MB
  • View hierarchy and UI: 20-30MB
  • Networking buffers: 10-20MB
  • App logic, models, state: 10-20MB
  • Total working set: ~150-250MB

A text-based messaging app (Slack, WhatsApp):

  • Message data for visible conversation: 5-10MB
  • Occasional image thumbnails: 10-20MB
  • View hierarchy: 15-25MB
  • SQLite/database buffers: 10-20MB
  • WebSocket connection buffers: 5-10MB
  • Total working set: ~50-85MB

These numbers shape your architectural decisions. The image-heavy app needs aggressive cache eviction, downsampling, and probably a two-tier cache (memory + disk). The messaging app has more headroom but needs to be careful about loading full chat history into memory.

When discussing memory budgets in an interview, you don't need exact numbers. You need to show the interviewer that you think about memory as a finite resource with competing demands, and that your design decisions account for those demands.

Memory Warnings and Cleanup#

Both platforms give your app a chance to free memory before the OS kills it. How you respond determines whether your app survives background pressure.

Responding to Memory Pressure#

Android provides more granular signals via onTrimMemory(level:), with distinct levels for different degrees of pressure. On iOS, when the system is running low on memory, it calls didReceiveMemoryWarning() on all active view controllers and posts a UIApplication.didReceiveMemoryWarningNotification — this is your last chance.

On either platform, the priority order for cleanup is the same:

  1. Image caches — drop in-memory image caches first. They can be rebuilt from disk or network.
  2. Preloaded or prefetched data — anything you loaded speculatively.
  3. Cached computed results — anything that can be recomputed.
  4. Non-visible view hierarchies — views for tabs the user isn't looking at.

What you should NOT release: the data the user is currently looking at, any unsaved user input, or state needed to resume the current operation.

override fun onTrimMemory(level: Int) {
    super.onTrimMemory(level)
    when (level) {
        ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
            // App moved to background — release UI-only resources
            imageCache.evictAll()
        }
        ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> {
            // Foreground but system is low — release non-critical caches
            thumbnailCache.evictAll()
            prefetchBuffer.clear()
        }
        ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> {
            // About to be killed — release everything possible
            imageCache.evictAll()
            dataCache.evictAll()
            thumbnailCache.evictAll()
        }
    }
}
override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()
    // Drop the image cache — it's the biggest win
    ImageCache.shared.removeAll()
    // Release prefetched data for screens the user hasn't visited
    prefetchedData.removeAll()
}

On Android, the TRIM_MEMORY_UI_HIDDEN level is especially useful — it fires when the user leaves your app (but before any memory pressure). Smart apps release their image caches here and reload them when the user comes back. This makes your app a better citizen and reduces the chance of being killed in the background.

Profiling and Monitoring in Production#

Finding leaks in development is one thing. Catching them in production is harder but just as important.

What to Track#

  • Peak memory usage per session: If this is trending upward across app versions, you've introduced a leak or regression.
  • Memory at crash time: Correlate OOM crashes with memory usage to understand your actual device-specific limits.
  • Memory by screen: If one screen consistently uses 3x more memory than others, that's where to investigate.

iOS Production Monitoring#

Apple provides MetricKit (iOS 13+) for collecting memory metrics in production. It delivers daily reports including peak memory usage, memory warnings received, and memory at termination.

Xcode Organizer shows memory reports aggregated across your user base, broken down by device type. This is where you discover that your app works fine on iPhone 15 Pro but crashes on iPhone SE due to lower memory limits.

Android Production Monitoring#

Android Vitals in the Google Play Console reports excessive memory usage and OutOfMemoryError crashes. It flags apps that exceed the 95th percentile of memory usage for their category.

For custom monitoring, track Runtime.getRuntime().totalMemory() and Runtime.getRuntime().freeMemory() at key points in your user flow and report them to your analytics backend.

A Practical Monitoring Strategy#

In interviews, I recommend describing a three-tier approach:

  1. Development: LeakCanary (Android) and Instruments (iOS) catch leaks before they ship.
  2. QA/Testing: Automated UI tests that navigate through screens repeatedly, checking that memory returns to baseline. Flag builds where memory grows monotonically.
  3. Production: MetricKit (iOS) and Android Vitals for passive monitoring, plus custom logging of peak memory at screen transitions for active monitoring.

Interview Tip: Mentioning production memory monitoring shows you think beyond development. Anyone can find a leak in a debugger. Senior engineers build systems that catch leaks before users see them.

Putting It All Together#

Memory management isn't a single technique — it's a mindset. When you design a mobile system, every architectural decision has a memory implication. The image loading strategy, the caching layer, the navigation architecture, the data layer — all of these contribute to your memory footprint.

In an interview, you don't need to recite ARC internals or GC generation thresholds from memory. What matters is showing that you think about memory as a first-class constraint, that you know the common failure modes on each platform, and that you have practical strategies for staying within budget.

The engineers who get hired at companies like Google and Airbnb aren't the ones who memorize [weak self]. They're the ones who can look at a system design and say: "This feed will have 20 visible cells with decoded images averaging 3MB each, so our steady-state memory for the feed is around 60MB. We should set our in-memory LRU cache to 80MB and downsample everything to 2x display resolution. On memory warnings, we drop the cache and reload from disk."

That's the level of thinking that gets you the offer.