Logging is one of the first habits developers pick up — it’s a quick way to trace values, catch errors, or just understand what the code is doing. But in Android apps, logging isn’t always as harmless as it seems. That simple logger call might be quietly using CPU, creating objects, or even slowing down your UI.
In this article, we’ll take a closer look at what can go wrong with something as simple as logging — and how to avoid unnecessary performance issues with just a few small changes.
We will focus on a widely used logging method — allowing logs to be toggled during runtime. This technique is very useful because it allows logging even in release builds (for example, using remote config) to help diagnose problems.
The downside of this approach is that Proguard/R8 keeps the parts of the code related to logging (like building the message and the log call itself) as is and that’s where things can get tricky — we’ll take a closer look shortly.
A minimal version of this approach might look like:
var loggingEnabled: Boolean = false | |
fun logD(message: String) { | |
if (loggingEnabled) { | |
Log.d("RabbitHole", message) | |
} | |
} |
At first glance, everything looks fine — logs don’t show up unless we turn them on, so there’s nothing to worry about, right? Well, not quite. Even if nothing is printed to the console, we should ask ourselves: is the logger really doing nothing behind the scenes?
Let’s look at a few real-world examples that can actually cause problems in production. What do they have in common? They seem harmless… but they’re not.
Let’s say logging is turned off. We’ll start with a very basic example:
logD("$userName started to awaken...") |
After compilation, this line turns into the following equivalent Java code:
StringBuilder sb = new StringBuilder(); | |
sb.append(userName); | |
sb.append(" started to awaken..."); | |
LoggerKt.logD(sb.toString()); |
Even when logging is turned off, the app still does extra work — it builds the log message, even though it’s never used and goes straight to garbage collection. If there are only a few such messages, it’s usually not a problem — modern garbage collectors handle short-lived objects pretty well, and this kind of code may not seem like something to worry about.
But when there are many logs, even this “invisible” work can hurt performance, especially on low-end devices. And while phones are getting more powerful each year, there are still many older or weaker devices out there — and on those, it’s important to pay more attention to resource usage.
But what if we want to log not just a primitive, but a class that has an overridden toString()
method? For example, a Kotlin data class:
data class Battery( | |
val level: Double, | |
val age: Long | |
) |
You’ve probably already guessed that, on top of the harmless StringBuilder
, we also get the serialization logic of the object itself:
StringBuilder sb = new StringBuilder(); | |
sb.append("Battery(level="); | |
sb.append(level); | |
sb.append(", age="); | |
sb.append(age); | |
sb.append(")"); | |
return sb.toString(); |
The example looks simple, but it can use more memory and CPU than you expect. It gets worse if your data class has a list — Kotlin will call toString()
for each item. And this work happens every time, even if we don’t need the result at all. Here is a real OutOfMemory
stack trace (with the app’s package and class names changed) from one of the apps I found on the web:

Besides the obvious example above, there are some trickier cases too. For debugging, we often use formatters — especially when working with decimal numbers:
val sleepTimeYears = loginTime / (1.days.inWholeMilliseconds * 365.0) | |
logD("$user started to awaken...\nSleep time: ${"%.1f".format(sleepTimeYears)} years") |
As it often happens, the devil is in the details. Let’s take a closer look at what this simple piece of code actually turns into:
StringBuilder var10000 = (new StringBuilder()).append(user).append(" started to awaken...\nSleep time: "); | |
String var7 = "%.1f"; | |
Object[] var8 = new Object[]{sleepTimeYears}; | |
String var10001 = String.format(var7, Arrays.copyOf(var8, var8.length)); | |
logD(var10000.append(var10001).append(" years").toString()); |
And while StringBuilder
is mostly harmless if we don’t have complex serialization, String.format
is a much heavier call. It involves creating a Formatter
object, which may access the system to get locale settings, and then it parses and goes through the given format string. And we should remember: our app won’t even use the result. In some cases, this can even lead to an ANR, here’s a real stack trace example (with the app’s package and class names changed) from my own experience:

The solution? Lambdas!
I’m sure you have already figured this out. Yes — just a small tweak to how you call your logger, and you’re cutting down system load as much as it can be:
var loggingEnabled: Boolean = false | |
inline fun logD(messageBuilder: () -> String) { | |
if (loggingEnabled) { | |
Log.d("RabbitHole", messageBuilder()) | |
} | |
} |
In the example above, it’s very important not to forget to mark the logger functions as
inline
— this helps save both time and memory (details).
Logger method call:
logD { "$user knocked at the door" } |
This implementation is almost literally a free way to handle logging. It doesn’t matter how complex the expression inside the lambda is — it won’t be evaluated unless logging is actually enabled.
The only thing that happens at runtime is the creation of a new lambda instance and passing it to the logging method. And only if logging is turned on, the lambda is unwrapped and the expression is evaluated.
To confirm that this solution is truly optimal, I ran a series of benchmarks on my Pixel 8 Pro. As a baseline, we’ll use a simple string passed to the logger without any concatenation:
logD("Follow the white rabbit") // B – a baseline logging call |
We’ll compare the baseline call above with the following set of methods:
logD("Wake up, $userName… ${v++}") | |
// S – simple call, userName is a string, v is a Long to avoid string caching | |
logD("Battery pool: $batteryPool ${v++}") | |
// SO – simple call, batteryPool is a list of 10 Battery objects, v is a Long | |
logD("Sleep time: ${"%.1f".format(sleepTimeYears)} years ${v++}") | |
// SF – simple call with formatting, v is a Long | |
logD { "Sleep time: ${"%.1f".format(sleepTimeYears)} years ${v++}" } | |
// LF – lambda call with formatting, v is a Long |
Job Offers
Results for each method (measured over 1,000 iterations):

As you can see above, the cost of logging calls alone can sometimes noticeably impact performance — especially when complex objects are being serialized. And we’re not even considering the memory overhead of serializing objects to strings.
Apps often log a huge amount of information, and when you add third-party SDKs (which usually wrap their logging behind a flag), it’s easy to end up with a lot of wasted work.
That “empty” work can steal a few milliseconds from your app during critical frames — pushing you closer to janky frames, or even worse, to an ANR.
In most real projects, you are unlikely to encounter the problems described in this article. But a good understanding of what’s going on under the hood will help you make smarter and more thoughtful decisions in your code.
Let’s be honest — logger optimization is usually not the first thing you need to fix. There are often much bigger issues that deserve your attention.
So no — you don’t need to rewrite your logger today, at least until you are directly faced with the problem.
But keep this in mind: every tiny optimization, every avoided waste adds up. Especially when your app grows, scales, or just tries to survive under pressure.
Profile first. Measure what matters. And when the time comes — don’t let your logger drag you down.
This article was previously published on proandroiddev.com.