Profiling 10 min read

ProfilingManager: Production Profiling in Android Without ADB or Dev Options

For most of my Android career, profiling had a dirty secret: the devices that matter are never the ones you can profile. You can instrument your Pixel dev device all day with Perfetto, Android Studio Profiler, or simpleperf. What you can't do is attach anything meaningful to a mid-range device running on a user's crammed home screen, in a country where latency is twice what you measured in the office, in the exact memory-pressure situation that triggered the jank report.

Firebase Performance gives you coarse timing buckets. Custom instrumentation gets you the hot paths you already know to look at. What's always been missing is the thing you actually need for root cause analysis: a real CPU or heap trace from a real device at the exact moment something went wrong. Android 15 changed that with ProfilingManager. Android 16 took it further by making the hardest flows — cold starts and ANRs — automatic.

Android 15
ProfilingManager introduced — heap dumps, profiles, and stack sampling on production devices
Perfetto
Industry-standard trace format, opens in ui.perfetto.dev with zero additional tooling
Android 16
System-triggered: cold start, reportFullyDrawn(), and ANR traces fire automatically

What ProfilingManager Actually Is

android.os.ProfilingManager is a system service introduced in Android 15 that lets your app request Perfetto profiling data from within its own process — on a production device, with no developer options enabled, and no ADB required. The output goes to your app's files directory via a callback, and the API rate-limits itself to minimize the impact on the running app.

The core entry point is requestProfiling(Bundle, Executor, Consumer): pass a Bundle that specifies the profiling type and any applicable options, the executor on which the result should be delivered, and a Consumer callback that receives the profiling result when the trace is complete. Google's recommendation is to use the corresponding AndroidX wrapper — androidx.core.os.Profiling available in Core 1.15.0-rc01 or higher — which simplifies constructing the request bundle and adds backwards-compatibility handling.

// Requires Core 1.15.0-rc01+
// Add to build.gradle.kts:
// implementation("androidx.core:core:1.15.0")

import androidx.core.os.Profiling

// Request a heap dump during a suspected leak window
Profiling.requestProfiling(
    context,
    Profiling.PROFILING_TYPE_HEAP_DUMP,
    Bundle(),                         // optional type-specific config
    context.mainExecutor,
) { result ->
    if (result.errorCode == Profiling.ProfilingResult.ERROR_NONE) {
        val traceFile = File(result.outputFilePath)
        uploadOrAnalyze(traceFile)
    }
}

Note on the code above: The snippet shows the AndroidX wrapper pattern. Check the latest androidx.core.os.Profiling API reference for exact method and constant names — the underlying framework API evolves, and the wrapper provides the most stable surface.

The Three Profiling Types and When to Reach for Each

ProfilingManager supports three primary types of profiling data, each answering a different question:

Heap dump

A point-in-time snapshot of every live object on the heap. This is the right tool when you have a memory leak report — take a heap dump at the moment memory is unusually high, upload the result, open it in Android Studio's Heap Viewer or convert to .hprof for analysis. The dump is heavy (can be tens of MB on a real app), so you don't want this running constantly. Target it: trigger after onTrimMemory(TRIM_MEMORY_RUNNING_CRITICAL) or when your own memory usage counters cross a threshold.

Heap profile

Instead of a static snapshot, a heap profile records allocation call stacks over time. Where a heap dump tells you what's alive, a heap profile tells you where allocations came from during a session window. This is what you want for diagnosing allocation-heavy screens — the kind that don't leak but force GC every few seconds and cause the subtle stutter that users describe as "the app feels laggy sometimes." I've hit this in Musist's audio browser: each track thumbnail was triggering a new Bitmap allocation on every bind instead of recycling from the pool, and the heap profile showed it immediately as a hot allocation site.

Stack sampling

CPU stack sampling: captures where the CPU is spending time across threads. This is your go-to for diagnosing jank, slow operations, or any situation where the UI thread is blocked but you can't tell by what. It's the production-equivalent of the CPU profiler in Android Studio, but running on user devices under real workloads.

All three types produce Perfetto-compatible trace files you can open directly at ui.perfetto.dev — no additional tooling or conversion step required.

Rate Limiting: What It Means in Practice

The API is rate-limited by the system to minimize performance impact. This is the right default — you're not building a continuous monitoring pipeline with ProfilingManager, you're doing targeted, event-driven collection. Trigger profiling in response to specific conditions your app can detect: memory warnings, a slow operation timeout, a user report flow. Don't request traces in a tight loop or on every screen transition.

