Set app memory budgets

App memory budgets let apps declare a memory budget for themselves, which tells the system to trim their memory use when the app uses more than its set budget. This is especially useful for system and bundled apps or for apps targeting memory-constrained devices, where the developer knows their expected memory working set (the memory the app actively needs for what the user is currently doing) and wants to ensure their app doesn't use too much of the system's shared RAM resources.

The budget is kept balanced by using memory eviction and swap to remove memory pages that weren't recently used, focusing the app's memory footprint on its current working set. When an app exceeds its declared budget, the operating system targets reclaim specifically at that app:

  1. Clean file-backed pages (such as inactive code and mapped assets) are evicted first because they can be re-read from storage if needed.
  2. Dirty file-backed pages are written back to storage and evicted.
  3. Anonymous memory pages (such as heap allocations) are compressed and swapped to zRAM.

As long as the working set doesn't exceed the budget, the app will work well while using no more memory than is in its set budget. The operating system evicts unused memory and compresses inactive heap pages to swap so that memory allocations remain bounded without terminating the process.

What happens when an app exceeds its budget

Exceeding a memory budget doesn't cause the system to terminate or crash your app. Instead, when an app uses more memory than its budget allows, the OS finds memory that the app hasn't used in some time and safely puts it away until it's needed again. This makes way for new app memory allocations and keeps the overall app's memory use within budget.

As long as there is headroom between what the app needs at any given time (its working set) and the set budget, the app continues to run normally. If an app sets a budget that is smaller than its working set, the app's performance at runtime can slow down as a result.

To learn how the OS tracks and reclaims memory, see the memory architecture guide:

Declare budgets in the Android manifest

Declaring your memory budgets in your AndroidManifest.xml is the primary and recommended method for defining budgets. It requires no runtime code, takes effect immediately upon process startup, and provides a clear contract for the operating system.

<memory-budget> declarations take effect on devices running Android 17 QPR2 (API level 37.2) and higher. On lower Android versions, the platform manifest parser safely ignores unrecognized XML elements, so you can adopt <memory-budget> without affecting backward compatibility.

Declare a baseline budget

For most apps, defining a single budget for the application is all that is needed. Declare a <memory-budget> element directly inside the <application> tag:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.simpleapp">

    <application
        android:label="@string/app_name">

        <!-- Baseline budget for the application -->
        <memory-budget android:maxMb="256" />

    </application>
</manifest>

This sets a 256MB resident memory budget across all processes and states for the package. When the app's memory footprint exceeds 256MB, the operating system trims inactive memory pages using eviction and swap.

Vary budgets by process state

An app requires different amounts of memory depending on its user visibility:

  • Foreground: The process is hosting a visible Activity interacting with the user. This state typically has the largest footprint due to active UI and graphics.
  • Perceptible: The process is perceptible to the user but doesn't host a visible window (for example, hosting a media playback foreground service, an active background download, turn-by-turn navigation, or an active input method).
  • Background: The process is running background jobs, receivers, or data syncs. It is expected to maintain a minimal footprint.

You can declare multiple <memory-budget> clauses to match these states:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.simpleapp">

    <application
        android:label="@string/app_name">

        <!-- Default budget for visible foreground UI -->
        <memory-budget android:maxMb="200" />

        <!-- Tighter budget when playing audio in background -->
        <memory-budget
            android:maxMb="120"
            android:state="perceptible" />

        <!-- Minimal budget when fully in background -->
        <memory-budget
            android:maxMb="48"
            android:state="background" />

    </application>
</manifest>

A base clause without android:state isn't required; if you only specify state-specific clauses (for example, android:state="background"), other states remain unconstrained by an app budget. When included, a clause without android:state acts as the default fallback for unspecified states (such as foreground), which subsequent more restrictive clauses override when the app transitions into perceptible or background states.

When sizing budgets for each state, target the active working set of that state rather than the peak resident memory (RSS high-water mark):

  • Size foreground budgets for smooth frame rendering: Give foreground UI enough headroom for its active working set so synchronous reclaim doesn't delay frame rendering. If your app includes a memory-intensive standalone workflow (such as a high-resolution image or video editor), consider moving that activity into a dedicated process (see Multi-process apps) so its allocations are budgeted independently and torn down when the user exits.
  • Size background budgets tightly for headless work: High background RSS in field telemetry often reflects UI allocations retained after leaving the foreground (see Co-locating a bound service with a memory-intensive UI and Release memory in response to events) or one-time file reads during background indexing and syncs. Because headless background work has no frame deadlines, evicting one-time file cache pages and swapping inactive heap pages to zRAM when a background sync briefly allocates beyond its budget causes no user-visible jank while preventing background spikes from evicting the user's foreground app.

For a step-by-step workflow, see Measure your working set and calibrate budgets.

How budget states map to process states

