2 min read

Memory leaks cost you money. Literally.

Memory leaks cost you money. Literally.
Photo by Marco Palumbo / Unsplash

As mobile devs, we often don’t care how much memory our app uses — as long as we don’t hit the dreaded OutOfMemory exception.

Let’s be honest: we do care as professionals, but if the app consumes 100 MB more than it should, it doesn’t hurt us. The app runs on our users’ phones — we’re not the ones paying for the extra RAM.

Well… I recently learned to care the hard way with my Telegram Finance Bot.

Yesterday, Railway messaged me that my bill doubled over the last two weeks.

I got curious - what’s causing this? Memory usage more than doubled over a couple of days - even though I didn’t introduce any big features or libraries. So I started digging…

My first suspicion was that I have a memory leak somewhere . In Android we have the benefit of coroutine scopes that are scoped to a certain component - like viewModelScope, lifecycleScope, etc. These scopes do the clean up magic behind the scenes. If the component is destroyed - so is everything inside that scope. A graceful, neat solution.

Well, in a pure Kotlin app you have to manage that yourself. In my bot I would start up CoroutineScopes for each operation and didn’t think twice of it. My thinking was - well, as long as everything finishes inside that scope - the memory will be released. And that is not wrong, but you have to be very careful with what’s going on inside the lambda.

Each scope created like this (CoroutineScope(Dispatchers.IO).launch { ... }) lives independently — if you forget to cancel it or it captures large references, they’ll stay in memory long after you expect them to be gone

To be honest, I didn’t find the exact leak — but I decided to optimize my app anyway.

Here’s what helped cut memory usage in half 👇

  • Made my data layer a singleton with Koin. Singletons help prevent redundant instantiations, but you also need to ensure they don’t themselves hold onto stale data forever.
  • I switched some StateFlows to SharedFlows where I didn’t need to retain the last emitted value — StateFlow always holds one, which can keep large objects in memory unnecessarily.
  • Created my own application-wide CoroutineScope with SupervisorJob instead of having dozens of independent CoroutineScopes running wild.
  • Removed unnecessary logging in production

After these changes, memory dropped (see the graph), and my Railway bill went down too.

Moral of the story:

Don’t be reckless with memory. Sloppiness can cost you real money — especially outside mobile apps.