Tracking Android Apps Responsiveness with JankStats
At Bitdrift, our goal is to deliver deep insights into app performance with minimal resource impact.
In the world of mobile app development, ensuring that your users don't experience slowness, jankiness or complete unresponsiveness is key to delivering a positive user experience.
That's why we are eager to introduce our recent addition for detecting and exposing those performance issues, and what this integration unlocks for our users.
In this post, we’ll walk through the challenges of monitoring app responsiveness and how we tackled them by integrating JankStats, a powerful Android library that provides real-time visibility into UI performance.

Glossary
Before we dive into the implementation details, let’s clarify some key concepts related to UI performance in Android:- Main thread (UI thread): This is the primary thread in every Android app. It handles user interactions, app logic, and prepares the UI for rendering by performing layout, measuring views, and updating the view hierarchy. If this thread is blocked or overloaded, the app becomes unresponsive and may trigger an ANR.
- Render Thread: A background thread created by the Android system (when hardware acceleration is enabled). It takes the UI instructions prepared by the main thread and handles the actual drawing on screen using the GPU when available. If the render thread can't finish drawing within the time budget (e.g. ~16.67ms per frame on devices with 60fps), you’ll see jank even if the main thread is running fine.
Note: The RenderThread was introduced in Android Lollipop (API 21), so it is not available in earlier versions of Android. - Frames: In Android, a "frame" refers to a single screen update. The system aims to render frames at a consistent rate (which is typically 60 frames per second). When rendering takes too long, frames are skipped or delayed, resulting in visible lag (commonly referred to as "jank").
- ANR: Stands for Application Not Responding. A common cause is the main thread of the app being blocked for too long (5 seconds or more). It can be categorized as:
- Non-Fatal: The ANR lasted for 5s or more but the app was able to recover.
- Fatal ANR: The main thread was blocked for more than 5 seconds, leading to an ANR dialog. The app was ultimately closed by the user or the system due to continued unresponsiveness.
The Challenge: Monitoring Main Thread Performance
One of the most critical indicators of app performance is the responsiveness of the main thread. Mobile developers are very familiar with this mantra You should never block the main thread The main thread needs to remain responsive to ensure a smooth user experience. Any delay or freezing of the main thread can cause the app to feel sluggish or unresponsive, which can ultimately lead to user frustration and high churn rates. Historically, developers have used tools like StrictMode, custom watchdogs, and fatal ANR (Application Not Responding) to determine main thread performance. While these tools have their uses, they often fall short while providing a detailed, real-time view of what’s happening on the main thread. StrictMode can catch violations, but it doesn’t provide a complete picture. Fatal ANR reports, while critical, often come too late and lack the context needed to identify the root causes of main thread performance issues.The Solution: Surfacing Slow Frames
To address these challenges and make slow frame tracking more accessible, Bitdrift has integrated JankStats, a powerful library that reports jank frames and its duration and is suitable for monitoring performance at scale. By leveraging JankStats and adding to our set of default Out of the box events, Bitdrift enables developers to gain detailed insights into their app’s frame performance, including the identification of slow, frozen, and non fatal ANR frames. These metrics are captured automatically, offering a much more granular view of performance issues before they escalate into fatal ANRs or crashes.JankStats integration: the due diligence
Before integrating with any 3rd party library, we carefully review its capabilities and constraints. Specifically, we evaluate:- App size impact: Our library impact on apk size is minimal (~1.5 MB) and as we add more features, we need to be careful it remains that way.
- Performance impact: The chosen library shouldn’t cause new main thread performance issues
- Support across OS levels: This is important given our min sdk is API 21
Constraints | Findings |
---|---|
App size impact | 5KB |
Performant | Needs careful thread management |
Support across all OS levels (API 21+) | Support since API 1 |
The Integration Process: Challenges
Challenge 1: JankStats Lifecycle Setup
One of the first challenges we encountered was initializing JankStats correctly. JankStats requires access to the current Window object of the application, which is typically available at the Activity or Fragment level. However, Bitdrift’s SDK operates at the library not the application level, which meant we didn’t have direct access to the current Activity or Fragment.kotlin
// From the current Actvitity override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) jankStats = JankStats.createAndTrack(window, jankFrameListener) }
Initial App Launch Setup
Upon initial cold launch we cannot rely just on ActivityLifecycleCallbacks.onActivityResumed given that the customer can choose to initialize our library at any arbitrary point in their app’s lifecycle which may very well be at a later point of this callback, for that reason in order to get the current window during initial app launch setup we relied on LifecycleEventObserver.onStateChanged callback using ProcessLifecycleOwner, which guarantees notifying of lifecycle events that may have already happened to new subscribers. Given that this callback doesn’t provide the current window object we leveraged the curtains library, which provides a WindowSpy.pullWindow method that can be called anywhere. WindowSpy was already in use for SessionReplay, so no new dependencies were needed.kotlin
/** * [LifecycleEventObserver] callback to determine when Application is first created */ override fun onStateChanged( source: LifecycleOwner, event: Lifecycle.Event, ) { if (event == Lifecycle.Event.ON_CREATE) { jankStats = JankStats.createAndTrack(currentWindow, this) } }
Detecting Activity Lifecycle Changes
After JankStats is set up on initial app launch, the next step is to detect changes on the current Activity lifecycle. This will be important in order to stop collection of jank stats when activity is paused and to prevent any potential memory leaks. For that will rely on ActivityLifecycleCallbacks.onActivityPausedWithin stopCollection will make sure JankStats tracking is disabled to prevent any risk of potential leakskotlin
override fun onActivityPaused(activity: Activity) { stopCollection() }
Now, if the activity is resumed again will rely on ActivityLifecycleCallbacks.onActivityResumed to set the current JankStats instance for the current activity windowkotlin
private fun stopCollection() { jankStats?.isTrackingEnabled = false performanceMetricsStateHolder?.state?.removeState(SCREEN_NAME_KEY) jankStats = null performanceMetricsStateHolder = null }
To summarize, this is how the whole lifecycle will look likekotlin
override fun onActivityResumed(activity: Activity) { jankStats = JankStats.createAndTrack(window, this) }
Challenge 2: Handling Frame Duration Variability
Another challenge we encountered was the variability of frame durations across OS versions. Upon further investigation, we found that the duration captured will vary by OS level and that relying only on the main exposed property upon onFrame callback won’t be accurateFrameData is the base class holding duration per frame and is captured by frameDurationUiNanos field, relying directly on this property for all API level won’t be an accurate representation of real frame duration Now if we look at the docs for each class:kotlin
override fun onFrame(volatileFrameData: FrameData) { // This is the only property accessible here volatileFrameData.frameDurationUiNanos }
So we ended adding our extension function that will be called upon onFrame callback.FrameData - API <24 ----------------------- @property frameDurationUiNanos The time spent in the UI portion of this frame (in nanoseconds). This is essentially the time spent on the UI thread to draw this frame, but does not include any time spent on the RenderThread. FrameDataApi24 - API >=24 and API <31 -------------------------------------- @property frameDurationCpuNanos The time spent in the non-GPU portions of this frame (in nanoseconds). This includes the time spent on the UI thread (frameDurationUiNanos) plus time spent on the RenderThread. FrameDataApi31 -API >=31 -------------------------- @property frameDurationTotalNanos The total time spent rendering this frame (in nanoseconds), which includes both CPU and GPU processing.
Please note that on devices running API levels below 24, the duration reported may not be as precise as on higher API levels. However, it still offers valuable insights by capturing the duration spent on the main UI thread.kotlin
private fun FrameData.toDurationNano(): Long = when (this) { is FrameDataApi31 -> frameDurationTotalNanos is FrameDataApi24 -> frameDurationCpuNanos else -> { frameDurationUiNanos } }
Now, If we dig down into the source implementation, we can see that JankStats relies on FrameMetrics which was added on API 24 and that FrameMetrics is another abstraction of the original native layer implementation JankTracker.cpp, which is responsible for filling up FrameInfo.h data (which is the same data presented in dumpsys gfxinfo). FrameMetrics source contains more details about how each of the durations exposed in JankStats above are computed:kotlin
// Within JankStatsApi31Impl override fun getFrameData( startTime: Long, expectedDuration: Long, frameMetrics: FrameMetrics ): FrameDataApi31 { val uiDuration = frameMetrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION) frameMetrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION) + frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION) + frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) + frameMetrics.getMetric(FrameMetrics.DRAW_DURATION) + frameMetrics.getMetric(FrameMetrics.SYNC_DURATION) prevEnd = startTime + uiDuration metricsStateHolder.state?.getIntervalStates(startTime, prevEnd, stateInfo) val isJank = uiDuration > expectedDuration val totalDuration = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) // SWAP is counted for both CPU and GPU metrics, so add it back in after subtracting GPU val cpuDuration = totalDuration - frameMetrics.getMetric(FrameMetrics.GPU_DURATION) + frameMetrics.getMetric(FrameMetrics.SWAP_BUFFERS_DURATION) val overrun = totalDuration - frameMetrics.getMetric(FrameMetrics.DEADLINE) frameData.update(startTime, uiDuration, cpuDuration, totalDuration, overrun, isJank) return frameData }
kotlin
/* * Bucket endpoints for each Metric defined above. * * Each defined metric *must* have a corresponding entry * in this list. */ private static final int[] DURATIONS = new int[] { // UNKNOWN_DELAY Index.INTENDED_VSYNC, Index.HANDLE_INPUT_START, // INPUT_HANDLING Index.HANDLE_INPUT_START, Index.ANIMATION_START, // ANIMATION Index.ANIMATION_START, Index.PERFORM_TRAVERSALS_START, // LAYOUT_MEASURE Index.PERFORM_TRAVERSALS_START, Index.DRAW_START, // DRAW Index.DRAW_START, Index.SYNC_QUEUED, // SYNC Index.SYNC_START, Index.ISSUE_DRAW_COMMANDS_START, // COMMAND_ISSUE Index.ISSUE_DRAW_COMMANDS_START, Index.SWAP_BUFFERS, // SWAP_BUFFERS Index.SWAP_BUFFERS, Index.FRAME_COMPLETED, // TOTAL_DURATION Index.INTENDED_VSYNC, Index.FRAME_COMPLETED, };
JankStats automatically differentiates between devices running at 60fps or 120fps. This ensures that the appropriate time budget is used for each frame rate: 16.67ms per frame for 60fps devices and 8.33ms per frame for 120fps devices.
Challenge 3: Setting Up Heuristics And Runtime Config Values
Given the findings mentioned above, we decided that customers may want to set their thresholds on when to start emitting jank frames. So we added runtime configuration parameters for:- Slow, Frozen, ANR thresholds
Enriching The Reports
After shipping an initial version with JankStats, we’ve thought that it would be useful to also append the last seen screen that led to jank by leveraging other out-of-the-box events that append valuable information, like the last viewed screen, to the dropped frame payload. Given JankStats supports state management through the PerformanceMetricState API, we could pass existing Screen View Event information into it This was a straightforward process, first we needed to set a performanceMetricsStateHolder instance for the current view.Later we needed to expose a trackScreenNameChanged that will be updated upon each screen view updatekotlin
performanceMetricsStateHolder = PerformanceMetricsState.getHolderForHierarchy(window.decorView.rootView)
kotlin
fun trackScreenNameChanged(screenName: String) { if (runtime.isEnabled(RuntimeFeature.DROPPED_EVENTS_MONITORING)) { performanceMetricsStateHolder?.state?.putState(SCREEN_NAME_KEY, screenName) } }
So, how does it look?
Given that duration is a OOTB field you can add workflows that are tailored to your needsThe Benefits: Enhanced Visibility and Control
Our integration of JankStats provided several key benefits for users of Bitdrift:- Teams can more easily track slow, frozen, and ANR frames to better understand main thread responsiveness
- Unlock the ability to fix performance issues before they escalate into ANRs and negatively impact your business
- Thresholds for slow, frozen, and ANR frames can be remotely customized in real-time to adjust verbosity levels and reduce noise
- JankStats + session replay lets you view what exactly happened during sessions that lead to jankiness to better diagnose performance issues that do come up
Looking Ahead: What’s Next for Performance Insights
Integrating JankStats into Bitdrift’s observability platform has been a valuable learning experience that has significantly enhanced our ability to monitor and diagnose main thread performance issues at scale. With the ability to track slow, frozen, and ANR frames, developers now have more visibility into their apps’ performance than ever before. In the future, we plan to continue gathering feedback from our users and refining our observability tools. In the short term, we’ve already added the "screen_view" event to the JankStats integration, allowing developers to map which screens are causing performance issues. Interested in learning more? Check out the sandbox to get a hands-on feel for what working with Capture is like or get in touch with us for a demo. Please join us in Slack as well to ask questions and give feedback!Author

Fran Aguilera