AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Debug ReplayKit & MediaProjection Screen Recording Crashes

NFNourin Mahfuj Finick··9 min read

Screen recording and broadcast features crash in ways that look nothing like ordinary app bugs. A user taps Start Recording, the consent sheet flashes, and then the app dies silently — or worse, the recording stops mid-session with no trace in your crash reporter. On iOS, screen capture runs through ReplayKit (RPScreenRecorder plus the RPBroadcastSampleHandler broadcast upload extension), and on Android it runs through MediaProjection with MediaRecorder and MediaCodec. Both stacks are small, callback-heavy, and governed by system-level constraints — a roughly 50 MB memory ceiling on iOS and a foreground-service-type requirement on Android 14 — that turn a routine feature into a reliable crash generator. This guide breaks down the concrete crash modes in each stack, with fixes you can ship and an instrumentation strategy that makes them visible before your users do.

Why screen recording crashes fly under the radar

Three properties make this class of bug easy to miss. First, the work is split across process boundaries: an iOS broadcast extension runs in its own sandboxed process and is killed by the system (jetsam) when it exceeds its memory budget, so the stack trace never reaches your main app. Second, the critical failure points happen during transitions — the consent dialog, the startRecording callback, the foreground-service handoff — which are exactly the moments your logging tends to be thinnest. Third, the symptoms are silent: a recording that simply stops, a black frame, or a broadcast that disconnects, with no EXC_BAD_ACCESS and no NullPointerException to point at.

The good news is that both platforms expose the same underlying failure surface, so a cross-platform mental model goes a long way. If you have chased the lower-level encode pipeline before, our guide to mobile camera and media crash debugging covers the shared MediaRecorder/AVFoundation layer, and debugging iOS app extension crashes explains the jetsam process limits that govern broadcast extensions.

iOS: in-app recording with RPScreenRecorder

The in-app path is the simpler of the two, but it still has sharp edges. RPScreenRecorder.shared() is a singleton you must guard behind an availability check — isAvailable returns false on simulators and on devices where the feature is disabled — and its callbacks always arrive on a background queue. The most common in-app crash is a misuse of the completion handlers: calling startRecording while a recording is already active, or calling stopRecording twice, which double-releases an internal buffer and can produce a crash that only reproduces under fast tap sequences.

let recorder = RPScreenRecorder.shared()
 
guard recorder.isAvailable else { return }
 
recorder.startRecording { [weak self] error in
    if let error = error {
        // Recording did not start — do not proceed as if it did.
        self?.handleStartFailure(error)
        return
    }
    // Success: persist the fact that a recording is now live.
    self?.isRecording = true
}

Guard every transition with an explicit state flag rather than trusting the framework to reject bad calls. ReplayKit does throw RPRecordingErrorCode values for some misuse, but relying on that is fragile — the framework's internal state machine and your app's can drift, especially when the user background the app or an incoming call interrupts the session. Treat the recording state as an invariant your code owns, and transition it only inside the completion handlers.

iOS: the broadcast extension and its 50 MB ceiling

The broadcast path is where ReplayKit crashes get serious. A broadcast upload extension hosts an RPBroadcastSampleHandler that receives a stream of CMSampleBuffer objects containing both video and audio. This extension runs in a separate process with a strict memory limit — in practice around 50 MB — and when it crosses that line, the system terminates it without warning. The termination shows up in the device console as a jetsam event, not in your app's crash reporter, which is why so many teams ship "recording just stops" bugs for months.

class SampleHandler: RPBroadcastSampleHandler {
 
    override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer,
                                      with sampleBufferType: RPSampleBufferType) {
        switch sampleBufferType {
        case .video:
            // Encode/deliver frames without retaining them.
            enqueue(sampleBuffer)
        case .audioApp, .audioMic:
            enqueue(sampleBuffer)
        @unknown default:
            break
        }
    }
 
    override func broadcastFinished() {
        // Flush and release every buffer you still hold.
        flushQueue()
    }
}

The failure modes map directly to memory discipline. A handler that buffers every frame into an in-memory queue before writing grows without bound and hits the jetsam limit in minutes of a 1080p stream. A handler that captures CMSampleBuffer objects without releasing them — or that performs CPU-heavy work like pixel-format conversion on the sample-buffer queue — stalls the pipeline until the system kills it for unresponsiveness. The fix is to treat every frame as transient: copy only what you need, release the rest immediately, and backpressure the queue rather than accumulating it.

If you start a broadcast from inside the app instead of from Control Center, you hold an RPBroadcastController whose broadcastStarted/broadcastPaused/broadcastResumed/broadcastFinished callbacks mirror the extension's own lifecycle. The crash to watch for here is a controller that outlives its delegate: because the controller retains the broadcast session, releasing the delegate while a broadcast is still live leaves you with a session you can no longer stop, and the extension keeps running until the system reaps it. Keep the controller and its delegate alive for the full duration of the broadcast, and always call finishBroadcast(with:) on the completion path.