For production at scale, you also want to apply your own sampling layer on top: pick a percentage of sessions where profiling is enabled, or gate it on a feature flag. The system rate limits will protect you if you overshoot, but having your own control means you're not wasting quota on users where the conditions for the bug you're hunting aren't present.

The Cold Start Problem — and Why Android 16 Fixes It

There's an inherent limitation with requestProfiling(): your code has to be running to call it. Cold start happens before your first line of application code executes. By the time Application.onCreate() runs, the startup inflation has already happened. The ContentProviders that were blocking your process are already initialized. You can measure the outcome with ApplicationStartInfo timings, but you can't observe the execution inside it with a manually triggered trace.

ANRs have the same problem in reverse: by the time the system kills your app, there's nothing left to run the code that would request a trace.

This is what Android 16's system-triggered profiling solves. Instead of your app requesting profiling at a specific moment, you register interest in trigger events once, and the system starts and stops the trace on your behalf whenever those events occur. The system has the context your code doesn't — it can begin instrumenting at actual process start, before any of your code runs, and it can capture the ANR trace at the moment of unresponsiveness rather than after the fact.

System-Triggered Profiling in Android 16

The three supported system triggers are:

You register for these triggers through ProfilingManager, and the resulting traces are delivered to your app's data directory after the triggering event completes. The cold start trace in particular is something that was never cleanly achievable with manual instrumentation — you'd always be missing the first tens or hundreds of milliseconds where the heaviest initialization work actually happens.

If you've been relying on ApplicationStartInfo to measure startup times, that still applies — but now you can pair the timing with an actual Perfetto trace that shows what was executing during each startup phase, not just how long it took.

ApplicationStartInfo.getStartComponent() — new in Android 16

Alongside system-triggered profiling, Android 16 adds ApplicationStartInfo.getStartComponent(). This returns which component type actually triggered the process start — distinguishing between an Activity start, a Service start, a ContentProvider access, and a BroadcastReceiver invocation. It's a small addition with a disproportionate payoff: a significant chunk of "my startup is slow" investigations turn out to be a background Service or ContentProvider that's paying the same initialization cost as a foreground Activity start, but with no user visible benefit from doing so. Being able to split your startup traces by launch pathway rather than lumping everything into a single "cold start" bucket is the right way to build an optimization plan.

Production Strategy: How to Actually Use This

Here's how I'd approach integrating ProfilingManager into a shipping app, based on the patterns that have worked for me across different performance investigations:

Start with system triggers for cold start. On Android 16 devices, register for cold start traces from Application.onCreate(). You don't need to do anything else — traces arrive in your data directory automatically. Implement a background upload from there, similar to how you'd handle crash log uploading. This gives you a continuous baseline of startup traces from real user devices, not just your dev machine.

Use heap dumps reactively, not proactively. Hook into onTrimMemory() callbacks and request a heap dump when the device signals genuine memory pressure your app is contributing to. This pins the trace to the exact moment the problem is occurring, which is the only context where it's diagnostically useful.

Gate on build type or feature flag for stack sampling. Stack sampling has more overhead than heap profiling. Enable it on internal build variants by default, and use a remote flag to turn it on for a controlled percentage of production sessions when you're actively investigating a jank report. Rate limiting provides a safety net, but a feature flag gives you explicit control.

Analyze with Perfetto, not just Android Studio. ui.perfetto.dev handles the output files directly and has better support for long traces and cross-process analysis than Android Studio's profiler. For cold start traces, the Timeline view showing main thread work, binder calls, and GC pauses side-by-side is where you'll find the actual bottlenecks.

What This Changes

When I was hunting down the 60% startup improvement I shipped in Nodat, the hardest part wasn't the fix — it was closing the gap between what the Macrobenchmark showed on my local device and what actual users were experiencing on their hardware. ProfilingManager is the piece that closes that gap. You can now build a production profiling pipeline that delivers Perfetto traces from the devices that matter, triggered automatically on the flows that matter, without requiring your users to enable developer options or your team to be present with an ADB cable.

Datadog has already shipped this in production to deliver what their engineering team describes as millions of in-depth performance insights, as of June 2026. The underlying capability has been in Android 15 since its release. If your app supports API 35+ and you're not pulling traces from production yet, this is the starting point worth prioritizing this quarter.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment