---
title: "Tracking Android Apps Responsiveness with JankStats"
slug: "jank-stats-integration"
blurb: "At Bitdrift, our goal is to deliver deep insights into app performance with minimal resource impact. \n\nIn 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.\n\nThat'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.\n\nIn 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."
cover:
  url: "/assets/posts/jank-stats-integration/feature-cover-desktop@1x.webp"
  alt: "Tracking Android App Responsiveness with JankStats"
socialThumbnail:
  url: "/assets/posts/jank-stats-integration/feature-cover-desktop@1x.webp"
  alt: "Tracking Android App Responsiveness with JankStats"
author:
  - "fran"
publishedDate: "2025-04-30T13:45:29.046Z"
modifiedDate: "2025-04-30T13:45:29.046Z"

---

# 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.

Now looking at what constitutes a “jank” frame. [Android categorizes](https://developer.android.com/topic/performance/vitals/render#jank-relationship) frames into three types

<Image alt="Android Developer Documentation for Tracking Jank" altAsCaption asset="/assets/posts/jank-stats-integration/android-developers-slow-rendering.webp" />

_NOTE: The duration of a single frame depends on the refresh rate of the device. For devices running at 60 fps, each frame has a time budget of approximately 16.67 ms. For devices running at 120 fps, the time budget is halved to approximately 8.33 ms._

# 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](https://developer.android.com/reference/android/os/StrictMode), [custom watchdogs](https://github.com/SalomonBrys/ANR-WatchDog), and fatal [ANR](https://developer.android.com/topic/performance/vitals/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](https://developer.android.com/topic/performance/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](https://developer.android.com/topic/performance/jankstats#initialization) 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 Activity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    jankStats = JankStats.createAndTrack(activityWindow, jankFrameListener)
}
```

### Initial App Launch Setup

Upon initial cold launch we cannot rely just on [ActivityLifecycleCallbacks.onActivityResumed](https://developer.android.com/reference/android/app/Application.ActivityLifecycleCallbacks#onActivityResumed(android.app.Activity)) 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](https://developer.android.com/reference/androidx/lifecycle/LifecycleEventObserver#onStateChanged(androidx.lifecycle.LifecycleOwner,androidx.lifecycle.Lifecycle.Event)) callback using [ProcessLifecycleOwner](https://developer.android.com/reference/androidx/lifecycle/ProcessLifecycleOwner), which guarantees notifying of lifecycle events that may have already happened to new subscribers.

<Image asset="/assets/posts/jank-stats-integration/blog_jankstats_diagram_01.webp" />


Given that this callback doesn’t provide the current activity window object we leveraged the [curtains](https://github.com/square/curtains) library, which provides a ***WindowSpy.pullWindow*** method that can be called anywhere. ***WindowSpy*** was already in use for [SessionReplay](https://bitdrift.io/replay), 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(currentActivityWindow, 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. 

<Image asset="/assets/posts/jank-stats-integration/blog_jankstats_diagram_02.webp" />

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.onActivityPaused](https://developer.android.com/reference/android/app/Application.ActivityLifecycleCallbacks#onActivityPaused(android.app.Activity))

```kotlin
    override fun onActivityPaused(activity: Activity) {
        stopCollection()
    }      
```

Within stopCollection will make sure JankStats tracking is disabled to prevent any risk of potential leaks

```kotlin
    private fun stopCollection() {
        jankStats?.isTrackingEnabled = false
        performanceMetricsStateHolder?.state?.removeState(SCREEN_NAME_KEY)
        jankStats = null
        performanceMetricsStateHolder = null
    } 
```

Now, if the activity is resumed again will rely on ActivityLifecycleCallbacks.onActivityResumed to set the current JankStats instance for the current activity window

```kotlin
    override fun onActivityResumed(activity: Activity) {
        jankStats = JankStats.createAndTrack(window, this)
    }
```

To summarize, this is how the whole lifecycle will look like

<Image asset="/assets/posts/jank-stats-integration/blog_jankstats_diagram_03.webp" />


## 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 accurate

```kotlin
    override fun onFrame(volatileFrameData: FrameData) {
        // This is the only property accessible here
        volatileFrameData.frameDurationUiNanos 
    }
