Engineering Journal Mobile Engineering

Building Self-Healing Android Applications

Why keeping an app alive should never make it slower.

Intermediate
RESPONSIVENESS ACROSS ONE LONG SESSION cold start active use BACKGROUNDED · PROCESS NEVER KILLED recovery pass warm, again runtime recovery, a health check, not a restart 20+ MIN BACKGROUND FEELS LIKE MINUTE ONE

The Process That Got Slower

Every engineer who has shipped a long-lived mobile app has run into some version of this story. A user opens the app in the morning and it's fast, navigation is instant, a scan finishes before they've fully lowered the phone. They come back to it forty times that day, in the gaps between other things: a notification glanced at, the app switched away from and back to without a second thought. By evening, the same app that felt instant at nine a.m. is measurably slower answering the exact same actions it answered instantly eight hours earlier. Nothing crashed. Nothing errored. The app simply got slower, the longer it stayed alive.

There's a fix that resolves the symptom completely and explains nothing: force-quit the app and reopen it. The moment the process restarts, the slowdown is gone, not "a bit snappier," but back to the exact responsiveness of the first launch. That detail is the most useful clue in the story, because it rules out the explanation most engineers reach for first. A bug a full restart cures instantly isn't a broken feature. It's something the process quietly accumulated simply by staying alive, and a fresh process, by definition, hasn't accumulated anything yet.

This article is about that second category of problem, which gets a fraction of the attention the first one does. Startup performance has an enormous, well-earned body of tooling behind it: macrobenchmarks, baseline profiles, marketing copy about launching "in under a second." What happens after that moment, once an app has been alive, foregrounded and backgrounded repeatedly, for hours or days without a single full restart, is comparatively unexamined, despite being where users spend almost all their time in an app.

It's worth being direct about what this piece is and isn't arguing. It isn't claiming every long-lived app degrades this way, or that the pattern described here is the only correct fix, plenty of apps are simple enough, or recreated by the OS often enough, that this problem never surfaces in practice. Runtime degradation can come from many different causes depending on how an app is built; what follows is one architectural response, not a universal law. FarmakoMed, a medication-management app, is the concrete case study anchoring the second half of this article, not because its solution is the only correct one, but because it's real, shipped, and had to decide what to do about this.

Cold, Warm, and Hot Aren't the Whole Story

Android's own documentation is precise about this, and it's worth restating because most performance conversations flatten it into a single word, "startup." A cold start is the expensive case: the system creates the app's process from nothing, initialises objects, inflates a first layout, and puts an activity on screen. A warm start skips process creation but still redoes real work. A hot start is the cheapest of the three: the process and activity are already resident, and the system simply brings them back to the foreground.[1]

Warm vs Cold Start

A cold start creates the app's process from nothing. A warm start reuses the process but still redoes real work. A hot start reuses everything and simply brings an already-resident activity to the foreground.[1] Most engineering effort targets the first case; most sessions, for a habitually used app, are the third.

It's easy to see why cold start absorbs most of the attention. It's the case that's easiest to benchmark deterministically, one well-defined entry point, an entire tooling ecosystem (Macrobenchmark, Baseline Profiles) built around measuring it. It's also the case that shows up in the story a team tells about its own app: "launches in under a second" fits on a slide. "Stays exactly as fast on the fortieth open of the day as it was on the first" does not, even though it's arguably the more consequential claim.

For a returning user, the overwhelming majority of real sessions aren't cold starts at all. They're hot or warm resumes, the app was already there, sitting in memory, and the user just switched back to it. A team that only benchmarks the cold path has excellent visibility into how the app feels the first time and effectively none into how it feels the fortieth time that day, after sitting resident, doing nothing in particular, for twenty minutes. A benchmark suite that only exercises one of those cases has nothing to say about the other.

What Accumulates in a Process That Never Restarts

A process Android doesn't kill keeps everything it was holding the moment it was backgrounded, every thread, every listener, every object still reachable from a static reference or a singleton nobody explicitly scoped to a screen. None of that is unusual; it's exactly how a process is supposed to work. What's easy to miss is how much of it a typical app quietly creates and never explicitly tears down, because nothing about a single screen closing normally forces the question.

The list is familiar once you start looking for it: memory growth too small for a leak detector to flag; bitmap retention when a decoded image stays cached for a closed screen; coroutine scopes started against a component's lifetime and never cancelled; listeners, an ActivityEventListener, a BroadcastReceiver, registered once and never explicitly removed; caches sized for a single session rather than a week of them; Compose state retained by a back-stack entry that's technically still alive; ViewModels scoped more broadly than the screen that created them; background services outliving the work that started them; CameraX use cases and the executors bound to them; and the execution objects AI runtimes create and don't always fully release when the model itself unloads.

