Cutting Android Camera-to-Preview Lag from 400ms to 44ms with Perfetto
A Perfetto-based diagnosis and optimization of Android lag after taking a photo
Background
On a photo-taking page, after the user taps the shutter, the page switches from a camera preview state to a photo preview state. In actual use, there was noticeable lag after the photo was taken.
After capturing a trace with Perfetto, one frame after the photo was taken showed abnormally long duration, with the initial worst lag approaching several hundred milliseconds.
Looking at the optimization results, after several rounds of diagnosis and code adjustments, the key duration roughly went from:
400ms+ -> ~245ms -> ~44ms
The perceived lag improved significantly.
1. Initial symptom: main thread shows sys_futex
In Perfetto, the first thing visible was that the app's main thread spent a very long time in one frame, with a call chain roughly like:
MainThread
Choreographer#doFrame
traversal
draw
postAndWait
sys_futex
At first glance, sys_futex looks like a system-level call, which can easily mislead one into thinking the problem is not on the application side.
But a key point to note here:
sys_futexdoes not mean the system is performing heavy computation; it usually means the current thread is waiting on some synchronization object, such as a lock, a condition variable, another thread, the render thread, or some other system component.
Combined with the parent node postAndWait, this kind of call typically means:
The main thread handed off drawing work to the RenderThread and is now waiting for the RenderThread to finish.
Therefore, the investigation focus cannot stop at the main thread; you must also look at the RenderThread during the same time window.
2. Tracing further: RenderThread stuck in the graphics submission path
In the same time window, a call chain like the following can be seen on the RenderThread:
RenderThread
DrawFrames
Drawing
flush layers
QueueSubmit
sys_ioctl
In the initial stage, the sys_ioctl slice was very long, about several hundred milliseconds, and spent most of its time in the Sleeping state.
This indicates that the RenderThread was not running on the CPU for hundreds of milliseconds, but was waiting at a synchronization point in the kernel, graphics driver, GPU, Surface, or fence.
At this point, the wait chain can be understood as:
MainThread
waiting on RenderThread
RenderThread
waiting on graphics driver / GPU / Surface / fence
So this is not a simple case of "main thread business code executing too slowly"; rather, after the photo is taken, heavy operations or synchronous waits are triggered on the graphics pipeline.
3. Correlating with code: displaying the raw image directly after capture
After further examining the photo page code, the original logic was roughly as follows.
In the photo capture success callback, the image file and image URI were saved directly:
onImageSaved = { imageUri, imageFile ->
currentImageFile = imageFile
previewImageUri = imageUri
isCaptureInProgress = false
}
The UI switches between camera preview and image preview based on whether previewImageUri exists:
if (previewImageUri == null) {
CameraPreview(...)
} else {
CapturedImagePreview(uri = previewImageUri)
}
The image preview loads the URI obtained from the capture directly:
Image(
painter = rememberAsyncImagePainter(previewImageUri),
contentDescription = null,
)
This means the raw large image from the capture could directly enter the UI preview pipeline.
If the captured image has a high resolution, but the actual display area on the page is much smaller than the original image dimensions, it could cause:
Read image file
-> Decode large Bitmap
-> Create GPU texture
-> RenderThread flush layers
-> QueueSubmit
-> Graphics driver / GPU synchronous wait
This highly matches the long QueueSubmit -> sys_ioctl duration seen in Perfetto.
4. First round of optimization: downsampling the preview image to display size
Problem
The UI preview area actually only needs a relatively small image, but the original logic might have let the image library decode at the original size or a larger size.
This increases:
- Bitmap decoding cost
- Java/native heap pressure
- GPU texture upload cost
- RenderThread draw submission pressure
Optimization approach
For the UI preview, there is no need to load the original large image; instead, a downsampled preview image should be loaded according to the target display area.
Example code:
@Composable
fun CapturedImagePreview(
uri: Uri,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val density = LocalDensity.current
val previewWidthDp = 400.dp
val previewHeightDp = 300.dp
val previewWidthPx = with(density) { previewWidthDp.roundToPx() }
val previewHeightPx = with(density) { previewHeightDp.roundToPx() }
val request = remember(uri, previewWidthPx, previewHeightPx) {
ImageRequest.Builder(context)
.data(uri)
.size(previewWidthPx, previewHeightPx)
.precision(Precision.INEXACT)
.build()
}
Image(
painter = rememberAsyncImagePainter(request),
contentDescription = null,
modifier = modifier.size(previewWidthDp, previewHeightDp),
contentScale = ContentScale.FillWidth,
)
}
The key points here are:
.size(previewWidthPx, previewHeightPx)
.precision(Precision.INEXACT)
This tells the image library:
Only an image close to the UI preview size is needed; there is no need to decode at the full original dimensions.
Optimization effect
After this round, the long duration dropped from roughly 400ms+ to roughly 245ms.
This confirms that "displaying the raw image directly on screen" was indeed a significant source of the lag.
5. Second round of diagnosis: Runnable Preempted indicates thread preemption
After the first round of optimization, looking at Perfetto again, the RenderThread's long sys_ioctl still existed, but the thread state had changed.
Previously, most of the time was:
Sleeping
After optimization, it became a large amount of:
Runnable (Preempted)
The two have different meanings:
| State | Meaning |
|---|---|
| Running | The thread is executing on the CPU |
| Sleeping | The thread is waiting for an event, lock, IO, fence, or driver response |
| Runnable / Preempted | The thread wants to run but did not get the CPU |
This indicates that after optimization, the remaining problem is no longer primarily "waiting for GPU/driver to return", but more like:
The RenderThread wants to continue running, but other threads in the system are occupying the CPU at the same time.
6. Using Perfetto SQL to find who is competing for CPU
When the Runnable Preempted proportion is very high, the next step is to check which threads were actually running on the CPU during the corresponding time window.
One thing to note is that the relative time displayed in the Perfetto UI cannot be directly used as ts in SQL. Timestamps in Perfetto SQL are typically the trace's internal nanosecond time.
A more reliable approach is:
- First find the real
tsanddurof the targetsys_ioctlfrom theslicetable. - Then use this time window to count the threads that actually ran in the
schedtable.
Below is a sanitized SQL example:
WITH target AS (
SELECT
s.ts AS start_ts,
s.ts + s.dur AS end_ts
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t USING (utid)
LEFT JOIN process p USING (upid)
WHERE s.name = 'sys_ioctl'
AND t.name = 'RenderThread'
AND p.name LIKE '%your.app.package%'
ORDER BY s.dur DESC
LIMIT 1
),
overlap AS (
SELECT
p.name AS process_name,
t.name AS thread_name,
CASE
WHEN sched.ts > target.start_ts THEN sched.ts
ELSE target.start_ts
END AS overlap_start,
CASE
WHEN sched.ts + sched.dur < target.end_ts THEN sched.ts + sched.dur
ELSE target.end_ts
END AS overlap_end
FROM sched
JOIN target
LEFT JOIN thread t USING (utid)
LEFT JOIN process p USING (upid)
WHERE sched.ts < target.end_ts
AND sched.ts + sched.dur > target.start_ts
)
SELECT
COALESCE(process_name, '[kernel]') AS process_name,
COALESCE(thread_name, '[unknown]') AS thread_name,
ROUND(SUM(overlap_end - overlap_start) / 1000000.0, 3) AS running_ms
FROM overlap
GROUP BY process_name, thread_name
ORDER BY running_ms DESC
LIMIT 30;
The query results showed that within the long sys_ioctl window, besides the app's RenderThread, the following types of threads also occupied significant CPU:
CameraX / camera related thread
SurfaceFlinger
RenderEngine
Graphics composer service
Kernel memory reclaim / compaction threads
logcat / logd / trace probe related threads
This shows that the lag after taking a photo is not caused by a single thread, but by the superposition of multiple factors:
Camera processing
+ Image preview on screen
+ Graphics composition
+ GPU/driver resource allocation
+ Memory compaction
+ Trace/log collection overhead
7. Second round of optimization: avoid removing the camera preview Surface in the same frame
Problem
The original UI logic was a mutually exclusive switch:
if (previewImageUri == null) {
CameraPreview(...)
} else {
CapturedImagePreview(uri = previewImageUri)
}
This means that in the frame where previewImageUri is set after a successful capture, the following happen simultaneously:
CameraPreview is removed from the Compose tree
PreviewView / Surface / TextureView begins teardown
CapturedImagePreview is added
Image is decoded and displayed for the first time
GPU texture is created
SurfaceFlinger / RenderEngine recompose
These operations stacked in the same frame can easily cause RenderThread and graphics pipeline jitter.
Optimization approach
Do not remove the camera preview in the same frame that the image is first displayed; instead, briefly keep the camera preview and let the image preview overlay on top.
Example code:
Box(modifier = Modifier.fillMaxSize()) {
if (keepCameraPreviewVisible) {
CameraPreview(
onImageCaptureReady = { imageCapture = it },
onError = { /* handle error */ },
)
}
if (previewImageUri != null) {
CapturedImagePreview(uri = previewImageUri)
}
PreviewMask(...)
}
The core purpose of this step is not to permanently keep the camera preview, but to avoid:
Surface teardown and the image's first on-screen display happening in the same frame.
Optimization effect
After this round, the previously single 200ms+ QueueSubmit -> sys_ioctl was noticeably broken up, indicating that the same-frame release of the camera preview Surface was indeed an important contributing factor.
8. Third round of optimization: displaying the image preview across frames
Problem
Even if the camera preview is no longer removed in the same frame, the capture success callback might still update multiple Compose states at once:
currentImageFile = imageFile
previewImageUri = imageUri
isCaptureInProgress = false
These state changes can trigger:
- Button state changes
- Image preview entering Composition
- Image request creation
- Compose recomposition
- First frame image drawing
If all are squeezed into the same frame, it is still easy to cause jank.
Optimization approach
After the photo is saved successfully, first update lightweight states, then wait 1–2 frames before setting the image URI to let the image preview enter the UI.
Example code:
onImageSaved = { uri, file ->
currentImageFile = file
isCaptureInProgress = false
coroutineScope.launch {
withFrameNanos { }
withFrameNanos { }
if (currentImageFile == file) {
previewImageUri = uri
}
}
}
withFrameNanos is used here instead of a fixed delay(32) to better align with Compose frame scheduling.
Preventing stale callbacks
Since image display is delayed by two frames, it is necessary to avoid showing an old image if the user quickly retakes a photo or exits.
Therefore, a check like the following was added:
if (currentImageFile == file) {
previewImageUri = uri
}
Only when the current file is still the file from this capture is the corresponding image displayed.
9. Fourth round of optimization: briefly retain then release the camera preview
Problem
If the camera preview is kept permanently underneath the image, it brings new problems:
- CameraX is still working
- Preview surface still exists
- SurfaceFlinger / RenderEngine may still continuously compose
- Sustained multi-frame load increases
Therefore, retaining the camera preview can only be a transitional measure, not a permanent one.
Optimization approach
After a successful capture:
First retain CameraPreview
Wait two frames then display the image preview
Delay a short while longer
Finally release CameraPreview
Example code:
private const val CAMERA_PREVIEW_RELEASE_DELAY_MS = 300L
onImageSaved = { uri, file ->
currentImageFile = file
isCaptureInProgress = false
coroutineScope.launch {
withFrameNanos { }
withFrameNanos { }
if (currentImageFile == file) {
previewImageUri = uri
}
delay(CAMERA_PREVIEW_RELEASE_DELAY_MS)
if (currentImageFile == file && previewImageUri == uri) {
keepCameraPreviewVisible = false
imageCapture = null
}
}
}
When retaking, restore the camera preview:
onRetakeClick = {
keepCameraPreviewVisible = true
previewImageUri = null
currentImageFile = null
imageCapture = null
}
Actively unbinding camera use cases
When CameraPreview leaves Composition, actively unbind CameraX use cases:
DisposableEffect(Unit) {
onDispose {
val providerFuture = ProcessCameraProvider.getInstance(context)
providerFuture.addListener({
try {
providerFuture.get().unbindAll()
} catch (e: Exception) {
// log or ignore
}
}, ContextCompat.getMainExecutor(context))
}
}
This avoids the situation where only PreviewView is removed but CameraX remains bound to the lifecycle and continues working.
Optimization effect
In the end, the key long duration was reduced to about 44ms.
Compared to the initial several-hundred-millisecond-level long lag, this is a clear improvement.
10. Why this is not simply a "system problem"
There is one easily misjudged point in this investigation:
- The main thread shows
sys_futex - The RenderThread shows
sys_ioctl - GPU/driver related slices have very long durations
These all look like system calls, but they do not mean "the application can do nothing".
Application-layer behavior affects the system graphics pipeline, for example:
- Whether the raw large image is displayed directly
- Whether the camera Surface is released in the same frame
- Whether multiple Compose state changes are triggered in the same frame
- Whether the image's first on-screen frame and Camera teardown overlap
- Whether CameraX release is delayed to a user-insensitive moment
Therefore, a more accurate understanding of this type of problem is:
System calls are the manifestation; resource changes triggered by the application layer are the important root cause.
11. Summary of the final optimization strategy
This optimization used a combination strategy:
1. Reduce the cost of putting the image on screen
Raw large image displayed directly
-> Display downsampled to UI preview size
2. Avoid releasing Camera Preview Surface in the same frame
CameraPreview and ImagePreview mutually exclusive switch
-> Briefly retain CameraPreview, ImagePreview overlays on top
3. Split state updates across multiple frames
All states set immediately in capture callback
-> Update lightweight states first, wait two frames before displaying image
4. Delay releasing the camera preview
CameraPreview released in the same frame as image display
-> Delay CameraPreview release until after the image is stable
5. Actively unbind CameraX
CameraX may still be bound after PreviewView is removed
-> Actively unbindAll in CameraPreview onDispose
6. Reduce performance testing interference
Remove frequent recomposition logging
Reduce the impact of trace/log on test results
12. Summary of Perfetto analysis experience
1. Look at the parent-child call chain first, not just the syscall name
sys_futex and sys_ioctl are results, not root causes.
Judgment needs to be made in context:
postAndWait + sys_futex
=> Main thread is waiting on RenderThread
QueueSubmit + sys_ioctl
=> RenderThread is in the graphics submission path
2. Thread state is more important than function name
For the same sys_ioctl:
- Predominantly Sleeping: more like waiting on GPU/driver/fence.
- Predominantly Runnable Preempted: more like the thread wants to run but is preempted by other threads.
3. For Runnable Preempted, use SQL to find CPU consumers
When a thread is Preempted for a long time, don't just stare at the current thread; check which threads actually ran on the CPU in the same time window.
4. Use small experiments to verify hypotheses
Don't make large changes all at once. It is recommended to verify step by step according to hypotheses:
| Hypothesis | Experiment |
|---|---|
| Raw image on screen is too heavy | Downsample image preview to target size |
| Surface same-frame teardown is too heavy | Retain CameraPreview, overlay image on top |
| State updates squeezed into the same frame | Delay setting image URI by two frames |
| CameraPreview continuously consumes resources | Briefly retain then release and actively unbind |
5. Pay attention to interference from the trace itself
If too many data sources are enabled, such as logcat, raw syscall, excessively large buffers, the trace itself will also increase CPU and IO pressure.
Once the problem direction is clear, you can appropriately reduce collection items to make the data closer to real running performance.
13. Directions worth trying next
If there is a need to further compress the remaining ~40ms of short-term fluctuation, the following experiments can be continued.
1. Extend the CameraPreview release delay
For example, from:
private const val CAMERA_PREVIEW_RELEASE_DELAY_MS = 300L
Adjust to:
private const val CAMERA_PREVIEW_RELEASE_DELAY_MS = 500L
The goal is not to reduce the release cost, but to move the release fluctuation to a moment less perceptible to the user.
2. Try the camera preview's performance mode
If the compatibility mode is currently used, experiment with using performance mode:
previewView.implementationMode = PreviewView.ImplementationMode.PERFORMANCE
But it is necessary to verify the layering compatibility of SurfaceView with Compose masks and overlays.
3. Evaluate the capture output resolution
If the business does not require the highest quality original image, evaluate reducing the capture output size or using a low-latency capture mode.
But this may affect subsequent business results and should be evaluated in conjunction with product requirements.
Summary
The key to this lag optimization was not finding a single "slow function", but reconstructing the complete wait chain through Perfetto:
MainThread waits on RenderThread
RenderThread waits on graphics submission / GPU / driver
The graphics pipeline is further affected by image on-screen, Camera surface switching, memory allocation, and CPU scheduling competition
Ultimately, by reducing the cost of putting the image on screen, splitting state updates, avoiding same-frame release of the camera preview Surface, delaying release, and actively unbinding CameraX, the key long duration was reduced from several hundred milliseconds to tens of milliseconds.
The takeaway from this type of problem is:
Android UI lag does not necessarily occur in the application main thread's business code. In many cases, application-layer resource switching and UI state changes amplify into the RenderThread, SurfaceFlinger, GPU driver, and kernel scheduling layers. The value of Perfetto lies in helping us string this entire chain together.