The platform maps manifest budget states to runtime process states based on RunningAppProcessInfo.importance, which Activity Manager computes from both the process's own active components and any incoming service bindings or content provider queries from other apps:

  • foreground: Active, visible user interactions, such as hosting a resumed Activity, maintaining the top app state when the screen turns off, or hosting a service or content provider actively bound or queried by the top foreground app.
  • perceptible: Workloads that are perceptible to the user without a visible window, such as active media playback, turn-by-turn navigation, camera or microphone capture, active background downloads, data sync foreground services, or services bound by a foreground client using flags such as BIND_NOT_FOREGROUND (see Controlling inheritance with BIND flags). While internal platform metrics might label some of these workloads PROCESS_STATE_IMPORTANT_FOREGROUND, that internal constant doesn't signify a visible window and is governed by the perceptible budget.
  • background: Work that isn't immediately perceptible to the user, such as background jobs, alarms, and broadcast receivers.

The following table shows how runtime importance levels map to manifest states:

Manifest android:state Runtime importance (RunningAppProcessInfo) Typical components
foreground IMPORTANCE_FOREGROUND
IMPORTANCE_TOP_SLEEPING
Resumed visible Activity, top app with screen locked, service or content provider bound by the top app
perceptible IMPORTANCE_FOREGROUND_SERVICE
IMPORTANCE_VISIBLE
Active media playback, navigation, downloads, or sync foreground services; service bound with foreground-service or visible flags
background IMPORTANCE_PERCEPTIBLE
IMPORTANCE_CANT_SAVE_STATE
IMPORTANCE_SERVICE
Background jobs, receivers, background sync

For details on how client bindings elevate a service or provider host process and how the process transitions back to background or cached after the client unbinds or moves off-screen, see Service bindings and process states.

When a process enters a cached state (IMPORTANCE_CACHED) with no active components, the platform releases its active memory budget and manages its memory through standard cached-process reclamation.

Inspect your app's process state and budget

One way to inspect your app's active process state and importance during development is by querying Activity Manager using ADB:

adb shell dumpsys activity processes <package-name>

The following abbreviated example shows the process record and OOM control entries for an app running a foreground service:

ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)
  All known processes:
  *APP* UID 10123 ProcessRecord{edf056c 3919:com.example.app/u0a123}
    pid=3919
    oom adj: max=1001 curRaw=200 setRaw=200 cur=200 set=200
    curProcState=4 mRepProcState=4 setProcState=4 lastStateTime=-42s360ms
    hasStartedServices=true
    mHasForegroundServices=true forcingToImportant=null
...
  Process OOM control (48 total):
    Proc #22: prcp  F/S/FGS  ---NFU-TI  t: 0 3919:com.example.app/u0a123 (fg-service)
        oom: max=1001 curRaw=200 setRaw=200 cur=200 set=200
        state: cur=FGS  set=FGS  lastRss=0.00 lastCachedRss=0.00

In this output:

  • curProcState=4 and state: cur=FGS reflect that the process is in the foreground service state. If your app is hosting an active visible activity, this appears as TOP. If it is running an important foreground component (such as a download or sync), it appears as IMPF.
  • In the Process OOM control table, prcp indicates that the process is evaluated under the perceptible priority tier, which corresponds to the perceptible budget.

To inspect your app's current resident memory usage:

adb shell dumpsys meminfo <package-name>

Multi-process apps

If your application divides its work across multiple processes, configure dedicated process budgets using the <process> tag inside <processes>.

For example, consider a streaming music app (com.example.radio):

  1. Main process: Hosts the visible UI and the audio playback engine (MediaSessionService with a mediaPlayback foreground service). When visible, the process operates under the 180MB foreground budget. When the user leaves the app while music continues playing, the process enters the perceptible state, where a 64MB budget is sufficient for the playback engine and audio buffer.
  2. Sync process (:sync): Dedicated process running background metadata synchronization and download indexing. Because this process is only ever active in the background, you don't need to explicitly declare state="background"; a single budget applies.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.radio">

    <application
        android:label="@string/app_name">

        <!-- Package baseline: main process with UI and audio playback -->
        <memory-budget android:maxMb="180" />

        <!-- Tighter budget when audio plays in the background -->
        <memory-budget
            android:maxMb="64"
            android:state="perceptible" />

        <processes>
            <!-- Dedicated background sync process -->
            <process android:process=":sync">
                <memory-budget android:maxMb="32" />
            </process>
        </processes>

        <service
            android:name=".playback.AudioPlayerService"
            android:foregroundServiceType="mediaPlayback"
            android:exported="false" />

        <service
            android:name=".sync.PlaylistSyncService"
            android:process=":sync"
            android:exported="false" />

    </application>
</manifest>

When you declare a <processes> block in your manifest, list <process> entries only for the processes that declare a <memory-budget>. If you want to budget only specific subprocesses while leaving the rest of the app at the system limit for the device, you can omit the <memory-budget> elements directly under <application>.