None of these, on their own, is the kind of leak that trips a detector's default threshold, every individual instance is easy to defend, "it's one thread," "it's one listener," and the defence is even true, in isolation. The problem is arithmetic, not any single bad decision: a process making small, individually justifiable exceptions to full cleanup, for hours, accumulates a baseline overhead a fresh process never had to carry.

Why Memory Leaks Aren't the Whole Story

A classic memory leak grows without bound until it eventually crashes the process. What's described here is different, and quieter: a fixed, nonzero amount of extra live state, a few threads, a few listeners, a few unpruned cache entries, that a fresh process simply wouldn't have. It doesn't grow indefinitely and it doesn't crash anything; it just taxes the scheduler and memory subsystem a little more, continuously, for as long as the process lives, exactly the regression a crash-free, leak-detector-clean build can still ship with.

Compose State Pitfalls

remember{} blocks anchored to a back-stack entry that's technically still alive; snapshot-state observers registered once and never unregistered; a recomposition scope subscribing to a long-lived flow that outlives the screen that created it. None of these are Compose bugs, Compose is doing exactly what it was told. They're closures over an assumption, "this screen's lifetime is short," that was true at cold start and quietly stopped being true forty minutes into a session nobody designed for.

Why onPause() Was Never Going to Be Enough

Android gives developers a well-defined set of lifecycle callbacks, onPause(), onStop(), onDestroy(), and most teams are genuinely disciplined about using them: release the camera when the preview closes, unregister a listener when the map screen closes, cancel a ViewModel's scope when it's cleared. This is well-trodden, code-reviewed territory. What's much less examined is the question those callbacks were never designed to answer: what should happen after the entire application, not any one screen, has sat backgrounded and un-killed for twenty minutes? onPause() and onStop() fire at the screen level, scoped to whatever's on top of the back stack, nothing in that model prompts a developer to ask what an application-wide background stretch, as opposed to a single screen closing, should trigger.

That's because the platform's own mental model is built around "when might this process die," not "what does this process accumulate while it's alive." Android's official guidance is explicit that a process's lifetime isn't controlled by the app itself, the system may terminate it at any time, based on memory pressure and how important its running components are to the user.[2] That's useful for saving state safely. It's also, inadvertently, a kind of training: if the assumption is "eventually the OS kills this and hands back a fresh process," there's no prompt to think about what a process that doesn't die accumulates in the meantime, and a modern, high-RAM device generous about letting a backgrounded process persist is exactly the scenario nobody was designing for.

Background Is Not Idle

"Backgrounded" doesn't mean "off." A backgrounded process keeps its heap, its threads except the ones the OS reclaims, and every object still reachable from something nobody scoped to a screen. The OS treats a long background stay as a scheduling decision to be made later, on its own terms. The application is the only thing in a position to treat it as an event worth reacting to now.

From Passive Cleanup to Active Recovery

The instinct described above, release resources when a screen closes, is passive cleanup. It's correct, necessary, and reactive: it only fires in response to something closing, scoped to whatever just closed. It has no mechanism for asking a harder question: does this process's current state still resemble a fresh process closely enough that the user can't tell the difference? Nothing about one screen's onPause() knows anything about the other nineteen things sitting in memory.

Active recovery is a deliberate answer to that question, run at the moment the app returns to the foreground, conditioned on how long it was actually away. It isn't a restart. It doesn't recreate the process, touch anything the user would recognise as their own data, or interrupt whatever the user is doing. The closer analogy is a scheduled health check: a bounded pass that asks a handful of narrow questions, is anything that should have been released still resident? Is any cache larger than it should be allowed to grow?, and nudges the ones that fail back toward baseline, invisibly.

TWO PROCESSES, SAME LENGTH OF TIME WITHOUT RECOVERY restart WITH RECOVERY no restart needed each gap = a background/resume cycle · dashed guides mark where the two lanes line up Without recovery, only a full restart resets the baseline. With recovery, every long resume resets it on its own.

The pattern has a fairly consistent anatomy: something has to know how long the process was backgrounded; a threshold separates a brief app-switch from a genuinely long absence; each recovery action is cheap, idempotent, and safe to run redundantly; and the whole thing hooks into whatever mechanism already observes foreground/background transitions, rather than becoming a second, competing observer of the same signal.

Recovery Should Be Conditional, Not Automatic

Running a recovery pass on every foreground transition, including the ten-second glance at a different app, would spend real cost solving a problem that glance never created. The threshold that separates "brief app-switch" from "absence worth reacting to" is as much a product decision as an engineering one, get it wrong, and the fix becomes indistinguishable from the problem it was meant to solve.

Modern AI Features Make This Harder, Not Easier

