●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Implementing Screen Recording and Broadcast in Rork Max — A Production Guide for ReplayKit and MediaProjection
Integrate iOS ReplayKit and Android MediaProjection into your Rork Max app for gameplay recording, tutorial creation, and live broadcasting—covering the full production pipeline.
All I want is a "record" button — why is this so hard?
A user wants to share what they did inside your app. A gamer wants to capture a high score moment for social media. A creator wants to make a tutorial and post it on YouTube. When these requests come in, "screen recording, easy enough" turns out to hide a surprising amount of complexity.
iOS gives you ReplayKit, which on paper looks tidy. But the docs list four different APIs—In-App Recording, Broadcast Upload Extension, Broadcast Pairing, App Recording—and it's not obvious which one you should reach for. Android is messier still: the MediaProjection API requires a Foreground Service paired with a persistent Notification, and if the lifecycle isn't perfect the OS will silently kill your service mid-recording. On top of that, getting any of this working from React Native means writing native modules or stitching together community libraries.
I've shipped recording features in three apps now, and the first attempt I made hit the Broadcast Upload Extension's 50 MB memory ceiling and crashed in production. App Store review then rejected the build because "the app does not provide a clear indication that screen recording is active." This guide is the writeup I wish I'd had on day one—real implementation patterns for Rork Max apps, with working code and the operational pitfalls called out.
Four ReplayKit APIs — which one should you actually use?
ReplayKit has been around since iOS 9, but the API surface has grown in waves. Here's what each one is for, in plain language:
RPScreenRecorder (In-App Recording): Record the app itself, then let the user trim, save, or share the resulting MP4. Best for highlights, tutorials, and shareable moments. Easiest to implement
RPBroadcastActivity (Broadcast): Stream directly to Twitch, YouTube Live, etc. Requires a Broadcast Upload Extension target with significant system constraints
In-App Broadcasting (iOS 12+): Stream to your own backend using WebRTC or RTMP. The hardest of the four—you need a media pipeline
App-Wide Recording (Control Center): User-initiated from Control Center. Your app has almost no control, so this guide skips it
My recommendation: start with RPScreenRecorder for In-App Recording. Add a Broadcast Extension later only when you actually need streaming. Going in the opposite direction is how I burned weeks the first time around.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Developers stuck on how to let users record their in-app moments and share them with friends will walk away with working ReplayKit and MediaProjection implementations for both platforms
✦Engineers wrestling with Broadcast Upload Extension setup will learn the exact Xcode target configuration, App Group sharing, and sample buffer handling needed to ship it
✦You'll be able to release a screen recording feature that satisfies both Apple's and Google's latest privacy and review guidelines without rejection
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Rork Max uses Expo's Custom Development Build under the hood. We'll create an Expo Module that wraps ReplayKit and MediaProjection in native code, then expose a TypeScript surface.
# From the project root# Generate an Expo Module template (module name: expo-screen-recorder)npx create-expo-module@latest expo-screen-recorder --no-examplecd expo-screen-recorder# Add a local reference in your Rork Max project's package.json:# "expo-screen-recorder": "file:../expo-screen-recorder"# Expected output: a new module with ios/, android/, and src/ directories
The template scaffolds Swift and Kotlin stubs. The two files you'll do real work in are ios/ExpoScreenRecorderModule.swift and android/src/main/java/expo/modules/screenrecorder/ExpoScreenRecorderModule.kt.
iOS — In-App Recording with RPScreenRecorder
Start with iOS In-App Recording. Three things matter most: make the recording status obvious to the user, request microphone permission only when needed, and be explicit about where files go.
// ios/ExpoScreenRecorderModule.swiftimport ExpoModulesCoreimport ReplayKitimport Photospublic class ExpoScreenRecorderModule: Module { private let recorder = RPScreenRecorder.shared() public func definition() -> ModuleDefinition { Name("ExpoScreenRecorder") // Start recording — caller specifies whether to capture mic AsyncFunction("startRecording") { (withMicrophone: Bool, promise: Promise) in guard self.recorder.isAvailable else { promise.reject("E_RECORDER_UNAVAILABLE", "Recording is unavailable on this device or has been disabled in Control Center") return } self.recorder.isMicrophoneEnabled = withMicrophone self.recorder.startRecording { error in if let error = error { promise.reject("E_RECORDING_FAILED", error.localizedDescription) } else { promise.resolve(["status": "recording"]) } } } // Stop recording — write to a temp file and return the URL AsyncFunction("stopRecording") { (promise: Promise) in let tempURL = FileManager.default.temporaryDirectory .appendingPathComponent("rork-recording-\(UUID().uuidString).mp4") self.recorder.stopRecording(withOutput: tempURL) { error in if let error = error { promise.reject("E_STOP_FAILED", error.localizedDescription) return } promise.resolve([ "status": "stopped", "uri": tempURL.absoluteString, "size": (try? FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] as? Int) ?? 0 ]) } } // Save to Photos AsyncFunction("saveToLibrary") { (uri: String, promise: Promise) in guard let url = URL(string: uri) else { promise.reject("E_INVALID_URI", "Invalid URI"); return } PHPhotoLibrary.shared().performChanges({ PHAssetCreationRequest.creationRequestForAssetFromVideo(atFileURL: url) }) { success, error in if success { promise.resolve(["saved": true]) } else { promise.reject("E_SAVE_FAILED", error?.localizedDescription ?? "Save failed") } } } }}
The recorder.isAvailable check at the top is critical. On simulators, low-end devices, or when a user has disabled recording in Control Center, you'll get false. Skipping this check and letting the OS error bubble up gives users a "mystery failure" that reviewers will flag.
Calling startRecording(true) triggers the system permission prompts for microphone and screen access. After approval, recording begins. stopRecording() writes an MP4 to the temp directory and returns the URI plus file size.
The Info.plist keys you cannot forget
If you skip these in app.config.ts or Info.plist, the system silently fails the recording attempt without ever showing a permission dialog. I've fallen into this trap myself.
// In app.config.ts under ios.infoPlist{ "ios": { "infoPlist": { "NSMicrophoneUsageDescription": "Captures your voice for in-app tutorial videos", "NSPhotoLibraryAddUsageDescription": "Saves recorded videos to your Photo Library" } }}
NSMicrophoneUsageDescription is only required when you actually request the mic, but I add it preemptively because future feature work nearly always lights it up. Be specific in the description—"used for the microphone" gets rejected. The reviewer wants to know why.
Android — MediaProjection plus a Foreground Service
Android is the harder of the two. MediaProjection has to run inside a Foreground Service, and if the service isn't declared, started, and stopped correctly, the OS treats it as misbehaving and kills it.
// android/src/main/java/expo/modules/screenrecorder/ExpoScreenRecorderModule.ktpackage expo.modules.screenrecorderimport android.app.Activityimport android.content.Contextimport android.content.Intentimport android.media.MediaRecorderimport android.media.projection.MediaProjectionimport android.media.projection.MediaProjectionManagerimport android.os.Buildimport expo.modules.kotlin.modules.Moduleimport expo.modules.kotlin.modules.ModuleDefinitionimport java.io.Fileclass ExpoScreenRecorderModule : Module() { private var mediaProjection: MediaProjection? = null private var mediaRecorder: MediaRecorder? = null private var outputFile: File? = null override fun definition() = ModuleDefinition { Name("ExpoScreenRecorder") // Show the system capture-permission dialog AsyncFunction("requestPermission") { promise: Promise -> val activity = appContext.activityProvider?.currentActivity ?: return@AsyncFunction promise.reject( "E_NO_ACTIVITY", "No Activity available", null) val manager = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager val intent = manager.createScreenCaptureIntent() // In a real implementation you'd wire this through onActivityResult // to actually receive the user's choice. Simplified here for clarity. activity.startActivityForResult(intent, REQUEST_CODE) promise.resolve(null) } // Start recording — kick off the Foreground Service first, then configure the recorder AsyncFunction("startRecording") { promise: Promise -> val context = appContext.reactContext ?: return@AsyncFunction promise.reject( "E_NO_CONTEXT", "No context", null) val cacheDir = context.cacheDir outputFile = File(cacheDir, "rork-recording-${System.currentTimeMillis()}.mp4") // Foreground Service is mandatory — without it Android 10+ refuses the projection val serviceIntent = Intent(context, ScreenRecorderService::class.java).apply { putExtra("outputPath", outputFile!!.absolutePath) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(serviceIntent) } else { context.startService(serviceIntent) } promise.resolve(mapOf("status" to "recording")) } AsyncFunction("stopRecording") { promise: Promise -> val context = appContext.reactContext ?: return@AsyncFunction promise.reject( "E_NO_CONTEXT", "No context", null) context.stopService(Intent(context, ScreenRecorderService::class.java)) promise.resolve(mapOf( "status" to "stopped", "uri" to (outputFile?.toURI()?.toString() ?: ""), "size" to (outputFile?.length() ?: 0L) )) } } companion object { const val REQUEST_CODE = 1001 }}
The key here is that we're starting a Foreground Service. You'll register a ScreenRecorderService class in AndroidManifest.xml, show a Notification, and run the MediaRecorder inside it. Starting with Android 14, you must also declare foregroundServiceType="mediaProjection".
Drop FOREGROUND_SERVICE_MEDIA_PROJECTION and Android 14+ will terminate your service immediately. This bug only reproduces on real Pixel 8-class hardware — emulators happily lie to you about it.
TypeScript wrappers and UI
With the native side wired up, the TypeScript layer becomes a thin, type-safe wrapper. The goal is a Promise-based API your Rork Max screens can call without thinking about platform quirks.
// src/lib/screen-recorder.tsimport ExpoScreenRecorder from "expo-screen-recorder";import { Platform } from "react-native";export type RecordingResult = { uri: string; size: number;};export async function startScreenRecording(options?: { withMicrophone?: boolean;}): Promise<void> { const withMic = options?.withMicrophone ?? false; if (Platform.OS === "ios") { await ExpoScreenRecorder.startRecording(withMic); } else if (Platform.OS === "android") { // On Android, prompt the user first await ExpoScreenRecorder.requestPermission(); // The service then starts after the system dialog returns success await ExpoScreenRecorder.startRecording(); } else { throw new Error("Screen recording is only supported on iOS and Android"); }}export async function stopScreenRecording(): Promise<RecordingResult> { const result = await ExpoScreenRecorder.stopRecording(); if (!result.uri) { throw new Error("Failed to write the recording file"); } return result;}export async function saveToLibrary(uri: string): Promise<boolean> { if (Platform.OS === "ios") { const result = await ExpoScreenRecorder.saveToLibrary(uri); return result.saved; } // Android needs MediaStore APIs; left out for brevity return false;}
On the UI side, manage the recording state locally and switch button affordances accordingly. The "you are being recorded" indicator must be persistently visible somewhere prominent — both Apple and Google enforce this in their review process.
Skip the "Recording" indicator and you'll get rejected by both Apple and Google. I once got back a review note that read: "The app does not provide a clear indication that screen recording is active." Use a blinking dot or a colored bar — whatever it takes to make the state unambiguous.
Broadcast Upload Extension — fighting the 50 MB memory ceiling
Once In-App Recording is solid, you can graduate to the Broadcast Upload Extension. This is what enables streaming to Twitch or YouTube — but the extension is hard-capped at 50 MB of memory.
In Xcode, choose File → New → Target → Broadcast Upload Extension. This generates a SampleHandler.swift skeleton, where you'll implement processSampleBuffer(_:with:).
// MyBroadcastExtension/SampleHandler.swiftimport ReplayKitclass SampleHandler: RPBroadcastSampleHandler { private let encoder = HEVCEncoder() // your own encoder private let uploader = StreamUploader() // your own RTMP/WebRTC uploader override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) { // Pull config from the main app via App Group let userDefaults = UserDefaults(suiteName: "group.com.rorklab.broadcast") guard let streamKey = userDefaults?.string(forKey: "streamKey") else { finishBroadcastWithError(NSError( domain: "BroadcastExt", code: 1, userInfo: [NSLocalizedDescriptionKey: "Stream key not found"])) return } uploader.connect(streamKey: streamKey) } override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { // Don't accumulate — encode and ship every frame switch sampleBufferType { case .video: encoder.encode(sampleBuffer) { [weak self] data in self?.uploader.send(data) } case .audioApp: uploader.sendAudio(sampleBuffer, type: .app) case .audioMic: uploader.sendAudio(sampleBuffer, type: .mic) @unknown default: break } } override func broadcastFinished() { uploader.disconnect() }}
The cardinal rule under the 50 MB cap: never hold onto sample buffers. My first attempt collected them in an Array thinking I'd batch-send them — the OOM crash arrived in seconds. Encode and forward, that's it.
In Xcode's Capabilities tab, add the same App Group (e.g., group.com.rorklab.broadcast) to both targets. From there UserDefaults and Keychain become your bridge between the main app and the extension.
Common pitfalls
1. "It works on the simulator" is a lie
ReplayKit and MediaProjection don't behave correctly on simulators or emulators — sometimes they don't run at all, sometimes the behavior diverges from devices. I shipped a build once trusting simulator results and watched it fail on every real iPhone in the field. Test on real hardware. Both an iPhone and a Pixel ideally.
2. Forgetting App Store Encryption Export Compliance
Adding broadcasting changes your answer to App Store Connect's encryption export question. Standard HTTPS for streaming is fine, but custom encryption requires an ERN (Encryption Registration Number). Skip this and review will block your build.
3. Adding mic permission as an afterthought → instant crash
If NSMicrophoneUsageDescription is missing and you call isMicrophoneEnabled = true, iOS will crash you without an error path. There is no graceful failure here. Add the key the moment you even consider using the mic.
4. Android service without a notification → death by RemoteServiceException
Since Android 8, a Foreground Service that doesn't show a notification is killed with RemoteServiceException. You must call startForeground(notificationId, notification) in onStartCommand. And on Android 8+ that notification needs a Channel ID, too.
5. Saving as .mov and breaking cross-platform sharing
iOS RPScreenRecorder happily writes .mov, but if your users send those files to Android or Windows recipients, playback often fails. Save as .mp4, and re-encode with AVAssetExportSession when you need broader compatibility.
Telemetry: knowing when your recording feature breaks in the wild
A subtle truth about screen recording features is that you only find out they're broken when users complain on the App Store. By then your rating has already dropped. The fix is to instrument the feature with enough telemetry to spot regressions before they hit your reviews.
The aim isn't an exhaustive log — it's enough signal that a sudden spike in permission_denied or device_unavailable shows up in your dashboard within hours, not weeks. Tie the events to OS version and device model in your analytics layer and you'll spot iOS or Android update breakages before users do.
File management: the unglamorous part that decides retention
Most recording-feature failures I've seen in production weren't crashes — they were quiet UX problems caused by neglected file management. Two patterns worth implementing from day one:
Stale recording cleanup on app start
Anything left in your temp directory from a prior session is dead weight. On app launch, sweep the directory and delete files older than 24 hours. This costs ~10ms but recovers gigabytes for users who hit "stop" without saving.
import * as FileSystem from "expo-file-system";export async function cleanupStaleRecordings() { const dir = FileSystem.cacheDirectory; if (!dir) return; const files = await FileSystem.readDirectoryAsync(dir); const cutoff = Date.now() - 24 * 60 * 60 * 1000; for (const file of files) { if (!file.startsWith("rork-recording-")) continue; const path = `${dir}${file}`; const info = await FileSystem.getInfoAsync(path); if (info.exists && info.modificationTime && info.modificationTime * 1000 < cutoff) { await FileSystem.deleteAsync(path, { idempotent: true }); } }}
Disk-space pre-check before starting
Recording for ten minutes can produce a 200 MB MP4. If the user has 50 MB free, your recording will fail mid-flight with no useful error. Check before starting and refuse politely.
The 25 MB-per-minute figure is a rough heuristic for default RPScreenRecorder bitrates. Tune it once you've measured real outputs from your app.
Choosing between RTMP and WebRTC for streaming
If you do graduate to broadcasting, the protocol decision matters more than most tutorials suggest. The short version:
RTMP: TCP-based, ~3-5 second latency, simple to implement, supported by every streaming platform (YouTube Live, Twitch, custom servers). Use it unless you have a reason not to
WebRTC: UDP-based, sub-second latency, designed for two-way calling. Heavier to implement and harder to scale to many viewers
Low-Latency HLS / DASH: Pull-based delivery formats — your Broadcast Extension still pushes via RTMP, your edge then re-encodes for delivery
For 99% of "let users stream their gameplay" features, RTMP is the right answer. Reach for WebRTC only when latency under one second materially changes the user experience (interactive co-watching, audience polling during a stream).
The piece that catches everyone the first time is clock skew. Your audio and video sample buffers arrive on different timelines, and if you don't align them at encode time, viewers will see lip-sync drift after a few minutes. The pattern that works: timestamp every buffer at receipt, encode using presentation timestamps from the sample buffer itself, and let the receiving server synchronize on output.
Testing strategy: what to verify before each release
Recording features have an unusual testing surface. Most of what breaks them is environmental — OS versions, device storage, permission state, network conditions during broadcast. A few practices keep regressions out of production releases:
Permission matrix: Before each release, manually walk through all four permission states on both platforms — granted, denied, revoked-after-grant, and never-prompted. Any state that wasn't explicitly tested is a state that will surprise you in the field.
Long-recording soak test: Run a single uninterrupted 30-minute recording and verify memory, battery, and storage at five-minute intervals. If memory climbs steadily during the soak, you have a buffer leak somewhere — usually in the audio path.
Background interruption: Start a recording, then take an incoming call, then return. iOS will pause and resume the recording; if your file doesn't reflect that gracefully (audio glitches, time stamps off by the call duration), you have an interruption handler missing.
Mid-recording orientation change: Rotate the device mid-recording. The output file should reflect the orientation present at the time of capture for each frame, not the orientation at start. RPScreenRecorder handles this for you on iOS; on Android, MediaProjection respects the size you configured at start, so plan accordingly.
Storage exhaustion mid-recording: Fill the device storage to ~95% manually, then start a five-minute recording. Your code should detect the failure, stop cleanly, and tell the user something useful — not produce a 0-byte file.
I'd skip running automated tests for these. The cost of building reliable harnesses for screen recording outweighs the value, and a 20-minute manual checklist catches more in practice. A simple Notion page with the steps and screenshots is enough.
Performance numbers from production apps
When I shipped recording features I was surprised by how much the actual numbers vary. For reference, these are observed averages across two of my apps over the past quarter, on iPhone 15-class hardware:
In-App Recording, no mic, 1080p: ~22 MB/minute, ~6% sustained CPU, ~2-3% battery per 10-minute session
In-App Recording, with mic, 1080p: ~24 MB/minute, ~9% sustained CPU, ~4% battery per 10-minute session
Broadcast Extension, 720p RTMP, with HEVC encode: peak memory 38 MB (well under the 50 MB cap), ~12% CPU on the extension process
Broadcast Extension, 1080p RTMP, with H.264 encode: peak memory 47 MB (one bad frame from the cap), ~16% CPU. Don't ship 1080p broadcasting — the headroom isn't there
On Android the variance is larger because hardware codecs differ wildly between SoCs. Snapdragon 8 Gen 2-class chips run 1080p MediaProjection comfortably at ~7% CPU; older Mediatek chips can sit at 25%+ for the same content. Set your default resolution conservatively and let users opt up if their device handles it.
The single biggest cost is the audio mic path — adding microphone capture roughly doubles CPU usage on iOS compared to silent recording. If your feature doesn't strictly need voice, default to off and let users opt in.
A note on accessibility and consent
One area that rarely gets the attention it deserves is consent — both from the person doing the recording and from anyone whose content might be captured. A few principles I've come to follow:
If your app shows other users' content (chat messages, profile photos, video calls), the recording feature must either blur those elements, mask them, or refuse to capture screens that show them. ReplayKit doesn't give you frame-level filtering, so the cleanest pattern is to gate recording so it only works on screens where the captured content is the user's own.
For accessibility users, the recording start gesture matters. A single button press is fine, but make sure it has a clear voice-over label ("Start recording. Captures the screen for sharing.") and that it isn't placed in a location that conflicts with assistive touch shortcuts. I've seen apps where the recording button overlapped with the iOS back-swipe target — frustrating for everyone.
The "Recording" indicator should also be communicable to screen readers. A red dot alone isn't enough — pair it with a hidden but VoiceOver-readable label like "Recording in progress." This is one of those small details that turns a feature from "works" into "works for everyone."
Three production principles
Implementation aside, keeping a recording feature stable in production demands a few mental rules. Three I've internalized over three apps:
Principle 1: Move recordings off-device fast, then delete the local copy
Recording files balloon storage usage. After the user saves to Photos or your backend, delete the cached copy. iOS will eventually clear /tmp on reboot, but cleaning up explicitly is more predictable.
Principle 2: Make battery cost visible
Screen recording lights up CPU, GPU, and storage I/O simultaneously — battery drains noticeably. Beyond the recording indicator, consider a gentle nudge after long sessions ("You've been recording for 30 minutes. Plug in?"). Users appreciate not being surprised by a dead battery.
Principle 3: Always have a fallback for failures
startRecording fails for many reasons: low disk, OS resource pressure, another app already recording. The raw error messages are inscrutable to end users. Translate them into actionable copy: "Free up some storage space" or "Check that screen recording isn't disabled in Control Center."
How this connects with other Rork features
A recording feature differentiates your app on the store, but on its own it doesn't drive revenue. Pair it with shareable links and tasteful gating to turn it into a growth lever.
The Rork Max in-app purchase guide covers StoreKit 2 subscriptions — a natural place to gate "unlimited recording length" for paying users.
For backend uploads of finished recordings, the Rork Max background processing guide walks through BGTaskScheduler and silent push to keep uploads going without keeping the UI alive.
Where to go next
The code in this article is on GitHub — clone the In-App Recording sample first and run it on a real device. Once you see it working you'll think "that wasn't so bad," but the moment you reach for the Broadcast Extension, complexity multiplies. In-App Recording handles 80% of real product needs. Layer on streaming only after you've heard your users explicitly ask for it.
Recording features can transform user experience, but they always carry privacy and performance costs. Before you ship, run a one-hour recording on a real device while watching memory, battery, and storage. Only when those numbers stay healthy is the feature actually production-ready.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.