Memory use in any subprocess counts against both its process budget and the enclosing package budget. A process experiences memory pressure at runtime if it breaches either its process budget or the package budget, whichever threshold is reached first.

Scale budgets for high-density displays

For applications whose memory footprint scales significantly with how many pixels there are to draw on the display at once—such as a photo gallery app caching bitmaps sized to the screen—Android provides two alternative mechanisms to scale budgets dynamically with display specifications:

  • Scale by display density bucket (android:additionalMbPerDensity): Adds megabytes proportionally to the square of the display's density ratio relative to mdpi (1.0x / 160 dpi), because 2D raster drawables and UI assets scale across both width and height:

    <!-- Baseline 180MB + 16MB multiplied by (density ratio)^2 -->
    <memory-budget
        android:maxMb="180"
        android:additionalMbPerDensity="16" />
    

    On an mdpi display (1.0x), the budget is 180 + 16 × 12 = 196 MB. On an xxhdpi display (3.0x), the budget scales to 180 + 16 × 32 = 324 MB.

  • Scale by physical display resolution (android:additionalBytesPerDisplayPixel): Adds bytes directly per physical display pixel (Width × Height). This is ideal for applications allocating full-screen graphics surfaces, render buffers, or full-resolution photo caches where memory consumption scales directly with raw display pixel count rather than UI density buckets:

    <!-- Baseline 128MB + 16 bytes per physical display pixel -->
    <!-- For example, a 4-byte RGBA full-screen buffer with double or quadruple buffering -->
    <memory-budget
        android:maxMb="128"
        android:additionalBytesPerDisplayPixel="16" />
    

    On a 1080p display (1080 × 2400 ≈ 2.59M pixels), this adds ≈ 41.4 MB to the baseline budget. On a 1440p display (1440 × 3120 ≈ 4.49M pixels), it adds ≈ 71.8 MB.

These two attributes are alternatives. Choose the attribute that matches your app's primary scaling factor, and avoid combining both in the same clause.

Specialize for device form factors

When shipping an APK across phones, tablets, and Wear OS, use the android:feature attribute to adjust budgets for different hardware targets.

On Wear OS watches, RAM is constrained, and the app's UI and feature set is much simpler. You can declare a tighter budget specialized for the watch feature:

<!-- General phone and tablet baseline -->
<memory-budget android:maxMb="180" />

<!-- Wear OS override: simpler UI and constrained hardware -->
<memory-budget
    android:maxMb="48"
    android:feature="watch" />

Resolution rule: last applicable clause takes effect

When defining multiple <memory-budget> elements for an application or process, the system evaluates them in the order they are declared in the manifest. The last applicable budget clause is the one that is enforced.

Because the last applicable budget wins, ordering matters. Always place the most general baseline budget first, followed by more specific overrides (such as state-specific or hardware-specific clauses).

XML attribute reference

All memory size attributes are expressed in megabytes (MB) and map to Linux cgroup memory.current charge (which excludes shared memory like the Zygote).

Attribute Format Default Description
android:maxMb Integer (> 0) Required The baseline resident memory budget limit in MB.
android:state Enum Any The process state this budget applies to: foreground, perceptible, or background. If omitted, the clause acts as a fallback for any unspecified state.
android:additionalMbPerDensity Integer (≥ 0) 0 Additional megabytes multiplied by the square of the display density ratio relative to mdpi (1.0x).
android:additionalBytesPerDisplayPixel Integer (≥ 0) 0 Additional bytes allocated per physical display pixel (Width × Height), useful for surface buffers and bitmaps.
android:feature String Any Restricts the clause to devices declaring specific hardware features: watch, automotive, or leanback.

Measure your working set and calibrate budgets

A good budget sits just above your app's working set for a given state, not at its unconstrained peak. To find that point, run a realistic user flow, lower the budget step by step, and watch for the moment your app starts paying for it.

Understand working set versus resident memory

Without a budget, your app's resident footprint (memory.current) holds two kinds of memory:

  • The working set: the Java, Kotlin, and native heap and the hot file-backed pages (DEX code, native libraries, and assets) that the current user flow touches over and over
  • Cold slack: bitmap caches from earlier screens, one-time startup buffers, heap slack left over after an allocation burst, and file pages that were read once and never touched again

A budget at your unconstrained peak never reclaims the cold slack. A budget below your working set makes the kernel swap out heap pages and evict code pages that your app immediately faults back in. These working-set refaults cost CPU time and battery, and once they land on the UI thread, they cost frames too.

What you're looking for is the knee: the lowest budget where reclaim removes only cold slack, so refaults, CPU time, and frame times stay where they were without a budget.

Prepare your test environment