Modern mobile applications increasingly bundle real computation that didn't used to live on a phone at all: local large language models, on-device OCR, vision models, speech recognition, embedding generation. Each typically owns its own execution context, a dedicated thread or executor, a native inference engine handle, bindings to a GPU or NPU delegate. These objects are genuinely expensive to create, which is exactly what tempts a team into keeping them warm across screens, and exactly what makes them just as capable of quietly outliving their purpose as any other long-lived resource.

AI Pipelines Have Lifecycles Too

An inference engine handle, a GPU or NPU delegate binding, an executor thread feeding a model with work. These are native resources the layer above them loses track of more easily than a plain object, because releasing the model doesn't automatically release what was built to serve it. A pipeline has a lifecycle just like a screen does; nobody's used to reviewing it for one.

The specific danger with AI runtime objects is that they don't fail loudly when they leak. A model correctly unloaded from memory doesn't automatically take the thread that used to serve it with it, that thread can simply keep existing, idle, waiting for work that will never arrive. The visible symptom is never "the AI feature broke." It's "the AI feature, and everything near it, got a little slower", much harder to attribute to any single cause.

Camera pipelines belong in the same category. A live camera sensor is a real, measurable power draw, and most teams are careful about when it's open. The lifecycle risk usually isn't the sensor itself. It's the smaller scaffolding around it: an event listener registered once when a native camera module is constructed, and never removed when that module is later torn down.

Design Principles for Self-Healing Applications

None of the individual techniques described above are exotic on their own. Put together, they describe a short list of principles that hold regardless of platform, framework, or which specific resource happens to be at risk:

  1. Release resources as early as possible. Release the moment something stops actively serving a user action, not the moment a leak detector forces the question.
  2. Never assume the process will restart. The platform's own docs say the OS may kill a process at any time, the corollary teams miss is that it may just as easily not, for hours.
  3. Treat warm resumes as first-class scenarios. A resume after a long background stay deserves the same design attention as a cold start. It happens far more often.
  4. Monitor runtime health continuously, not only at launch. Thread counts and executor liveness are cheap to sample on resume, expensive to reconstruct after a user has already noticed something is wrong.
  5. Build recovery instead of relying on process death. Process death was never a cleanup mechanism a team designed. It was a side effect of an OS decision a team happened to benefit from.
  6. Prefer lightweight reinitialisation over full restarts. A fix has to cost meaningfully less than the problem it solves, or "force-quit and reopen" stays the more attractive option, which means the feature failed.
  7. Make recovery invisible to users. No spinner that says "optimising," no flicker, no lost scroll position. If a user can tell recovery happened, it wasn't finished being designed.
  8. Measure long-running performance, not only startup benchmarks. A suite that only exercises cold start stays green while a warm-resume regression ships completely unmeasured.

What We Found Inside FarmakoMed

The clearest way to make any of this concrete is to describe where it actually surfaced. FarmakoMed's diagnostics for this class of problem started on iOS, during an investigation into a related but distinct symptom, the app occasionally being terminated by the OS while backgrounded, forcing a cold start on the next open. That investigation produced a lifecycle diagnostics module logging app-state transitions, memory snapshots, and resume timing to a bounded on-device log, without altering runtime behaviour. It stayed iOS-only until the same broader problem, warm-resume slowdown, not background termination, turned up on Android too, and the module was ported over.

Porting it over meant looking at what a long-backgrounded Android process was holding onto, and the investigation found three concrete leaks, each individually small. A Health Connect module created a CoroutineScope at construction and never cancelled it, so every coroutine ever launched against it stayed reachable for as long as the process lived. A camera capture module registered an ActivityEventListener and never removed it, even after invalidation. And the local AI vision model's inference executor, the thread that actually ran inference, kept existing, idle, after the model it served was unloaded, because unloading the model's weights and releasing the executor that fed it work turned out to be two different actions, and only the first was happening.

That last one complicates a simpler story the team had already told itself. FarmakoMed's AI runtime already had a firm rule, described in an earlier piece in this journal: release the model on backgrounding rather than keep a multi-gigabyte artefact resident "just in case." That rule was working as designed. It just didn't cover the smaller object underneath it, a single executor thread, easy to overlook precisely because it isn't the multi-gigabyte number anyone was watching.

Two of the three fixes were narrow and permanent: the camera module now removes its listener on invalidation, and unloading the AI model now recreates its executor instead of leaving the old one idle. The Health Connect fix is more interesting for what it doesn't do, the scope is cancelled only when the native module tears down, not on ordinary backgrounding, since cancelling a user-initiated read over a brief app-switch would surface as a spurious failure.

Alongside those fixes, the same effort added a small runtime recovery manager: a resume handler registered on the same app-state machinery the diagnostics logger already used, rather than a second, competing listener, that only acts if the app was backgrounded for five minutes or longer. Below that threshold, a resume pays no cost. Above it, it checks that the local AI runtime is correctly suspended and prunes a re-fetchable document cache bounded by both age and total size, so it can never grow past a fixed ceiling.