Android: the consent dialog race

On Android, screen capture begins with a consent dialog that must be shown to the user before you obtain a MediaProjection token. The race is subtle: MediaProjectionManager.createScreenCaptureIntent() returns an Intent that you pass to startActivityForResult, and only the result's RESULT_CODE plus Intent data — passed to getMediaProjection() — yields a usable token. Teams crash here by capturing the result code but not the Intent data, or by calling getMediaProjection() more than once on the same result, which throws because the token is single-use.

val projectionManager = getSystemService(MediaProjectionManager::class.java)
startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_CODE)
 
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    if (requestCode != REQUEST_CODE || resultCode != RESULT_OK) return
    // Store BOTH resultCode and data — the token needs both.
    projection = projectionManager.getMediaProjection(resultCode, data!!)
    projection?.registerCallback(callback, handler)
}

The second classic crash is a SecurityException or an IllegalStateException thrown when you touch a MediaProjection after its token has been revoked — which happens automatically when the user revokes consent, when the screen is locked, or on Android 14 when the capture session is not tied to the required foreground service. Wrap every MediaProjection call in a check that the projection is still valid, and unregister your callback before releasing it to avoid a use-after-revoke.

Android 14+: the foreground service type requirement

Android 14 made screen capture a lot more likely to crash in the field by requiring apps to declare a foreground service with type mediaProjection before calling getMediaProjection(). If the service is not running — or if you declare the wrong type — the call throws a SecurityException, and on some OEM builds the app is killed outright. The fix is a manifest entry plus a runtime handoff that starts the service before you request the projection.

<service
    android:name=".CaptureService"
    android:foregroundServiceType="mediaProjection"
    android:exported="false" />
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    val intent = Intent(this, CaptureService::class.java)
    startForegroundService(intent)
}
// Only after the service is running:
projection = projectionManager.getMediaProjection(resultCode, data!!)

The ordering matters more than the declaration. A common field-only crash is starting the service after getMediaProjection(), which works in local testing (where timing masks it) but fails on release builds under load. Start the service first, wait for onStartCommand to confirm it is foreground, then request the token. Also remember to call stopSelf() when capture ends, or the persistent notification lingers and the service is eventually killed by the system.

MediaRecorder and MediaCodec failure modes

Once you hold a valid MediaProjection, you feed it into a VirtualDisplay that renders into a MediaRecorder or a MediaCodec surface. The crashes here are configuration-driven and tend to reproduce only on specific devices: an encoder profile the hardware rejects, a resolution the display pipeline cannot produce, or a codec that was never released between sessions. The MediaRecorder state machine is strict — calling stop() before start(), or calling prepare() after release(), throws IllegalStateException — and the same applies to the MediaCodec async model, where a buffer released twice or a codec stopped while an input buffer is queued produces native crashes in the OMX layer.

val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
    MediaRecorder(this)
} else {
    @Suppress("DEPRECATION")
    MediaRecorder()
}
recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE)
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
recorder.setVideoSize(width, height)
recorder.setVideoFrameRate(30)
recorder.setOutputFile(outputPath)
recorder.prepare()
 
val display = projection.createVirtualDisplay(
    "capture", width, height, density,
    DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
    recorder.surface, null, handler
)

The most reliable defense is lifecycle hygiene: create and release the recorder, codec, and virtual display in a strict, idempotent order, and never share a single encoder across concurrent sessions. Log every transition — prepare success, first frame, stop completion — so that when a specific device's codec fails, you can correlate the crash to the exact state where it happened instead of guessing from a bare SIGABRT.

A cross-platform debugging checklist

Across both stacks, the same five checks resolve most field crashes. First, confirm the feature is actually available before touching any API (RPScreenRecorder.isAvailable / MediaProjectionManager presence and a valid consent result). Second, guard every state transition with an explicit flag and transition it only inside completion callbacks. Third, on Android 14+, verify the mediaProjection foreground service is running before getMediaProjection(). Fourth, release every buffer, codec, and display in a strict order — memory ceilings and encoder state machines punish leaks here more than anywhere else. Fifth, capture the consent/token lifecycle in your logs, because the crash you cannot see is almost always a revoked token or a jetsam kill, not a bug in your encoding math.

Instrumenting screen capture with BugsPulse

The reason these bugs survive so long is that the interesting events — consent granted, projection revoked, buffer queue depth, extension memory pressure — live outside your normal crash reporting. BugsPulse fills that gap by capturing breadcrumbs across process boundaries, so a jetsam kill of your broadcast extension can be correlated with the last frame your handler processed, and an Android SecurityException can be tied to the exact consent state that preceded it. Instead of shipping a feature that works in QA and dies in the field, you get a timeline of the whole capture lifecycle on every affected device.

Ready to stop guessing why screen recording crashes only on your users' devices? Sign up at app.bugspulse.com/register and get breadcrumbs, crash grouping, and cross-platform capture telemetry in minutes.