Set up a repeatable test that behaves like production:

  • Calibrate the same R8-optimized, non-debuggable release build that you ship. Debug builds use more memory, so a budget tuned on a debug build is too high for release.
  • Test on a device or emulator running Android 17 QPR2 (API level 37.2) or higher. A userdebug build or an emulator image without Google Play is best, because some devices only let you read the cgroup files in the following sections after you run adb root.
  • Script a 30 to 60 second user flow with Jetpack Macrobenchmark, UI Automator, or adb shell input, once for each state that you plan to budget. An idle app tells you nothing about its working set.
    • foreground: scroll dense lists and media grids, move between your main destinations, open menus and dialogs, and run animations
    • perceptible: leave the visible window while perceptible work continues (for example, audio playback or a download), let it run for 10 to 15 seconds, and then return to the app
    • background: trigger a sync, a WorkManager job (for example, with adb shell cmd jobscheduler run -f <package-name> <job-id>), a started service, or a broadcast receiver while the app has no visible UI

Measure a background budget while the background work is running. Once the process becomes cached, the platform releases its budget (see How budget states map to process states), so an idle cached process doesn't tell you anything about your background budget.

Measure your baseline

Start with runs that have no budget at all, so you know what normal looks like. Use a build without <memory-budget> declarations, and start each run from a fresh process (adb shell am force-stop <package-name>).

Each app process has its own cgroup v2 memory controller. Look up its path from the 0:: line of /proc/<PID>/cgroup instead of building the path yourself, because the cgroup layout can differ between devices. Pass each device command to adb shell as a single quoted string so that the device shell sees the quotes:

PID=$(adb shell pidof -s <package-name>)
CG=/sys/fs/cgroup$(adb shell "sed -n 's/^0:://p' /proc/$PID/cgroup")
adb shell "cat $CG/memory.current $CG/memory.swap.current"
adb shell "grep -E '^(anon|file|workingset_refault_.*) ' $CG/memory.stat"

Reading these files is cheap and doesn't disturb your app, so you can sample them every second during a run:

  • memory.current: resident memory charged to the process, in bytes. This is the number that <memory-budget> limits.
  • memory.swap.current: memory compressed into zRAM swap, in bytes
  • anon and file in memory.stat: resident anonymous and file-backed memory, in bytes
  • workingset_refault_anon and workingset_refault_file in memory.stat: running counts of swapped-out or evicted pages that your app needed again. These counters are your main sign that a budget is too tight.

If you get a permission error, run adb root and try again. If you can't get root, adb shell dumpsys meminfo <package-name> gives you a coarser view. In App Summary, Java Heap, Native Heap, and Stack are mostly anonymous memory, and Code is file-backed. Software bitmaps count toward Native Heap and your budget, while hardware bitmaps and Graphics don't. Don't run dumpsys meminfo in a loop while you measure frame timing. It forces a garbage collection in your app and walks all of its memory mappings, which changes what you're trying to measure.

Sweep candidate budgets

You can try candidate budgets without rebuilding your app. Use am memory-limiter manual to set a budget for a running process, and none to remove it:

adb shell am memory-limiter manual <PID> 120MB
adb shell am memory-limiter manual <PID> none

A manual override lasts for the lifetime of that process instance, across process state changes, so clear it or restart the process before you move on. It also sets the process's hard limit (memory.max) to the same value, so a candidate budget below your app's working set can get the process killed instead of just slowing it down. Treat that as a failed budget.

You can also sweep from a benchmark build by reading a candidate value from an instrumentation argument and setting MemoryBudgetManager.processBudgetBytes. A runtime budget can only tighten a manifest budget, so sweep on a build without <memory-budget> declarations. It also stays in force across process state changes until you call clearProcessBudget.

Start at the highest memory.current from your baseline and step down 10 to 15 MB at a time. For each candidate budget:

  1. Start a fresh process, drive it into the target state, and apply the budget.
  2. Run the user flow once as a warm-up. Applying a budget evicts cold startup pages once, and you want to measure steady state rather than that transition.
  3. Reset frame stats, and record the starting refault counters and the process CPU time (utime and stime, in clock ticks):

    adb shell dumpsys gfxinfo <package-name> reset
    adb shell "grep -E '^workingset_refault_(anon|file) ' $CG/memory.stat"
    adb shell "cut -d ' ' -f 14,15 /proc/$PID/stat"
  4. Run the measured pass of your user flow.

  5. Record memory.current, anon, file, the refault counters, and CPU time again, and collect frame stats. dumpsys gfxinfo can print the same stats more than once, so take the first match, which covers the whole app:

    adb shell "dumpsys gfxinfo <package-name>" | grep -m 1 'Janky frames:'
    adb shell "dumpsys gfxinfo <package-name>" | grep -m 1 '95th percentile'

Repeat each candidate at least three times, and interleave candidates rather than running all repetitions of one candidate back to back. Compare each result with the spread of your baseline runs, not with a single run.