```

***FrameData*** 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

<Image asset="/assets/posts/jank-stats-integration/blog_jankstats_diagram_04.webp" />

Now if we look at the docs for each class:

```
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.

```

So we ended adding our extension function that will be called upon ***onFrame*** callback. 

```kotlin
private fun FrameData.toDurationNano(): Long =
    when (this) {
        is FrameDataApi31 -> frameDurationTotalNanos
        is FrameDataApi24 -> frameDurationCpuNanos
        else -> {
            frameDurationUiNanos
        }
    }
```

_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
// 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
    }

```

Now, If we dig down into the source implementation, we can see that JankStats relies on [FrameMetrics](https://github.com/aosp-mirror/platform_frameworks_base/blob/1cdfff555f4a21f71ccc978290e2e212e2f8b168/core/java/android/view/FrameMetrics.java#L38) which was added on API 24 and that FrameMetrics is another abstraction of the original native layer implementation [JankTracker.cpp](https://github.com/aosp-mirror/platform_frameworks_base/blob/4b1a8f46d6ec55796bf77fd8921a5a242a219278/libs/hwui/JankTracker.cpp), which is responsible for filling up [FrameInfo.h](https://github.com/aosp-mirror/platform_frameworks_base/blob/4b1a8f46d6ec55796bf77fd8921a5a242a219278/libs/hwui/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
/*
 * 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
- [Heuristics multiplier value](https://developer.android.com/reference/kotlin/androidx/metrics/performance/JankStats#jankHeuristicMultiplier()):
<Image alt="Android Developer Documentation for jankHeuristicMultiplier" altAsCaption asset="/assets/posts/jank-stats-integration/jank-heuristics-doc.webp" />
This runtime configuration removes unnecessary noise and allows the customer to update the underlying mechanism without additional code changes or new app version releases, so they can focus on what matters to them.

# 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](https://developer.android.com/topic/performance/jankstats#states) API, we could pass existing [Screen View Event](https://docs.bitdrift.io/sdk/features.html#log-events) information into it

<Image alt="From bitdrift docs" altAsCaption asset="/assets/posts/jank-stats-integration/screen-view-doc.webp" />

This was a straightforward process, first we needed to set a ***performanceMetricsStateHolder*** instance for the current view.

```kotlin
performanceMetricsStateHolder = PerformanceMetricsState.getHolderForHierarchy(window.decorView.rootView)
```

Later we needed to expose a ***trackScreenNameChanged*** that will be updated upon each screen view update

```kotlin
fun trackScreenNameChanged(screenName: String) {
    if (runtime.isEnabled(RuntimeFeature.DROPPED_EVENTS_MONITORING)) {
        performanceMetricsStateHolder?.state?.putState(SCREEN_NAME_KEY, screenName)
    }
}
```

# So, how does it look?

<Image alt="Detailed timeline view with slow and frozen frames" altAsCaption asset="/assets/posts/jank-stats-integration/frozen_frame.webp" />

Given that duration is a OOTB field you can add workflows that are tailored to your needs

<Image alt="Sample worflow that will trigger for dropped frame when duration greater than 2500 ms" altAsCaption asset="/assets/posts/jank-stats-integration/dropped-frames-duration.webp" />

# The Benefits: Enhanced Visibility and Control

Our integration of JankStats provided several key benefits for users of Bitdrift:

1. Teams can more easily track slow, frozen, and ANR frames to better understand main thread responsiveness
2. Unlock the ability to fix performance issues before they escalate into ANRs and negatively impact your business
3. Thresholds for slow, frozen, and ANR frames can be remotely customized in real-time to adjust verbosity levels and reduce noise
4. 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](https://bitdrift.io/sandbox) to get a hands-on feel for what working with Capture is like or [get in touch with us](https://bitdrift.io/signup) for a demo. Please [join us in Slack](https://communityinviter.com/apps/bitdriftpublic/bitdrifters) as well to ask questions and give feedback!
