lity, and developer ergonomics.
Step 1: Stream Acquisition & Constraint Negotiation
The entry point is getDisplayMedia(). Unlike getUserMedia(), which targets physical peripherals, this method surfaces the OS-level screen picker. Modern browsers support constraint objects that let you specify capture behavior programmatically.
interface CaptureConstraints {
video: {
displaySurface: 'monitor' | 'window' | 'browser';
width: number;
height: number;
frameRate: number;
};
audio: boolean;
}
const DEFAULT_CONSTRAINTS: CaptureConstraints = {
video: {
displaySurface: 'window',
width: 1920,
height: 1080,
frameRate: 30,
},
audio: true,
};
Rationale: Specifying displaySurface guides the browser's picker UI. While browsers ultimately respect user choice, hinting at window or browser reduces accidental full-desktop captures. Frame rate and resolution constraints prevent unnecessary encoding overhead when 1080p/30fps suffices for bug reproduction or workflow demos.
Step 2: Recording Engine Initialization
Once the stream is acquired, MediaRecorder handles the encoding pipeline. The engine must negotiate a supported MIME type, configure chunk intervals, and attach event listeners for data emission and stream termination.
class RecordingSession {
private recorder: MediaRecorder | null = null;
private chunks: Blob[] = [];
private stream: MediaStream | null = null;
constructor(private constraints: CaptureConstraints) {}
async start(): Promise<Blob> {
this.stream = await navigator.mediaDevices.getDisplayMedia({
video: this.constraints.video,
audio: this.constraints.audio,
});
const mimeType = this.resolveMimeType();
this.recorder = new MediaRecorder(this.stream, {
mimeType,
videoBitsPerSecond: 2500000,
});
this.recorder.ondataavailable = (event: BlobEvent) => {
if (event.data.size > 0) {
this.chunks.push(event.data);
}
};
this.recorder.start(500); // Emit chunks every 500ms
return new Promise((resolve, reject) => {
this.recorder!.onstop = () => {
const finalBlob = new Blob(this.chunks, { type: mimeType });
resolve(finalBlob);
this.cleanup();
};
this.recorder!.onerror = (err) => reject(err);
});
}
private resolveMimeType(): string {
const candidates = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
'video/mp4',
];
return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || 'video/webm';
}
stop(): void {
if (this.recorder?.state === 'recording') {
this.recorder.stop();
}
this.stream?.getTracks().forEach((track) => track.stop());
}
private cleanup(): void {
this.chunks = [];
this.recorder = null;
this.stream = null;
}
}
Rationale:
- Chunking every 500ms prevents memory accumulation. Large recordings would otherwise exhaust the heap if buffered entirely before encoding.
- MIME type negotiation ensures compatibility across Chromium, Firefox, and Safari. VP9/Opus is preferred for quality-to-size ratio, but fallbacks prevent silent failures.
- Explicit track termination guarantees OS-level capture indicators disappear immediately.
Step 3: Global Shortcut Integration
Developer workflows demand instant invocation. Mapping the capture lifecycle to a keyboard shortcut requires careful event delegation to avoid conflicts with IDE shortcuts or browser devtools.
class ShortcutController {
private isActive = false;
constructor(private session: RecordingSession) {
window.addEventListener('keydown', this.handleKeyDown);
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.altKey && e.key.toLowerCase() === 'r') {
e.preventDefault();
this.toggleCapture();
}
};
private async toggleCapture(): Promise<void> {
if (this.isActive) {
this.session.stop();
this.isActive = false;
return;
}
try {
this.isActive = true;
const blob = await this.session.start();
this.downloadBlob(blob);
} catch (err) {
console.warn('Capture aborted or denied:', err);
} finally {
this.isActive = false;
}
}
private downloadBlob(blob: Blob): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `capture_${Date.now()}.webm`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
}
}
Rationale: preventDefault() stops the browser from interpreting Alt+R as a native shortcut. The finally block guarantees state reset even if permission is denied or the user cancels the picker. Object URL creation and immediate revocation prevent memory leaks while enabling instant local download without server roundtrips.
Pitfall Guide
Browser media APIs are powerful but unforgiving when lifecycle management is neglected. The following pitfalls represent the most common production failures observed during implementation.
1. Ignoring Stream Termination Events
Explanation: Browsers automatically stop the stream when the user clicks the OS-level "Stop Sharing" button. If your code doesn't listen for onstop or track.ended, the UI remains in a "recording" state indefinitely.
Fix: Attach stream.getTracks().forEach(t => t.addEventListener('ended', () => this.stop())) and synchronize UI state with the recorder's state property.
2. Misconfiguring Audio Constraints
Explanation: audio: true behaves inconsistently across browsers. Firefox requires explicit system audio capture permissions, while Chromium may capture tab audio only if displaySurface is set to browser.
Fix: Detect browser engine and adjust constraints dynamically. Provide a fallback UI that explicitly requests microphone input if system audio is unavailable.
3. Memory Leaks from Unbounded Chunk Arrays
Explanation: Storing every BlobEvent data chunk in a single array without periodic cleanup or size limits will crash the tab during long recordings (>15 minutes).
Fix: Implement a circular buffer or flush chunks to IndexedDB when the array exceeds a threshold (e.g., 50 chunks). Alternatively, rely on MediaRecorder's built-in chunking and only assemble the final blob on stop.
4. Overlooking MIME Type Support
Explanation: Assuming video/mp4 works everywhere leads to silent recording failures. Safari supports MP4, but Firefox and older Chromium versions prefer WebM with VP8/VP9.
Fix: Always run MediaRecorder.isTypeSupported() before instantiation. Never hardcode a single MIME type.
5. Failing to Handle Permission Revocation Gracefully
Explanation: Users can revoke screen sharing permissions mid-capture via browser settings. Unhandled revocation throws uncaught exceptions and breaks the recording promise.
Fix: Wrap getDisplayMedia() in a try/catch. Listen for NotAllowedError and display a non-blocking toast notification explaining the revocation.
6. Blocking the Main Thread During Blob Assembly
Explanation: Concatenating dozens of megabytes of chunks synchronously freezes the UI, especially on lower-end devices.
Fix: Use new Blob(chunks, { type }) which is optimized in modern engines, or offload assembly to a Web Worker if processing exceeds 50MB.
Explanation: The displaySurface constraint is a hint, not a guarantee. Browsers may override it based on user preference or security policies.
Fix: Never enforce strict surface matching. Design the UI to handle monitor, window, and tab captures uniformly. Validate the actual track.getSettings().displaySurface post-acquisition if routing logic depends on it.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Quick bug reproduction / PR review | Browser-native API | Zero install, instant capture, local processing | $0, no infrastructure |
| Multi-track editing / post-production | Desktop suite (OBS, Camtasia) | Requires timeline editing, overlays, and advanced audio mixing | Licensing or hardware investment |
| Enterprise compliance / audit trails | Server-side capture + secure upload | Requires centralized storage, access controls, and retention policies | Cloud storage + compliance overhead |
| Low-end hardware / thin clients | Browser-native API | Reuses existing rendering pipeline, minimal CPU footprint | $0, scales with browser performance |
Configuration Template
// capture.config.ts
export const CAPTURE_CONFIG = {
constraints: {
video: {
displaySurface: 'window' as const,
width: 1920,
height: 1080,
frameRate: 30,
},
audio: true,
},
recorder: {
mimeType: 'video/webm;codecs=vp9,opus',
videoBitsPerSecond: 2500000,
timeslice: 500,
},
shortcut: {
key: 'r',
modifiers: ['alt'],
preventDefault: true,
},
storage: {
maxChunkCount: 100,
autoDownload: true,
filenamePrefix: 'dev_capture',
},
};
Quick Start Guide
- Initialize the engine: Import the configuration and instantiate
RecordingSession with your constraints.
- Wire the shortcut: Attach
ShortcutController to your application root or browser extension background script.
- Handle the output: The
start() method resolves to a Blob. Use the provided download utility or pipe it to your internal asset pipeline.
- Test permission flows: Run the capture in incognito/private mode to verify that permission prompts and revocation handling behave predictably.
- Deploy: Ship as a browser extension, embedded web app, or IDE plugin. No native binaries or installers required.