Architecture Note

The recovery pass never recreates the process and never touches anything the user would recognise as their own data, only caches, executors, and other transient runtime state a fresh process wouldn't have carried anyway. That boundary keeps a health check a health check, not a disguised restart.

The same change also added something the Android app never had before: an override for the OS's own onTrimMemory and onLowMemory signals. Before this, the app only reacted to its own JavaScript-side notion of foreground and background, never to the platform's independent, sometimes earlier, warning that memory is getting tight. The fix forwards the event into the diagnostics stream rather than triggering a release directly, since the AI runtime was already unloaded unconditionally on backgrounding. The value isn't a new release path; it's finally seeing memory pressure happen, even when nothing else about the app's state changed.

Invisible Engineering

There's a pattern worth naming explicitly here, because it reaches well past this one investigation. Users will never open a support ticket thanking an app for the executor thread it didn't leave idle for twenty minutes. Nobody notices the fifteenth minute of a long session feeling exactly like the first. That's not an event, it's the absence of one, and absences don't get reported.

That's an uncomfortable property for this kind of work, because it will rarely be recognised on its own terms. What gets noticed, and reviewed poorly in an app store, is the opposite: "this app gets sluggish the longer I leave it open." The engineering that prevents that sentence from ever being true produces no visible artefact anyone can point to. Its only trace is the complaint that was never filed.

Invisible Engineering

The best compliment a runtime recovery system can receive is silence, a long session that simply never became a story worth telling, on either side of the app.

Conclusion

Performance is not exclusively, or even primarily, a startup problem. It's a lifecycle problem, treating it as anything narrower leaves an enormous, easily overlooked surface area unmeasured: everything that happens to a process between the moment it launches and the moment, hours or days later, someone finally force-quits it because it started to feel wrong.

None of this is a universal prescription. Plenty of applications are simple enough, or recycled by the OS often enough, that this pattern never has room to surface. Where it does, the causes are rarely identical from one app to the next, and a recovery manager tuned for one won't necessarily transfer to another. What transfers is the underlying discipline: treat a long-lived warm process as a first-class scenario worth designing for, not an edge case a cold-start benchmark happens to miss.

References
  1. Android Developers, App startup time. Source for the cold, warm, and hot start definitions used throughout.
  2. Android Developers, Processes and app lifecycle. Source for the platform's own framing of process termination as system-controlled rather than app-controlled.
  3. Android Developers, ComponentCallbacks2. Source for onTrimMemory and the memory-pressure signals discussed in "What We Found Inside FarmakoMed."
  4. FarmakoMed engineering notes, Runtime Recovery Manager and Android lifecycle-diagnostics port (July 2026). Source for the specific leaks, fixes, and recovery-pass design described in that section.
  5. Related reading in this journal: Battery-Aware AI Inference on Mobile (source for the AI-runtime-unloads-on-backgrounding rule referenced above), AI Should Wait for the User, Not the Other Way Around, and Privacy by Architecture.

One Last Thought

The highest compliment users can give an application is not noticing its engineering at all. If an app still feels exactly as fast on day three of a long session as it did in the first minute after installing it, that's rarely an accident. It's usually the trace of someone having spent real effort managing the parts of the runtime nobody outside the team was ever going to see. That's the whole discipline, in one sentence: make the parts nobody sees the reason nobody notices anything.

FarmakoMed Engineering
Engineering Journal · Mobile Engineering Series

Key Takeaways

Performance is a lifecycle problem, not only a startup one. Cold-start optimisation matters, but users spend the overwhelming majority of their time in warm, already-running processes that most benchmark suites never measure.

Long-lived processes accumulate small, individually defensible state. No single leftover thread or unpruned cache crashes anything, together they raise a process's baseline overhead the longer it stays alive.

Screen-level lifecycle callbacks were never designed to answer an app-level question. onPause() and onStop() react to a screen closing; nothing in that model naturally prompts a check on what a long background stay should trigger.

Active recovery is a conditional health check, not a restart. It only runs above a deliberate threshold, touches only transient runtime state, and never recreates the process or the user's data.

Releasing the big resource doesn't automatically release what was serving it. FarmakoMed's AI runtime correctly unloaded its model on backgrounding, the executor thread behind that model still had to be fixed separately.

More in the Journal

This article builds on the AI-runtime release rule described in our earlier piece on battery-aware inference on mobile.

Read "Battery-Aware AI Inference on Mobile"

Join the conversation

How does your team think about warm-resume performance versus cold-start benchmarks? We'd like to hear it. Follow FarmakoMed on LinkedIn.

Follow on LinkedIn