Read the results

As the budget drops, your app goes through three regimes:

  • Above the knee: memory.current follows the budget down by shedding cold file pages and idle heap. anon stays flat, refaults stay near baseline, and CPU time and frame times don't move. This is where you want to be.
  • Below the knee: The cold pages are gone, so the kernel starts swapping out heap pages that your app still uses. Refaults jump by hundreds or thousands of pages per run and CPU time goes up. Frame times might barely move on an otherwise idle test device, but your app now spends CPU time and battery compressing and decompressing its own working set.
  • Well below the knee: Reclaim and swap-in faults land on the UI thread and RenderThread, and jank, frame times, and warm resume times all go up.

Compared with your unconstrained baseline, a candidate budget passes if it meets all of the following, which are a reasonable starting point:

  • The process survives the run
  • Refault deltas stay close to baseline
  • Process CPU time rises by no more than 5%
  • Janky frames rise by no more than 1 percentage point
  • P95 frame time rises by no more than 3 ms
  • Warm resume time (TotalTime from adb shell am start -W) rises by no more than 15%, for perceptible and background budgets

The knee is the lowest candidate budget that passes. For example, here's a foreground sweep for a hypothetical app with a 138 MB unconstrained peak:

Candidate budget memory.current anon file Refaults (pages) P95 frame time Regime
None 138 MB 75 MB 63 MB 0 10.5 ms Baseline
125 MB 121 MB 74 MB 47 MB 10 10.5 ms Above the knee
110 MB 110 MB 75 MB 35 MB 15 10.8 ms Knee
95 MB 95 MB 74 MB 21 MB 176 11.2 ms Below the knee
85 MB 85 MB 68 MB 17 MB 2,050 12.8 ms Below the knee
70 MB 70 MB 54 MB 16 MB 8,771 16.5 ms Well below the knee

Pick your budget

Set android:maxMb 10% to 20% above the knee, so that differences in user data, locales, and allocation bursts don't push your app below it. In the preceding example, the knee is at 110 MB, so 125 MB is a good choice.

Then install a release build with the new <memory-budget> declarations, run your user flow again, and check that the pass criteria still hold. dumpsys meminfo shows the active budget and its source in its Memory Budget section.

Validate non-foreground budgets with a warm return

A tight perceptible or background budget can look fine while your app is in that state and then cost you when the user comes back. Test the return trip too:

  1. Run the foreground flow, move the app into the target state, and apply the candidate budget with am memory-limiter manual.
  2. Let the perceptible or background work run for 10 to 15 seconds.
  3. Clear the override (adb shell am memory-limiter manual <PID> none), then immediately bring the app back with adb shell am start -W and record TotalTime. If you set the candidate through MemoryBudgetManager instead, call clearProcessBudget before the app returns.
  4. Run a short foreground interaction and check refaults and frame stats against the pass criteria.

Pick the lowest budget that passes both while the app is in that state and on the warm return, and add the same 10% to 20% headroom.

Calibrate display scaling

If you use android:additionalBytesPerDisplayPixel, find the foreground knee on two displays with different resolutions, such as 1080 × 2400 (≈ 2.59M pixels) and 1440 × 3120 (≈ 4.49M pixels). Divide the difference between the two knees, in bytes, by the difference in pixel count to get android:additionalBytesPerDisplayPixel. Subtract that per-pixel amount for the smaller display from its knee to get the resolution-independent base, and add headroom to get android:maxMb.

Runtime APIs (secondary dynamic option)

Declaring budgets statically in AndroidManifest.xml is the preferred solution for almost all apps. However, for applications with dynamic workloads or for runtime experimentation, Android provides runtime SDK and NDK APIs as a secondary option.

The runtime API lets you:

  • Query current memory usage and effective budgets.
  • Dynamically adjust your process budget downward.
  • Listen for over-budget events to proactively trim caches before the operating system triggers direct reclaim.

Android SDK API (MemoryBudgetManager)

The MemoryBudgetManager system service is available to apps written in Kotlin and Java starting in Android 17 QPR2 (minor SDK release, API level 37.2 / Build.VERSION_CODES_FULL.CINNAMON_BUN_2).

Retrieve the service

Before you access MemoryBudgetManager, check that the device runs Android 17 QPR2 or higher by checking SDK_INT_FULL. If your app's minSdk is lower than 36, check SDK_INT first so devices running Android 15 (API level 35) or lower don't throw NoSuchFieldError when reading SDK_INT_FULL:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA &&
    Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.CINNAMON_BUN_2
) {
    val budgetManager = context.getSystemService(MemoryBudgetManager::class.java)
}

Query usage and budgets

// Query current memory charged to this process and the package UID
val processUsageBytes = budgetManager.processCurrentUsageBytes
val packageUsageBytes = budgetManager.packageCurrentUsageBytes

