ScreenshotNeo

BlogEngineering

How to Fix BufferQueue Abandoned Errors with Android MediaProjection

Stop BufferQueue abandoned errors by fixing MediaProjection teardown, callback races, ImageReader ownership, and Android 14 session rules.

By the ScreenshotNeo team1 October 20268 min read

How to Fix BufferQueue Abandoned Errors with Android MediaProjection

BufferQueue has been abandoned means a MediaProjection producer is still writing frames after its consumer has been released or disconnected. In a typical capture pipeline, VirtualDisplay produces frames into a Surface backed by an ImageReader. Fix the error by synchronizing shutdown: stop new frame work, release the VirtualDisplay, close acquired Image objects, remove listeners, release the Surface and ImageReader, and prevent stale callbacks from restarting the pipeline.

An occasional log line during teardown can be a callback racing with shutdown. Repeated messages, black frames, crashes, or a failed resume indicate a lifecycle bug that needs fixing.

What BufferQueue abandoned means

Android connects graphics producers and consumers with a BufferQueue. MediaProjection sends captured frames through a virtual display to the output surface supplied by your app. The ImageReader consumes those buffers. If the ImageReader, Surface, or another consumer is closed first, the producer eventually calls dequeueBuffer() on a queue with no consumer and Android logs BufferQueue has been abandoned.

A BufferQueue abandonment occurs when the producer outlives the consumer.
A BufferQueue abandonment occurs when the producer outlives the consumer.

See the Android MediaProjection guide and the MediaProjection API reference for the documented session and resource lifecycle.

Correct shutdown sequence

  1. Atomically mark the capture session as stopping.
  2. Disable the ImageReader listener and stop encoder or coroutine work.
  3. Release the VirtualDisplay, which stops the producer.
  4. Close every acquired Image.
  5. Close the ImageReader and release its output Surface.
  6. Unregister the MediaProjection callback and stop the projection.
  7. Reject callbacks from the old session before creating a new one.

The exact cleanup thread can vary, but the producer must not continue submitting work to released output objects. Keep cleanup serialized on one handler, executor, or coroutine context.

Release the producer before its surface and ImageReader consumers.
Release the producer before its surface and ImageReader consumers.

Complete Kotlin implementation

The following example shows one owner for the projection, virtual display, ImageReader, Surface, and listener. It uses an atomic stop flag and a generation token so delayed callbacks cannot process frames from an old session.

class ProjectionCapture(
    private val context: Context,
    private val callbackHandler: Handler
) {
    private val stopping = AtomicBoolean(false)
    private val generation = AtomicLong(0)

    private var projection: MediaProjection? = null
    private var virtualDisplay: VirtualDisplay? = null
    private var imageReader: ImageReader? = null
    private var outputSurface: Surface? = null
    private var sessionGeneration = 0L

    private val projectionCallback = object : MediaProjection.Callback() {
        override fun onStop() {
            stopCapture()
        }
    }

    fun startCapture(
        mediaProjection: MediaProjection,
        width: Int,
        height: Int,
        densityDpi: Int
    ) {
        require(width > 0 && height > 0 && densityDpi > 0)

        check(projection == null) { "A capture session is already active" }
        stopping.set(false)
        val token = generation.incrementAndGet()
        sessionGeneration = token

        projection = mediaProjection
        // Register before createVirtualDisplay, as required by the API.
        mediaProjection.registerCallback(projectionCallback, callbackHandler)

        val reader = ImageReader.newInstance(
            width,
            height,
            PixelFormat.RGBA_8888,
            2
        )
        imageReader = reader
        outputSurface = reader.surface

        reader.setOnImageAvailableListener({ source ->
            if (stopping.get() || generation.get() != token) {
                return@setOnImageAvailableListener
            }

            var image: Image? = null
            try {
                image = source.acquireLatestImage()
                if (image == null || stopping.get() || generation.get() != token) {
                    return@setOnImageAvailableListener
                }
                // Copy or encode pixels here. Do not retain image after this callback.
                processImage(image)
            } finally {
                image?.close()
            }
        }, callbackHandler)

        virtualDisplay = mediaProjection.createVirtualDisplay(
            "ProjectionCapture",
            width,
            height,
            densityDpi,
            DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
            outputSurface,
            null,
            callbackHandler
        )
    }

    private fun processImage(image: Image) {
        // Read the planes while the image is open, then return promptly.
        val plane = image.planes.firstOrNull() ?: return
        val buffer = plane.buffer
        // Copy bytes or submit work that does not retain buffer/image references.
    }

    fun resize(width: Int, height: Int, densityDpi: Int) {
        require(width > 0 && height > 0 && densityDpi > 0)
        val reader = imageReader ?: return
        val display = virtualDisplay ?: return

        // Recreate the reader/surface if your pipeline requires a new buffer size;
        // update the virtual display and output dimensions together.
        display.resize(width, height, densityDpi)
        reader.setDefaultBufferSize(width, height)
    }

    fun stopCapture() {
        if (!stopping.compareAndSet(false, true)) return
        generation.incrementAndGet()

        imageReader?.setOnImageAvailableListener(null, null)

        virtualDisplay?.release()
        virtualDisplay = null

        imageReader?.close()
        imageReader = null

        outputSurface?.release()
        outputSurface = null

        projection?.unregisterCallback(projectionCallback)
        projection?.stop()
        projection = null
        sessionGeneration = 0L
    }
}

Call stopCapture() from every termination path: the projection callback, user cancellation, activity or service destruction, screen lock handling, and an explicit restart. Because the method is idempotent, multiple paths can safely request cleanup.

Starting a session safely

