How to Stop Your Android App from Draining the Battery with Wake Locks

How to Stop Your Android App from Draining the Battery with Wake Locks


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.


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 acquire() and release(), an early return, a callback that never fires, or a reference count that drifts out of balance.


These bugs are hard to spot in code review and invisible during normal testing, because the device is usually plugged in and awake anyway.


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.


Table of Contents



Prerequisites


To follow along, you should have:


  • Basic familiarity with Android development in Kotlin or Java
  • Android Studio installed (Hedgehog or newer recommended)
  • A physical device or emulator running Android 10 (API 29) or higher
  • Working knowledge of PowerManager, background execution limits, and Android's Doze mode

What Happens When an Android Device Sleeps


When an Android device is idle, the system aggressively conserves power. Two mechanisms are at play:


  1. Display sleep โ€” the screen turns off after a period of inactivity.
  2. CPU sleep โ€” once background work finishes, the application processor enters a low-power state. Timers stop firing, network callbacks pause, and your app simply stops running.

  3. This is intentional. If every app could keep the CPU running indefinitely, standby battery life would collapse. Android's Doze mode, introduced in Android 6.0 (API 23) and refined through Android 15 and Android 16, makes this even stricter: apps in the background are deferred, batched, and often paused entirely until the device exits Doze.


    In 2026, the platform is stricter than ever. Android 15 introduced tighter foreground service timeouts, and Android 16 continues to push background work into WorkManager and JobScheduler. If you're still holding a manual wake lock for routine background tasks, you're fighting the platform.


    What a Wake Lock Does


    A wake lock is a request to the PowerManager system service to keep the device in a specific power state. The most common type, PARTIALWAKELOCK, keeps the CPU running while allowing the screen and keyboard backlight to turn off.


    Important characteristics:


    • A wake lock does not turn the screen on (unless you use a screen wake lock, which is rarely appropriate).
    • A wake lock does not guarantee your app keeps running โ€” the system can still kill your process under memory pressure.
    • A wake lock does not bypass Doze. Since Android 6.0, PARTIALWAKELOCK is ignored during Doze unless your app is exempt or running a foreground service.
    • Wake locks are reference-counted per tag, not per object. This is where most leaks originate.

    For a comprehensive list of changes, see the Android 16 behavior changes documentation.


    The Types of Wake Locks


    PowerManager exposes several wake lock levels:


    | Flag | Keeps CPU awake | Keeps screen on | Typical use |

    |---|---|---|---|

    | PARTIALWAKELOCK | โœ… | โŒ | Background processing, sync, downloads |

    | SCREENDIMWAKE_LOCK | โœ… | Dim | Rarely used; deprecated behavior |

    | SCREENBRIGHTWAKELOCK | โœ… | Bright | Deprecated; use FLAGKEEPSCREENON instead |

    | FULLWAKELOCK | โœ… | Bright | Deprecated; avoid |

    | PROXIMITYSCREENOFFWAKELOCK | โœ… | Off | Phone calls only |


    For screen-on behavior, prefer WindowManager.LayoutParams.FLAGKEEPSCREENON on a view. For background work, use PARTIALWAKE_LOCK โ€” or better, use WorkManager, which handles wake locks for you.


    How to Acquire and Release a Wake Lock


    A minimal example looks like this:


    val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
    val wakeLock = powerManager.newWakeLock(
        PowerManager.PARTIAL_WAKE_LOCK,
        "MyApp::SyncWakeLock"
    )
    
    wakeLock.acquire()
    try {
        performSync()
    } finally {
        wakeLock.release()
    }
    

    Key points:


    • Always use a unique, descriptive tag. It appears in dumpsys power output, which is essential for debugging.
    • Always release in a finally block. An exception between acquire() and release() will leak.
    • Prefer acquire(timeout) over indefinite acquisition. A timeout guarantees the lock is eventually released even if your code path fails.

    wakeLock.acquire(10 * 60 * 1000L) // 10 minutes
    try {
        performSync()
    } finally {
        if (wakeLock.isHeld) {
            wakeLock.release()
        }
    }
    

    The isHeld check prevents an RuntimeException from releasing an already-released lock under some conditions.


    How Reference Counting Works


    Wake locks are reference-counted per tag, not per WakeLock instance. If two parts of your code acquire a lock with the same tag, the count is 2. Each call to release() decrements by one, and the CPU only sleeps when the count reaches zero.


    This has two consequences:


    1. Acquiring twice with the same tag and releasing once leaves the CPU awake forever โ€” a classic leak.
    2. Releasing more times than you acquired throws a RuntimeException under PowerManager's strict mode.

    3. Because the counter is per tag, two unrelated components using the same tag can interfere with each other. Use namespaced tags like "MyApp::SyncWorker" and "MyApp::LocationUpdates" to keep ownership clear.


      Common Ways Apps Leak Wake Locks


      1. Exceptions between acquire and release


      The most common cause. Any exception thrown in the work block skips release().


      // BAD
      wakeLock.acquire()
      doRiskyWork()   // may throw
      wakeLock.release() // never runs on exception
      

      Fix: wrap in try/finally.


      2. Early returns


      A guard clause in the middle of the work block returns before release() is reached.


      // BAD
      wakeLock.acquire()
      if (cacheFresh()) return
      fetchFromNetwork()
      wakeLock.release()
      

      Fix: use use {} with an AutoCloseable wrapper, or try/finally.


      3. Callbacks that never fire


      If you acquire a lock before a network callback and the callback never fires (timeout, cancellation, process death mid-flight), the lock leaks. Always pair network-driven wake locks with a timeout.


      4. Lock held across process death (or lack thereof)


      Wake locks are tied to the process. If your process is killed, the lock is released automatically. But if you expected the work to continue after process death โ€” for example, in a BroadcastReceiver โ€” the CPU may sleep before your work completes. Use goAsync() with a timeout, or better, delegate to WorkManager.


      5. Multiple acquire calls with the same tag


      As described above, reference counting means repeated acquire() calls stack. A leak of one reference keeps the device awake forever.


      6. Static or singleton wake lock references


      A static wake lock reused across activities or services makes ownership ambiguous. When one component releases, another may still expect the lock to be held.


      Patterns That Prevent Leaks


      Wrap wake locks in an `AutoCloseable`


      Kotlin's use block guarantees release, even on exceptions:


      class ScopedWakeLock(
          context: Context,
          tag: String,
          timeoutMs: Long = 10 * 60 * 1000L
      ) : AutoCloseable {
      
          private val wakeLock = (context.getSystemService(Context.POWER_SERVICE) as PowerManager)
              .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag)
      
          init {
              wakeLock.acquire(timeoutMs)
          }
      
          override fun close() {
              if (wakeLock.isHeld) wakeLock.release()
          }
      }
      
      // Usage
      ScopedWakeLock(context, "MyApp::Sync").use {
          performSync()
      }
      

      Always set a timeout


      An indefinite wake lock is a liability. A timeout transforms a potential overnight battery drain into a bounded, recoverable condition.


      Centralize wake lock ownership


      If multiple components need wake locks, route them through a single manager that tracks ownership and enforces timeouts. This makes audits easier and prevents tag collisions.


      Monitor with `dumpsys power`


      During development, run:


      adb shell dumpsys power | grep -A 20 "Wake Locks"
      

      You'll see every active lock, its tag, and its reference count. A lock held by your app with no active work is a leak.


      When You Don't Need a Wake Lock at All


      In 2026, most use cases that historically required PARTIALWAKELOCK are better served by higher-level APIs:


      • Background sync, uploads, retries โ†’ WorkManager. It acquires and releases wake locks internally, respects Doze and battery saver, and handles process death.
      • Location updates while moving โ†’ foreground service with location type. The system keeps the CPU running while the foreground service is active.
      • Music playback, VoIP calls โ†’ foreground service with the appropriate type.
      • Long-running user-visible work โ†’ foreground service with a clear foregroundServiceType and a persistent notification.

      Manual wake locks should be reserved for narrow cases: short, low-level hardware interactions inside an already-active foreground service, or bridging a very brief gap where the CPU must not sleep between a broadcast and a scheduled job.


      If you find yourself reaching for PARTIALWAKELOCK in a modern codebase, treat it as a code smell and check whether WorkManager or a foreground service fits.


      How to Detect Wake Lock Leaks


      Three tools cover almost every case:


      1. dumpsys power โ€” shows live locks with tags and reference counts.
      2. Android Studio Profiler (Energy) โ€” visualizes wake lock acquisitions over time against app activity.
      3. Battery Historian โ€” parses a bugreport and shows wake lock duration alongside screen and CPU state. A wake lock that spans screen-off periods with no matching app work is a leak.

      4. For automated CI checks, consider lint rules that flag WakeLock.acquire() calls without a matching release() in the same scope, and unit tests that assert isHeld == false after every code path.




        Wake locks are not evil โ€” they are a precise tool for a narrow job. The problem is that they look harmless in code review and behave invisibly at runtime. Wrap them in scoped helpers, always set a timeout, prefer WorkManager and foreground services where they fit, and verify with dumpsys power before you ship. Your users' batteries โ€” and your Play Store rating โ€” will thank you.

        via FreeCodeCamp

Related