// Query effective budgets (returns LIMIT_IS_DISABLED if unconstrained)
val processBudgetBytes = budgetManager.processBudgetBytes
val packageBudgetBytes = budgetManager.packageBudgetBytes

Dynamically set or clear budgets

You can set a tighter budget at runtime to constrain memory during lightweight tasks, or clear it when the task finishes:

// Set a tighter dynamic budget on the current process (e.g., 96 MB)
try {
    budgetManager.processBudgetBytes = 96L * 1024L * 1024L
} catch (e: IllegalArgumentException) {
    // Thrown if the budget is <= 0 or exceeds the manifest ceiling or system limit
    Log.e(TAG, "Requested budget exceeds manifest or system ceiling", e)
}

// Clear the dynamic process budget to restore the manifest limit (or unconstrained baseline)
budgetManager.clearProcessBudget()

Unlike <memory-budget> manifest clauses, which automatically switch budgets whenever the process transitions between foreground, perceptible, and background states, a dynamic budget set through MemoryBudgetManager applies a single ceiling until your code updates or clears it. If you use the runtime API to run field experiments or manage state-specific limits, update or clear the dynamic budget across lifecycle transitions (for example, using ProcessLifecycleOwner or activity lifecycle callbacks, as shown in the Adaptive image editor example).

Performance and idempotency

When working with MemoryBudgetManager at runtime, keep the following characteristics in mind:

  • Idempotency: All budget mutation APIs are idempotent. Calling clearProcessBudget() or clearPackageBudget() repeatedly or when no dynamic budget is active is a safe no-op. Similarly, setting setProcessBudgetBytes() or setPackageBudgetBytes() to the same value consecutively doesn't trigger redundant system reconfigurations.
  • Calling frequency: Setting, updating, or clearing a budget makes a cross-process call to the system service to update the operating system memory controller for the process. While lightweight, it isn't completely free. Call these APIs during discrete lifecycle transitions and task milestones (such as entering or leaving an activity, starting or completing background processing, or handling speech requests), and avoid calling them in tight loops, audio processing threads, or per-frame render routines.
  • Query frequency: The query properties processCurrentUsageBytes and packageCurrentUsageBytes fetch live memory usage from the system service. While fast, queries should be invoked on demand (such as during task transitions or inside OnOverBudgetListener callbacks) rather than polled in continuous loops.

Listen for over-budget pressure callbacks

Apps can register a listener to be notified when memory usage exceeds the budget threshold. This allows the app to perform proactive app-level cleanup (such as clearing in-memory bitmap caches) while the operating system reclaims memory to keep the process or package within its budget:

val cleanupThread = HandlerThread("MemoryBudgetCleanup").apply { start() }

val listener = MemoryBudgetManager.OnOverBudgetListener { budgetBytes ->
    Log.w(TAG, "Process exceeded memory budget of $budgetBytes bytes")
    // Proactively evict thread-safe caches on the background Looper
    imageTileCache.evictAll()
}

// Register on a background Looper before tightening a dynamic budget
budgetManager.registerProcessOverBudgetListener(cleanupThread.looper, listener)

// When done (for example, in onStop)
budgetManager.unregisterProcessOverBudgetListener(listener)

Best practices for over-budget callbacks:

  • Use a background Looper: Pass a background HandlerThread Looper to registerProcessOverBudgetListener or registerPackageOverBudgetListener whenever possible. Running cache eviction or file cleanup on a background thread avoids contending with frame rendering on the UI thread while the process is under memory pressure. Reserve Looper.getMainLooper for listeners that only touch main-thread-bound UI state.
  • Expect repeated callbacks: Over-budget callbacks aren't one-shot notifications. When usage exceeds the budget, the operating system reclaims cold pages first, and if pressure continues, the system keeps reclaiming memory and invoking your listener at most once every second. Make your callback idempotent. If your app only needs a single notification or wants a longer cooldown between cleanup passes, call unregisterProcessOverBudgetListener (or unregisterPackageOverBudgetListener) inside the callback, or guard your cleanup with a cooldown timestamp or AtomicBoolean.
  • Register before tightening a budget: If current memory usage already exceeds a new budget when you call setProcessBudgetBytes or setPackageBudgetBytes, an over-budget event is triggered as soon as the new budget is applied. Register your listener before setting the budget so your app doesn't miss that initial over-budget notification.
  • Be quick: Reclaim operations must offer immediate relief. Complex computations during pressure worsen performance.
  • Avoid allocations: Don't allocate new objects or start new threads inside the callback, as doing so can trigger immediate operating system direct reclaim.
  • Focus on high-yield targets: Evicting large Bitmaps, render buffers, or closing memory-mapped files is much more effective than releasing many small objects.

Native NDK API (<android/memory_budget_manager.h>)

