About this course
<p>A wake lock is one of the simplest APIs in Android and one of the easiest to misuse. Acquiring one takes a single line of code. Forgetting to release it can keep a phone's CPU awake for hours, drain the battery overnight, and earn your app a warning label on Google Play.</p>
<p>Most wake lock bugs aren't caused by a misunderstanding of the API itself. They come from ordinary control flow problems: an exception thrown between <code>acquire()</code> and <code>release()</code>, an early return, a callback that never fires, or a reference count that drifts out of balance.</p>
<p>These bugs are hard to spot in code review and invisible during normal testing, because the device is usually plugged in and awake anyway.</p>
<p>This article explains what a wake lock actually does at the system level, walks through the most common ways apps leak them, and shows concrete patterns and tools for preventing and detecting leaks. It also covers the modern APIs that remove the need for manual wake locks in most cases.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-happens-when-an-android-device-sleeps">What Happens When an Android Device Sleeps</a></p>
</li>
<li><p><a href="#heading-what-a-wake-lock-does">What a Wake Lock Does</a></p>
</li>
<li><p><a href="#heading-the-types-of-wake-locks">The Types of Wake Locks</a></p>
</li>
<li><p><a href="#heading-how-to-acquire-and-release-a-wake-lock">How to Acquire and Release a Wake Lock</a></p>
</li>
<li><p><a href="#heading-how-reference-counting-works">How Reference Counting Works</a></p>
</li>
<li><p><a href="#heading-common-ways-apps-leak-wake-locks">Common Ways Apps Leak Wake Locks</a></p>
</li>
<li><p><a href="#heading-patterns-that-prevent-leaks">Patterns That Prevent Leaks</a></p>
</li>
<li><p><a href="#heading-when-you-dont-need-a-wake-lock-at-all">When You Don't Need a Wake Lock at All</a></p>
</li>
<li><p><a href="#heading-how-to-detect-wake-lock-leaks">How to Detect Wake Lock Leaks</a></p>
</li>
<li><p><a href="#heading-how-to-test-wake-lock-behavior">How to Test Wake Lock Behavior</a></p>
</li>
<li><p><a href="#heading-wake-locks-below-the-framework">Wake Locks Below the Framework</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable writing Android apps in Kotlin and know the basics of services, broadcast receivers, and coroutines.</p>
<p>You'll also need <code>adb</code> installed and a physical device for the debugging section. Emulators don't model power states in a realistic way, so some of the commands later in the article produce misleading results on them.</p>
<h2 id="heading-what-happens-when-an-android-device-sleeps">What Happens When an Android Device Sleeps</h2>
<p>When the screen turns off and nothing else needs the CPU, Android puts the application processor into a low power suspend state. In this state, the CPU stops executing instructions, most of RAM is kept in self refresh, and only a few hardware blocks (the modem, the real time clock, sensor hubs, and a handful of interrupt controllers) stay alive. The device looks idle but is ready to wake up quickly when an interrupt arrives.</p>
<p>Android uses a mechanism called autosleep. The kernel tries to suspend the system as soon as there's no reason to stay awake. Anything that needs the CPU to keep running has to say so explicitly by holding a wakeup source (the kernel term for what Android calls a wake lock). As long as at least one wakeup source is active, the kernel refuses to suspend.</p>
<pre><code class="language-plaintext"> Screen off
|
v
Any active wakeup sources? == yes ==> CPU stays awake
|
no
|
v
Kernel suspends CPU
|
v
Interrupt (alarm, network packet, button press)
|
v
CPU resumes, driver or framework takes a wake lock to handle the event
</code></pre>
<p>The above diagram shows the decision the kernel makes repeatedly while the screen is off. If any wakeup source is held, the CPU keeps running. If none are held, the system suspends until a hardware interrupt arrives. When the device resumes, whatever code handles the interrupt usually grabs a short wake lock so it can finish its work before the kernel tries to suspend again.</p>
<p>The important detail is that staying awake is opt in. Your code doesn't get CPU time while the screen is off unless something is holding a wake lock on its behalf.</p>
<h2 id="heading-what-a-wake-lock-does">What a Wake Lock Does</h2>
<p>An app level wake lock is a request to <code>PowerManagerService</code>, a system service running inside <code>system_server</code>. When your app calls <code>acquire()</code>, the call goes over Binder to that service, which records the request along with your UID, a tag string, and the lock type. <code>PowerManagerService</code> aggregates all app requests and holds a single kernel wakeup source on behalf of all of them. When the last app level lock is released, it drops the kernel wakeup source and the device is free to suspend.</p>
<p>This design has two consequences worth understanding. First, the system knows exactly which app holds each lock and for how long, which is how battery statistics and Play Console vitals can blame your app.</p>
<p>Second, <code>PowerManagerService</code> links each lock to your process through a Binder death notification. If your process dies, the system releases its locks automatically. A leak is therefore bounded by the lifetime of your process, but processes that run foreground services or are kept alive by the system can live for many hours.</p>
<h2 id="heading-the-types-of-wake-locks">The Types of Wake Locks</h2>
<p>The <code>PowerManager</code> class defines several lock levels. Most of them are deprecated, and in practice only one matters for app developers.</p>
<table>
<thead>
<tr>
<th>Level</th>
<th>Keeps CPU on</th>
<th>Keeps screen on</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td><code>PARTIAL_WAKE_LOCK</code></td>
<td>Yes</td>
<td>No</td>
<td>Current, the only one most apps should use</td>
</tr>
<tr>
<td><code>SCREEN_DIM_WAKE_LOCK</code></td>
<td>Yes</td>
<td>Yes, dimmed</td>
<td>Deprecated since API 17</td>
</tr>
<tr>
<td><code>SCREEN_BRIGHT_WAKE_LOCK</code></td>
<td>Yes</td>
<td>Yes, full brightness</td>
<td>Deprecated since API 13</td>
</tr>
<tr>
<td><code>FULL_WAKE_LOCK</code></td>
<td>Yes</td>
<td>Yes, plus keyboard backlight</td>
<td>Deprecated since API 17</td>
</tr>
<tr>
<td><code>PROXIMITY_SCREEN_OFF_WAKE_LOCK</code></td>
<td>No</td>
<td>Turns screen off near proximity sensor</td>
<td>Current, used by calling apps</td>
</tr>
</tbody></table>
<p>The first column is the constant you pass to <code>newWakeLock()</code>, and the next two describe what the lock keeps powered. The screen-related levels were deprecated because apps kept them held after the user left, leaving the display on and the battery draining.</p>
<p>The replacement for keeping the screen on is the <code>FLAG_KEEP_SCREEN_ON</code> window flag (or <code>android:keepScreenOn</code> in a layout), which the window manager ties to the visibility of your window, so it can't leak. The proximity lock is a special case for in-call screens. For everything else, "wake lock" in modern Android means <code>PARTIAL_WAKE_LOCK</code>, and the rest of this article focuses on it.</p>
<h2 id="heading-how-to-acquire-and-release-a-wake-lock">How to Acquire and Release a Wake Lock</h2>
<p>Using a wake lock requires the <code>WAKE_LOCK</code> permission in your manifest. It's a normal permission, so it's granted at install time without a prompt.</p>
<pre><code class="language-xml"><uses-permission android:name="android.permission.WAKE_LOCK" />
</code></pre>
<p>This line declares that the app may hold wake locks. Without it, <code>acquire()</code> throws a <code>SecurityException</code>. Because the permission is granted silently, users have no idea your app uses wake locks until they look at battery usage, which is one more reason to be careful with them.</p>
<p>Here's the most basic usage.</p>
<pre><code class="language-kotlin">val powerManager = context.getSystemService(PowerManager::class.java)
val wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"myapp:upload"
)
wakeLock.acquire(10 * 60 * 1000L) // 10 minutes
uploadPendingFiles()
wakeLock.release()
</code></pre>
<p>The code gets the <code>PowerManager</code> system service and creates a <code>WakeLock</code> object with the partial level and a tag. Creating the object does nothing by itself. The CPU is only kept awake between <code>acquire()</code> and <code>release()</code>.</p>
<p>The tag <code>"myapp:upload"</code> shows up in <code>dumpsys</code>, Battery Historian, and Play Console, so it should identify both your app and the specific job.</p>
<p>The <code>app:component</code> format is the convention Google recommends, and it saves real time during debugging. The argument to <code>acquire()</code> is a timeout in milliseconds. If the lock is still held after 10 minutes, the system releases it for you.</p>
<p>This snippet looks correct but has a real bug: if <code>uploadPendingFiles()</code> throws, <code>release()</code> never runs. The timeout limits the damage to 10 minutes, but that's still 10 minutes of battery drain for every failure. The next sections explain how to fix this and what other traps exist.</p>
<h2 id="heading-how-reference-counting-works">How Reference Counting Works</h2>
<p>Wake locks are reference counted by default. Each call to <code>acquire()</code> increments an internal counter, each call to <code>release()</code> decrements it, and the lock is only dropped when the counter reaches zero.</p>
<pre><code class="language-kotlin">wakeLock.acquire(60_000L) // count = 1, lock held
wakeLock.acquire(60_000L) // count = 2
wakeLock.release() // count = 1, still held
wakeLock.release() // count = 0, lock released
wakeLock.release() // throws RuntimeException: WakeLock under-locked
</code></pre>
<p>The first two calls push the count to two. The first release only brings it back to one, so the lock stays held even though the code "released" it.</p>
<p>The lock is actually dropped on the second release. A third release, with nothing left to release, throws <code>RuntimeException</code> with the message "WakeLock under-locked". This is a crash in production, which is why some developers wrap <code>release()</code> in a <code>try</code> block or check <code>isHeld</code> first.</p>
<p>Reference counting is useful when several independent pieces of code share a single <code>WakeLock</code> object. It becomes a problem when the number of acquires and releases doesn't match, for example when a function is called twice but its completion callback fires only once.</p>
<p>You can turn reference counting off with <code>setReferenceCounted(false)</code>, after which any single <code>release()</code> drops the lock no matter how many times it was acquired. That's often the simpler model when one component owns the lock.</p>
<h2 id="heading-common-ways-apps-leak-wake-locks">Common Ways Apps Leak Wake Locks</h2>
<p>Wake lock leaks almost always fall into a small number of patterns. Recognizing them makes code review far more effective.</p>
<h3 id="heading-exceptions-and-early-returns">Exceptions and Early Returns</h3>
<p>The basic example above is the most common leak. Any code path that exits the function between <code>acquire()</code> and <code>release()</code> without running the release is a leak. That includes thrown exceptions, <code>return</code> statements added later by someone who didn't notice the lock, and <code>break</code> or <code>continue</code> inside loops.</p>
<pre><code class="language-kotlin">fun syncContacts() {
wakeLock.acquire(5 * 60 * 1000L)
val account = accountStore.current() ?: return
api.pushContacts(account)
wakeLock.release()
}
</code></pre>
<p>This function leaks the lock whenever there's no current account, because the early <code>return</code> skips the release. It also leaks when <code>pushContacts()</code> throws a network exception. Both paths look harmless in isolation. The timeout keeps the leak from being permanent, but every failed sync still costs 5 minutes of awake time.</p>
<h3 id="heading-asynchronous-work-that-outlives-the-caller">Asynchronous Work That Outlives the Caller</h3>
<p>Wake locks are often acquired in one place and released in a callback somewhere else. If the callback never runs, the lock is never released.</p>
<pre><code class="language-kotlin">fun startDownload(url: String) {
wakeLock.acquire()
downloader.enqueue(url, object : Callback {
override fun onComplete() {
wakeLock.release()
}
override fun onError(e: Throwable) {
log("download failed", e)
}
})
}
</code></pre>
<p>Two mistakes are here. The error callback forgets to release the lock, so every failed download leaks it. And the lock is acquired with no timeout at all, so the leak lasts until the process dies.</p>
<p>There is a third, less obvious risk: if <code>downloader</code> is ever cancelled or drops the request without calling either callback, the lock also leaks. Any design where the release depends on a third party calling you back needs a timeout as a safety net.</p>
<h3 id="heading-unbalanced-reference-counts">Unbalanced Reference Counts</h3>
<p>When a shared, reference counted lock is acquired more times than it's released, it stays held even though every code path appears to release it.</p>
<pre><code class="language-kotlin">class LocationTracker(private val wakeLock: PowerManager.WakeLock) {
fun onStart() {
wakeLock.acquire(30 * 60 * 1000L)
startUpdates()
}
fun onStop() {
stopUpdates()
wakeLock.release()
}
}
</code></pre>
<p>This class looks balanced. The problem appears when <code>onStart()</code> is called twice (for example after a configuration change or a duplicate intent) but <code>onStop()</code> is called once. The count ends at one, and the lock stays held until its timeout expires. Turning off reference counting, or guarding the acquire with <code>if (!wakeLock.isHeld)</code>, fixes this particular bug.</p>
<h3 id="heading-broadcast-receivers-that-start-background-work">Broadcast Receivers That Start Background Work</h3>
<p>The system holds a wake lock for you while <code>onReceive()</code> runs. As soon as <code>onReceive()</code> returns, that lock is released, and the process may be treated as idle.</p>
<p>A common mistake is to acquire a separate wake lock in <code>onReceive()</code>, start a thread, and release the lock from the thread. If the process is killed or the thread hangs, the release never happens.</p>
<p>The older <code>WakefulBroadcastReceiver</code> helper was built for this pattern and is now deprecated because of these problems. The modern alternatives are covered later in this article.</p>
<h3 id="heading-holding-a-lock-across-unbounded-operations">Holding a Lock Across Unbounded Operations</h3>
<p>Network requests without timeouts, waiting on a <code>CountDownLatch</code> without a deadline, and blocking reads from a Bluetooth socket are all operations that can hang forever. If a wake lock is held around one of them, the lock hangs too. The code technically releases the lock in every path, but the release is never reached because the operation never finishes.</p>
<h2 id="heading-patterns-that-prevent-leaks">Patterns That Prevent Leaks</h2>
<p>The fixes for these leaks come down to a few habits that are easy to apply consistently.</p>
<h3 id="heading-always-use-a-timeout">Always Use a Timeout</h3>
<p>Every call to <code>acquire()</code> should pass a timeout. Android Lint enforces this with the <code>WakelockTimeout</code> check, which flags calls to <code>acquire()</code> with no arguments.</p>
<p>Pick a timeout that's comfortably longer than the work should ever take, but short enough that a leak is survivable. For a sync job that normally finishes in 20 seconds, a few minutes is reasonable. An hour is not.</p>
<h3 id="heading-release-in-a-finally-block">Release in a Finally Block</h3>
<p>Structured release is the single most effective fix. In Kotlin, a small extension function makes it hard to get wrong.</p>
<pre><code class="language-kotlin">inline fun <T> PowerManager.WakeLock.withLock(
timeoutMs: Long,
block: () -> T
): T {
acquire(timeoutMs)
try {
return block()
} finally {
if (isHeld) release()
}
}
</code></pre>
<p>The function acquires the lock with a mandatory timeout, runs the caller's block, and releases the lock in a <code>finally</code> clause. Because of <code>finally</code>, the release runs on normal completion, on early return from the block, and when an exception propagates.</p>
<p>The <code>isHeld</code> check matters because the timeout may have already released the lock if the block ran long. Without the check, the <code>release()</code> call would throw "WakeLock under-locked" and replace whatever exception the block originally threw. Marking the function <code>inline</code> lets callers use <code>return</code> from the enclosing function inside the block, and the <code>finally</code> clause still runs.</p>
<p>With the helper in place, the contact sync function from earlier becomes safe.</p>
<pre><code class="language-kotlin">fun syncContacts() = wakeLock.withLock(timeoutMs = 5 * 60 * 1000L) {
val account = accountStore.current() ?: return
api.pushContacts(account)
}
</code></pre>
<p>The early return and any exception from <code>pushContacts()</code> now both pass through the <code>finally</code> block in <code>withLock</code>, so the lock is released on every path. The call site is also shorter, and a reviewer can confirm correctness at a glance without tracing every branch.</p>
<h3 id="heading-scope-locks-to-coroutines-carefully">Scope Locks to Coroutines Carefully</h3>
<p>The same pattern works inside a coroutine, because cancellation in Kotlin is delivered as a <code>CancellationException</code> and <code>finally</code> blocks run when a coroutine is cancelled.</p>
<pre><code class="language-kotlin">suspend fun uploadAll(files: List<File>) {
wakeLock.acquire(10 * 60 * 1000L)
try {
withTimeout(9 * 60 * 1000L) {
files.forEach { api.upload(it) }
}
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
}
</code></pre>
<p>The lock is acquired before the work starts and released in <code>finally</code>, which runs on completion, on failure, and on cancellation of the enclosing scope. <code>withTimeout</code> puts a hard deadline on the uploads themselves, set slightly shorter than the wake lock timeout so the coroutine gives up before the system forcibly drops the lock. This addresses the unbounded operation problem described earlier.</p>
<p>One caveat: the <code>inline</code> helper from the previous section can also be used here, since inline lambdas can call suspend functions when invoked from a suspend context.</p>
<h3 id="heading-give-each-job-its-own-lock">Give Each Job Its Own Lock</h3>
<p>Sharing one <code>WakeLock</code> object across unrelated features makes reference counts hard to reason about and makes battery stats useless, because every feature reports the same tag. Creating a separate lock with a descriptive tag per job costs almost nothing and makes leaks far easier to attribute.</p>
<p>If you do share a lock, disable reference counting or make sure a single owner manages it.</p>
<h3 id="heading-attribute-work-correctly">Attribute Work Correctly</h3>
<p>If your code acquires a wake lock on behalf of another app or UID (common in system services and SDKs), call <code>setWorkSource()</code> so the battery cost is charged to the right party. Apps rarely need this, but platform and OEM engineers should know it exists.</p>
<h2 id="heading-when-you-dont-need-a-wake-lock-at-all">When You Don't Need a Wake Lock at All</h2>
<p>The best way to avoid leaking a wake lock is to not hold one. Most background work on modern Android should use a higher level API that manages wake locks internally.</p>
<table>
<thead>
<tr>
<th>Need</th>
<th>Use instead of a manual wake lock</th>
</tr>
</thead>
<tbody><tr>
<td>Deferrable background work (sync, uploads, cleanup)</td>
<td>WorkManager or JobScheduler</td>
</tr>
<tr>
<td>Work that must run at an exact time</td>
<td><code>AlarmManager.setExactAndAllowWhileIdle()</code> plus a short job</td>
</tr>
<tr>
<td>Short follow up work after a broadcast</td>
<td><code>BroadcastReceiver.goAsync()</code></td>
</tr>
<tr>
<td>Long running work the user is aware of (music, navigation, workouts)</td>
<td>A foreground service with the appropriate type</td>
</tr>
<tr>
<td>Keeping the screen on while an activity is visible</td>
<td><code>FLAG_KEEP_SCREEN_ON</code></td>
</tr>
<tr>
<td>User initiated data transfers</td>
<td>User initiated data transfer jobs (API 34 and later)</td>
</tr>
</tbody></table>
<p>The left column describes what the app is trying to do, and the right column names the API that covers it. The common thread is that each of these APIs takes a wake lock on your behalf and releases it when your work finishes or times out.</p>
<p>WorkManager and JobScheduler hold a wake lock for the duration of <code>doWork()</code> or <code>onStartJob()</code> and enforce execution limits. <code>goAsync()</code> extends the system's broadcast wake lock for roughly 10 seconds while you finish up. <code>FLAG_KEEP_SCREEN_ON</code> is tied to window visibility and disappears when the user leaves.</p>
<p>Here's a WorkManager worker that replaces the upload example.</p>
<pre><code class="language-kotlin">class UploadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
uploadPendingFiles()
Result.success()
} catch (e: IOException) {
Result.retry()
}
}
}
</code></pre>
<p><code>CoroutineWorker</code> runs <code>doWork()</code> while JobScheduler holds a wake lock for it. When <code>doWork()</code> returns, the wake lock is released, whether the result is success, failure, or retry. If the work runs too long, the system stops it and cancels the coroutine. There's no <code>acquire()</code> or <code>release()</code> in the code, so there's nothing to leak. Returning <code>Result.retry()</code> on an I/O failure lets WorkManager schedule another attempt with backoff instead of the app keeping the CPU awake while it retries in a loop.</p>
<p>Manual wake locks still have a place. Media players, VoIP stacks, and apps that stream data from a connected device over Bluetooth often need to keep the CPU running while a foreground service is active. In those cases, the service is the lifecycle owner, and the wake lock should be acquired in <code>onCreate()</code> or when playback starts, and released in the matching stop path and in <code>onDestroy()</code>.</p>
<p>It also helps to know that Doze mode limits what a wake lock can do. When a device enters Doze, the system ignores partial wake locks held by apps that aren't on the battery optimization allowlist. That's not a license to leak, since the lock is still counted during maintenance windows and while the device is active, but it explains why a wake lock alone doesn't guarantee your code keeps running overnight.</p>
<h2 id="heading-how-to-detect-wake-lock-leaks">How to Detect Wake Lock Leaks</h2>
<p>Leaks rarely show up during development because the device is plugged in, the screen is on, and something else is keeping it awake. You need to look for them deliberately.</p>
<h3 id="heading-check-currently-held-locks-with-dumpsys-power">Check Currently Held Locks with dumpsys power</h3>
<p>The quickest check is to ask <code>PowerManagerService</code> what it's holding right now.</p>
<pre><code class="language-bash">adb shell dumpsys power | grep -A 20 "Wake Locks:"
</code></pre>
<p>This command dumps the full state of the power manager and filters it to the wake lock section. The output lists every app level wake lock currently held, one per line.</p>
<pre><code class="language-plaintext">Wake Locks: size=2
PARTIAL_WAKE_LOCK 'myapp:upload' ACQ=-14m32s110ms (uid=10245 pid=8123)
PARTIAL_WAKE_LOCK 'AudioMix' ACQ=-3s204ms (uid=1041 ws=WorkSource{10112})
</code></pre>
<p>Each line shows the lock level, the tag, how long ago it was acquired (<code>ACQ</code>), and the owning UID and process.</p>
<p>In this example, <code>myapp:upload</code> has been held for over 14 minutes, which is a strong hint of a leak if uploads normally take seconds. The second entry is held by the audio server on behalf of another app, as shown by its work source. Turn the screen off, wait a minute, and run this command again. Any lock from your app that is still present with a growing <code>ACQ</code> time deserves investigation.</p>
<h3 id="heading-measure-totals-with-batterystats">Measure Totals with batterystats</h3>
<p><code>dumpsys power</code> shows the current moment. To see how much wake lock time accumulated over a period, use battery statistics.</p>
<pre><code class="language-bash">adb shell dumpsys batterystats --reset
# unplug, use the device normally or leave it idle for a while
adb shell dumpsys batterystats com.example.myapp | grep -i "wake lock"
</code></pre>
<p>The first command clears the collected statistics so the measurement starts fresh. After running the device on battery for a while (at least an hour gives useful numbers), the second command prints statistics for your package, filtered to wake lock lines.</p>
<p>You'll see each tag along with its total held time and how many times it was acquired. A tag with a large total time and a small acquire count usually means a lock was held for a long stretch and not released promptly.</p>
<p>For a visual timeline, capture a bug report with <code>adb bugreport</code> and load it into Battery Historian, or record a Perfetto trace with the <code>android.power</code> data source enabled. Both show wake locks as bars on a timeline next to screen state, network activity, and CPU frequency, which makes it easy to see a lock that stays held long after the related activity stopped.</p>
<h3 id="heading-watch-android-vitals-in-play-console">Watch Android Vitals in Play Console</h3>
<p>Google Play tracks excessive partial wake lock usage as an Android vitals metric. A user session counts as excessive when the app holds non-exempt partial wake locks for a long cumulative period (the current definition is 2 or more hours within a 24 hour window) while in the background. Wake locks held by some system managed work, such as audio playback or certain job types, are exempt.</p>
<p>Play has made this a core vital, which means apps that exceed the bad behavior threshold can have reduced visibility in the store and may show a warning on their listing. The exact thresholds and exemptions change over time, so check the current Android vitals documentation, but the practical takeaway is stable: leaked wake locks are now a store ranking problem, not just a battery complaint.</p>
<h3 id="heading-enable-lint-checks">Enable Lint Checks</h3>
<p>Android Lint includes two relevant checks. <code>Wakelock</code> flags code paths where a lock is acquired but may not be released, and <code>WakelockTimeout</code> flags acquires without a timeout. Both are enabled by default as warnings. Raising them to errors in your <code>lint.xml</code> or Gradle configuration stops new leaks from being merged.</p>
<pre><code class="language-kotlin">android {
lint {
error += listOf("Wakelock", "WakelockTimeout")
}
}
</code></pre>
<p>This Gradle block promotes both checks from warnings to errors, so the build fails when either pattern appears. Lint is static and can't follow every asynchronous path, so it won't catch the callback leak shown earlier, but it catches the simple cases cheaply.</p>
<h2 id="heading-how-to-test-wake-lock-behavior">How to Test Wake Lock Behavior</h2>
<p>Wake lock handling is easy to cover with unit tests using Robolectric, which provides a shadow implementation of <code>PowerManager</code>.</p>
<pre><code class="language-kotlin">@RunWith(RobolectricTestRunner::class)
class ContactSyncTest {
@Test
fun releasesWakeLockWhenSyncFails() {
val context = ApplicationProvider.getApplicationContext<Context>()
val syncer = ContactSyncer(context, api = FailingApi())
runCatching { syncer.syncContacts() }
val lock = ShadowPowerManager.getLatestWakeLock()
assertThat(lock).isNotNull()
assertThat(lock.isHeld).isFalse()
}
}
</code></pre>
<p>The test creates the component under test with a fake API that always throws. It runs the sync and ignores the exception, then asks Robolectric for the most recently created wake lock.</p>
<p>The two assertions check that a lock was actually used and that it's no longer held after the failure. Tests like this are cheap to write for every error path, and they catch exactly the exception and early return leaks that are hardest to see in review. Injecting the <code>WakeLock</code> (or a small wrapper interface around it) into your classes makes this even easier, because you can then use a plain fake without Robolectric.</p>
<h2 id="heading-wake-locks-below-the-framework">Wake Locks Below the Framework</h2>
<p>If you work on platform code, HALs, or native daemons, you deal with wake locks one layer lower. Native code can call <code>acquire_wake_lock()</code> and <code>release_wake_lock()</code> from <code>libpower</code>, and on current Android versions these go through the <code>SystemSuspend</code> service, which owns the interface to the kernel's <code>/sys/power/wake_lock</code> file.</p>
<p>The AIDL interface of <code>SystemSuspend</code> returns an <code>IWakeLock</code> object from <code>acquireWakeLock()</code>. The lock is held as long as that object is alive and is released when the last reference is dropped, which gives native code the same scoped lifetime as the Kotlin <code>finally</code> pattern.</p>
<p>The kernel exposes the full list of wakeup sources (including those held by drivers, not just the one held by <code>PowerManagerService</code>) under <code>/sys/class/wakeup/</code> on recent kernels, or <code>/sys/kernel/debug/wakeup_sources</code> on older ones with debugfs. Reading either requires elevated privileges. When <code>dumpsys power</code> shows no app wake locks but the device still refuses to suspend, a driver or native daemon holding a kernel wakeup source is usually the cause, and these files show which one.</p>
<h2 id="heading-summary">Summary</h2>
<p>A wake lock tells the system that some code needs the CPU to stay awake even though the screen is off. App level wake locks are managed by <code>PowerManagerService</code>, which tracks every lock by UID and tag and holds a single kernel wakeup source for all of them. Because staying awake is opt in, a lock that's never released keeps the device from suspending and drains the battery until it times out or the process dies.</p>
<p>Most leaks come from ordinary control flow: exceptions and early returns that skip <code>release()</code>, error callbacks that forget to release, reference counts that drift out of balance, and locks held around operations that never finish.</p>
<p>The fixes are equally ordinary. Always pass a timeout, release in a <code>finally</code> block (ideally through a small helper like <code>withLock</code>), give each job its own tagged lock, and put deadlines on the work inside the lock.</p>
<p>In most cases, the better choice is to avoid manual wake locks entirely. WorkManager, JobScheduler, <code>goAsync()</code>, foreground services, and <code>FLAG_KEEP_SCREEN_ON</code> all manage the lock for you and release it when the work ends. When you do need one, verify it with <code>dumpsys power</code>, <code>batterystats</code>, Perfetto or Battery Historian, Lint, and unit tests that cover the failure paths.</p>
<p>With Play now treating excessive wake locks as a core vital, catching these leaks before release matters for both your users' batteries and your app's visibility in the store.</p>