performancememorymobile-qanon-functionalqa

Memory Leaks: How to Catch the Bug That Only Shows Up After an Hour

The nastiest bugs I’ve caught never reproduced in five minutes. The test case passes, smoke is green, the release ships. A week later the reviews say: “after an hour of play everything slows down”, “it crashes if you scroll the feed for a long time”, “the phone gets hot and the app closes by itself”. You open the app, check — it flies. Because you opened it for two minutes, and the user stays in it for forty.

Almost always there’s a memory leak behind reviews like these. And almost always it could have been found before release — if you know where to look.

What a leak is, through a tester’s eyes

A developer will say: an object is no longer needed, but a reference to it remains, so the garbage collector can’t free it. For QA another definition is more useful: memory grows and doesn’t come back when the user repeats the same action.

A healthy app “breathes”: open a screen — memory goes up, close it — memory returns to about where it was. A leaking app climbs a staircase: open-close, open-close, and after each round the low point is a bit higher than before. A hundred rounds — and the system kills the process.

Hence the main rule: you can’t see a leak on a single pass. You only see it on repetition.

What a leak looks like to the user

Symptoms almost never say “memory” out loud. Here’s what usually lands in reviews and support tickets:

  • Gradual slowdown. The longer the session, the lower the FPS and the slower screens open. The garbage collector runs more and more often.
  • Crashes with no stack trace. On Android the system kills the process via the Low Memory Killer, on iOS via jetsam. The app just disappears, and Crashlytics often has no proper crash, because it isn’t an exception — the process was killed from outside.
  • State reset after backgrounding. The user leaves the app for a minute, comes back — and it starts from scratch. A bloated process is the first to be killed in the background, as the Android process lifecycle docs say directly.
  • Heat and battery drain. A constantly working garbage collector means CPU.
  • “Doesn’t work on my old phone.” On a 12 GB flagship a leak survives two hours; on a 3 GB budget phone, twenty minutes. So complaints arrive unevenly and look like “a problem with one specific model”.

If reviews say “after a while”, “if you don’t close it”, “over time” — check memory, even if nobody used the word “leak”.

Scenarios that expose leaks

Leaks live where something is created and destroyed many times. These are the scenarios I run first.

  • Open and close a screen 20–50 times in a row. A product card, profile, settings, a shop popup. The most common source is a screen that doesn’t unsubscribe from events or leaves a timer alive when it closes.
  • Screen rotation. On Android rotation recreates the Activity. If something holds a reference to the old one, a whole screen stays in memory after each rotation.
  • Infinite feed and scrolling. Scroll a feed with images for five minutes. If images aren’t released, memory grows linearly.
  • Background and foreground. Go to the background and back 20 times. This catches subscriptions that are re-created on every return.
  • Repeated game sessions. For games: level → lose → restart, 30 times. Objects from previous levels that weren’t destroyed pile up.
  • Account switch and logout. The previous user’s data sometimes stays in memory forever — which is also a security problem.
  • Bad network. Requests cancelled by timeout whose callbacks keep holding the screen.
  • A long idle session. Just leave the app open for an hour. This catches leaks in timers, animations, polling and ad SDKs.

A note on ads and SDKs: leaks in third-party libraries are common. If memory grows only when an interstitial is shown, it’s still a bug and still worth reporting, even if “it’s not our code”.

How to measure: the quick way and the proper way

You don’t need to be a developer to see a leak. You need to capture two numbers: memory before and after repetition.

The quick way — no profiler. On Android, adb is enough:

adb shell dumpsys meminfo com.example.app

Look at the TOTAL PSS line. Capture it after a cold start, run the scenario 30 times, return to the same screen, wait 10–20 seconds and capture it again. If the number grew by tens of megabytes and doesn’t come down, you have a leak candidate. On iOS, Xcode → Debug Navigator → Memory gives a similar rough picture when running from a Mac.

The proper way — a profiler.

  • Android: the Memory Profiler in Android Studio shows a live memory graph, can capture a heap dump and highlights leaked Activities and Fragments. If the team has a debug build, ask them to add LeakCanary: it catches leaks during ordinary manual testing and shows a notification with the reference chain. For QA it’s an almost free detector.
  • iOS: Xcode Instruments with the Allocations and Leaks instruments, plus the Memory Graph in the debugger. Apple covers this in its guide to gathering memory information.
  • Unity: the built-in Profiler Memory module and the Memory Profiler package. The key trick: take a snapshot in the menu, play five levels, return to the menu, take a second one and compare. Anything left over from the levels is a leak.
  • Web: Chrome DevTools → Memory, a heap snapshot before and after the scenario. Google has a good guide to fixing memory problems with the three-snapshot technique.

The principle is the same everywhere: snapshot → repeat → snapshot → compare. You don’t need to understand the whole object graph. It’s enough to see that after opening a screen 30 times, 30 copies of that screen are alive in memory.

How not to fool yourself

A few traps I’ve fallen into myself.

  • Growth isn’t a leak yet. Image caches, object pools and warmed-up shaders legitimately increase memory after the first actions. A leak is when growth continues with every repetition and never hits a ceiling. So look at the trend over 5, 10, 20 and 30 repetitions, not at one number.
  • Let the garbage collector run. Capture the number 10–20 seconds after the action, not immediately. Profilers have a “force GC” button — press it before a snapshot.
  • Test the release build. Debug builds use more memory and behave differently. Do the final check on what users will get.
  • Test on a weak device. A leak that adds 200 MB per hour on a flagship will kill the process in 15 minutes on a budget phone. For mobile projects it’s worth keeping at least one 3–4 GB phone in the device matrix.

What to attach to the bug report

A bug like “the app slows down after an hour” can take a developer a week. A bug with data takes an hour. Here’s what I attach:

  • The exact repetition scenario: “open the product card and go back, 30 times”.
  • Numbers: memory after start, after 10, 20 and 30 repetitions. Ideally a table or a profiler graph screenshot showing the staircase.
  • Device, OS and build type (release or debug).
  • A heap dump or snapshot if you have one: .hprof for Android, .memgraph for iOS, .snap for Unity.
  • The LeakCanary report if it fired: it already contains the reference chain, which is almost the answer.
  • What doesn’t leak: “the same scenario on the profile screen doesn’t grow memory”. This narrows the search a lot.

How to build it into the process

You don’t need to hunt leaks by hand before every release. Three habits are enough:

  • A soak scenario in regression. One long 30–60 minute scenario: the main screens in a loop. Run it once per release, ideally automated, recording memory at the start and the end.
  • A CI threshold for critical screens. If you have UI tests, add a step: run the screen 20 times and compare PSS. Growth above a set threshold turns the test red.
  • LeakCanary in QA debug builds. The cheapest option: testers find leaks as a side effect of normal work.

Checklist: testing for memory leaks

  • Capture memory after a cold start on a reference screen.
  • Open and close key screens 20–50 times.
  • Rotate the screen repeatedly if the app supports it.
  • Background and foreground the app 20 times.
  • For games: restart a level 30 times; go menu → level → menu 10 times.
  • Scroll feeds and image lists for 5 minutes.
  • Leave the app open and idle for 30–60 minutes.
  • Show ads and other SDK screens several times in a row.
  • Capture the number 10–20 seconds after each action, ideally after a forced GC.
  • Look at the trend over 5, 10, 20, 30 repetitions, not a single number.
  • Do the final check on a release build and a weak device.
  • In the bug report: scenario, numbers per repetition, device, heap dump.

A memory leak is a bug that punishes your most loyal users: the ones who stay in the app the longest. Short test cases can’t see it by definition. Only a tester who thought to repeat the same action thirty times and look at the number can.