Native apps can use the C NDK API exposed by libandroid.so starting in Android 17 QPR2 (API level 37.2).

CMake configuration

find_library(android-lib android)
target_link_libraries(my_native_engine PRIVATE ${android-lib})

Include header and query usage

#include <android/memory_budget_manager.h>

// Query current memory usage
int64_t process_usage = AMemoryBudgetManager_getProcessCurrentUsageBytes();
int64_t package_usage = AMemoryBudgetManager_getPackageCurrentUsageBytes();

// Query current budget
int64_t process_budget = 0;
AMemoryBudgetResult result = AMemoryBudgetManager_getProcessBudget(&process_budget);
if (result == AMEMORY_BUDGET_RESULT_SUCCESS) {
    // Current budget available in process_budget
} else if (result == AMEMORY_BUDGET_RESULT_LIMIT_IS_DISABLED) {
    // No budget is currently active
}

Dynamically configure native budget

// Set a tighter process budget (e.g. 160MB)
AMemoryBudgetResult result = AMemoryBudgetManager_setProcessBudget(160LL * 1024 * 1024);
if (result != AMEMORY_BUDGET_RESULT_SUCCESS) {
    const char* error_msg = AMemoryBudgetManager_resultToString(result);
    // Handle error (e.g. AMEMORY_BUDGET_RESULT_ERROR_EXCEEDS_MANIFEST_LIMIT)
}

// Clear the dynamic budget to resume manifest limits (or unconstrained baseline)
AMemoryBudgetManager_clearProcessBudget();

Monitor memory pressure events

The NDK provides two ways to monitor memory events:

  1. High-Level Watcher (AMemoryBudgetManager_Watcher_create): Monitors events on an ALooper with automatic debouncing.
  2. Low-Level File Descriptor: AMemoryBudgetManager_getProcessMemoryPressureFd returns a native file descriptor that can be integrated directly into a custom epoll engine loop.
void onMemoryPressure(int32_t event_mask, const AMemoryBudgetEvents* events, void* userdata) {
    // High-yield eviction of unused native textures or geometry caches
    purgeNativeTextureCaches();
}

// Register watcher on a background worker ALooper with a 1000ms debounce interval
AMemoryBudgetManagerWatcher* watcher = AMemoryBudgetManager_Watcher_create(
    background_looper,
    AMEMORY_BUDGET_MANAGER_EVENT_PROCESS,
    1000 /* debounce_ms */,
    &onMemoryPressure,
    NULL /* userdata */
);

// When done:
AMemoryBudgetManager_Watcher_destroy(watcher);

Bind the watcher to a background worker thread's ALooper, rather than your main UI or render thread, so that cache eviction doesn't contend with frame rendering. Create the watcher before tightening a budget with AMemoryBudgetManager_setProcessBudget or AMemoryBudgetManager_setPackageBudget so your app doesn't miss the initial over-budget event. While the process or package remains over budget and the operating system continues reclaiming memory, the watcher invokes the callback repeatedly (at most once per debounce_ms interval). Make your callback idempotent, or call AMemoryBudgetManager_Watcher_destroy (which is safe to call from inside the callback) if your app only needs a one-shot notification.

Runtime API examples

The following examples demonstrate how to implement the runtime APIs using the Android SDK API (written in Kotlin) and the Native NDK API (written in C++).

Android SDK example: Adaptive image editor

This example shows an image editing app (com.example.imageeditor) whose manifest declares a 256MB ceiling to accommodate its multi-layer canvas:

<manifest ... >
    <application ... >
        <!-- Manifest ceiling accommodates the heaviest editing workload -->
        <memory-budget android:maxMb="256" />
    </application>
</manifest>

When the user is browsing the lightweight thumbnail gallery, the app uses the Android SDK API in Kotlin to dynamically tighten its process budget to 96MB. When the user opens the multi-layer editing canvas, the app clears the dynamic budget to restore the full 256MB manifest ceiling. It also registers an OnOverBudgetListener on a background HandlerThread Looper before applying the gallery budget to evict cached preview bitmaps under pressure without blocking the UI thread.

package com.example.imageeditor.ui

import android.app.Activity
import android.app.MemoryBudgetManager
import android.graphics.Bitmap
import android.os.Bundle
import android.os.HandlerThread
import android.util.Log
import android.util.LruCache

class ImageEditorActivity : Activity() {

    private lateinit var budgetManager: MemoryBudgetManager
    private lateinit var cleanupThread: HandlerThread

    // Thread-safe in-memory cache for rendered preview tiles (32MB limit)
    private val previewCache = object : LruCache<String, Bitmap>(32 * 1024 * 1024) {
        override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
    }