Request consent with MediaProjectionManager.createScreenCaptureIntent(). In the activity result, obtain one MediaProjection, register the callback, and then create exactly one virtual display for that projection instance.

private val captureLauncher = registerForActivityResult(
    ActivityResultContracts.StartActivityForResult()
) { result ->
    if (result.resultCode != Activity.RESULT_OK || result.data == null) return@registerForActivityResult

    val manager = getSystemService(MediaProjectionManager::class.java)
    val projection = manager.getMediaProjection(result.resultCode, result.data!!)
    capture.startCapture(
        projection,
        width = resources.displayMetrics.widthPixels,
        height = resources.displayMetrics.heightPixels,
        densityDpi = resources.displayMetrics.densityDpi
    )
}

On Android 14 and later, one consent session grants one capture start. Do not reuse the same consent result to create multiple projection instances, and do not call createVirtualDisplay() more than once on one projection. A stopped projection cannot create another virtual display. If capture runs in a foreground service, declare and use the mediaProjection foreground-service type as described in the official guide.

Why each part of the fix matters

Symptom Likely cause Correction
Error appears during stop Pending producer callback races with teardown Disable listeners, gate callbacks, then release the virtual display.
Black or frozen frames ImageReader queue is full or images are never closed Close every acquired image in finally; use acquireLatestImage() when dropping old frames is acceptable.
Crash after rotation Old activity owns resources while a new activity starts another session Use one lifecycle owner and finish the old stop path before restarting.
Resume fails on Android 14+ Stopped projection or reused consent result Request consent again and create a fresh projection and virtual display.
Distorted output Virtual display and surface dimensions differ Resize both together and keep width, height, and density positive.

Common errors and fixes

BufferQueue has been abandoned repeats continuously

A producer is still alive after its consumer was closed, or a listener is submitting work to an old surface. Make the stop path the sole owner of release operations. Increment a generation token before releasing objects and check it in every callback.

maxImages or acquire failures

ImageReader has a finite queue. Close images promptly, do not retain their planes or buffers beyond the callback, and select a queue size appropriate for your processing time. If you only need the newest frame, acquireLatestImage() prevents stale frames from accumulating.

Callback runs after resources are null

Removing a listener does not necessarily cancel work already queued on a handler. Use both an atomic stopping flag and a session generation check. Cancel queued jobs where your executor supports cancellation.

Second call to createVirtualDisplay() fails

On Android 14+, treat each projection as single-use for virtual-display creation. Stop and discard the old projection, request a new consent result, and start a new session.

Capture stops when the user locks the screen

Projection can terminate when the screen locks, another projection starts, the user stops sharing, or the hosting activity or service is destroyed. Handle MediaProjection.Callback.onStop() and update UI state there instead of assuming the session remains active.

Images have the wrong aspect ratio

Use the same width and height for the ImageReader, output surface, and virtual display. When the target size changes, resize the display and surface as one operation or recreate both under the same serialized stop/start controller.

Lifecycle checklist

  • Register MediaProjection.Callback before createVirtualDisplay().
  • Keep one owner for projection, display, reader, surface, and listener.
  • Use one idempotent stop method for every shutdown cause.
  • Mark stopping before releasing anything.
  • Disable listeners and gate queued callbacks.
  • Release the virtual display before output consumers.
  • Close every acquired Image in a finally block.
  • Use positive, matching dimensions and density.
  • Request fresh consent for a new Android 14+ session.

Performance, reliability, and cost notes

Frame processing should copy only the bytes it needs and return the Image quickly. Heavy encoding on the listener thread increases queue pressure; move CPU work to a bounded executor while retaining no Image, Plane, or ByteBuffer reference after close. If latency matters more than every frame, use acquireLatestImage(). If every frame matters, use backpressure and explicit queue monitoring instead of silently dropping frames.

Keep all state transitions serialized. A single coroutine actor, handler thread, or synchronized controller is easier to reason about than independent activity, service, and callback cleanup. Log session generation, dimensions, stop reason, and resource ownership to diagnose races without logging pixel data.

MediaProjection itself does not charge per frame. Your practical costs are CPU, memory, storage, encoding, and any backend transfer used to store or upload captures. Releasing resources promptly reduces memory pressure and avoids repeated restarts.

Or skip the browser setup

If your goal is a website screenshot rather than Android display capture, ScreenshotNeo provides a single HTTP request and handles the browser lifecycle for you. Cookie and consent banners are accepted and removed before capture, along with 60+ known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.

See the ScreenshotNeo API documentation for all options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://developer.android.com/media/grow/media-projection -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://developer.android.com/media/grow/media-projection"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://developer.android.com/media/grow/media-projection' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

There are 1,000 screenshots per month free with no card. Paid plans start at $5 for 3,000 shots, and every plan includes the full feature set. Create a free ScreenshotNeo account.

FAQ

Is every BufferQueue abandoned log a bug?

No. A single line during orderly teardown can be a race between a pending producer callback and shutdown. Repeated logs or broken capture indicate unsynchronized ownership or cleanup.

Should I release the ImageReader before the VirtualDisplay?

Usually no. Stop the producer first by releasing the virtual display, then close the consumer resources. Always gate callbacks before either release.

Can I restart a stopped MediaProjection?

Do not reuse a stopped projection. Request consent again and create a fresh projection and virtual display, especially on Android 14 and later.

Which image acquisition method should I use?

Use acquireLatestImage() when stale frames can be discarded. Use acquireNextImage() when every frame is required, with explicit backpressure and prompt closure.

Not necessarily. Resize the virtual display and output surface together when the active session supports it. A new consent flow is required when starting a new projection session after the old one has stopped.