    private val overBudgetListener = MemoryBudgetManager.OnOverBudgetListener { budgetBytes ->
        Log.w(TAG, "Process memory pressure detected (budget: ${budgetBytes / 1048576}MB). Evicting preview cache.")
        previewCache.evictAll()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        budgetManager = getSystemService(MemoryBudgetManager::class.java)
        cleanupThread = HandlerThread("MemoryBudgetCleanup").apply { start() }
    }

    override fun onStart() {
        super.onStart()
        // Register listener on a background Looper before tightening the budget
        budgetManager.registerProcessOverBudgetListener(cleanupThread.looper, overBudgetListener)
        // Constrain memory during lightweight gallery browsing
        applyGalleryBudget()
    }

    override fun onStop() {
        super.onStop()
        budgetManager.unregisterProcessOverBudgetListener(overBudgetListener)
    }

    override fun onDestroy() {
        cleanupThread.quitSafely()
        super.onDestroy()
    }

    /**
     * Called when the user enters the high-resolution editing canvas.
     * Clears the dynamic budget, restoring the full 256MB manifest ceiling.
     */
    fun enterEditingCanvas() {
        // Clear the tighter dynamic budget to restore the full manifest ceiling (256MB)
        budgetManager.clearProcessBudget()
        Log.i(TAG, "Restored manifest budget ceiling (256MB) for editing canvas")
    }

    /**
     * Called when the user exits the editor back to the thumbnail gallery.
     * Re-applies the tighter dynamic budget.
     */
    fun exitToGallery() {
        previewCache.trimToSize(8 * 1024 * 1024)
        applyGalleryBudget()
    }

    private fun applyGalleryBudget() {
        try {
            // Dynamically tighten budget to 96MB for the lightweight gallery view
            budgetManager.processBudgetBytes = 96L * 1024L * 1024L
            Log.i(TAG, "Tighter dynamic budget applied for gallery: 96MB")
        } catch (e: IllegalArgumentException) {
            Log.e(TAG, "Could not apply dynamic budget", e)
        }
    }

    companion object {
        private const val TAG = "ImageEditor"
    }
}

NDK C++ example: Native 3D engine

This example shows a native C++ game engine managing memory budgets based on the active graphics quality level, assuming the application's manifest declares a 512MB ceiling to accommodate High quality graphics (android:maxMb="512"). The engine starts AMemoryBudgetManager_Watcher_create on a background worker thread's ALooper before tightening the budget for lower quality presets, and unloads texture mipmaps when over budget.

#include <android/memory_budget_manager.h>
#include <android/looper.h>
#include <android/log.h>

#define LOG_TAG "Native3DEngineMemory"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)

class MemoryGovernor {
public:
    MemoryGovernor() : mWatcher(nullptr) {}

    ~MemoryGovernor() {
        stopMonitoring();
    }

    // Configures process budget based on user graphics quality settings
    bool setQualityBudget(int qualityLevel) {
        int64_t targetBytes = 0;
        switch (qualityLevel) {
            case 0: // Low (budget: 128MB)
                targetBytes = 128LL * 1024 * 1024;
                break;
            case 1: // Medium (budget: 256MB)
                targetBytes = 256LL * 1024 * 1024;
                break;
            case 2: // High (budget: 512MB)
                targetBytes = 512LL * 1024 * 1024;
                break;
            default:
                // Clear dynamic override and restore manifest limit
                AMemoryBudgetManager_clearProcessBudget();
                return true;
        }

        AMemoryBudgetResult result = AMemoryBudgetManager_setProcessBudget(targetBytes);
        if (result != AMEMORY_BUDGET_RESULT_SUCCESS) {
            LOGW("Could not set quality budget: %s", AMemoryBudgetManager_resultToString(result));
            return false;
        }
        return true;
    }

    // Call on a background worker ALooper before tightening the budget with setQualityBudget
    bool startMonitoring(ALooper* backgroundLooper) {
        if (!backgroundLooper) return false;

        // Monitor process budget events, debounced to at most once every 1000ms
        mWatcher = AMemoryBudgetManager_Watcher_create(
            backgroundLooper,
            AMEMORY_BUDGET_MANAGER_EVENT_PROCESS,
            1000,
            &MemoryGovernor::onPressureEvent,
            this
        );
        return mWatcher != nullptr;
    }

    void stopMonitoring() {
        if (mWatcher) {
            AMemoryBudgetManager_Watcher_destroy(mWatcher);
            mWatcher = nullptr;
        }
    }

    void unloadUnusedTextures() {
        LOGW("Memory pressure callback triggered. Purging cached texture mipmaps...");
        // Fast, idempotent, high-yield eviction without allocating memory
    }

private:
    static void onPressureEvent(
        int32_t event_mask,
        const AMemoryBudgetEvents* events,
        void* userdata
    ) {
        auto* governor = static_cast<MemoryGovernor*>(userdata);
        governor->unloadUnusedTextures();
    }

    AMemoryBudgetManagerWatcher* mWatcher;
};