Browser-Based Web Screenshots: From getDisplayMedia API to Canvas Implementation
Browser-Based Web Screenshots: From getDisplayMedia API to Canvas Implementation#
I recently built a web screenshot tool, thinking it’d be straightforward. Spoiler: I hit several gotchas. Here’s what I learned, in case you’re tackling the same problem.
Choosing a Screenshot Approach#
There are three common approaches for web screenshots:
- Server-side - Use Puppeteer/Playwright to render and capture on the server
- html2canvas - Frontend library that converts DOM to Canvas
- getDisplayMedia API - Native browser screen capture API
Each has trade-offs:
| Approach | Pros | Cons |
|---|---|---|
| Puppeteer | Powerful, handles complex pages | Requires server, adds latency |
| html2canvas | Pure frontend, no permissions | CORS issues, styling differences |
| getDisplayMedia | Native, real-time preview | Requires user authorization, not automatable |
I chose getDisplayMedia API. While it needs user permission, it’s simple to implement, works with any webpage, and captures what users actually see.
Core Implementation with getDisplayMedia#
This API lives under navigator.mediaDevices, sharing heritage with camera/microphone APIs:
const captureScreenshot = async () => {
try {
// Request screen sharing permission
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'browser' // Prefer browser tab
}
})
// Create video element to play the stream
const video = document.createElement('video')
video.srcObject = stream
// Wait for video metadata to load
await new Promise((resolve) => {
video.onloadedmetadata = () => {
video.play()
resolve(null)
}
})
// Draw to Canvas
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')!
ctx.drawImage(video, 0, 0)
// Stop the stream (release resources)
stream.getTracks().forEach(track => track.stop())
// Export as image
const dataUrl = canvas.toDataURL('image/png', 0.9)
return dataUrl
} catch (error) {
console.error('Screenshot failed:', error)
throw error
}
}
Key Parameters Explained#
displaySurface option:
'browser'- Browser tab (recommended)'window'- Application window'monitor'- Entire screen
Users can choose what to share in the permission dialog. This parameter just suggests a preference.
Video constraints:
await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'browser',
width: { ideal: 1920 }, // Desired width
height: { ideal: 1080 }, // Desired height
frameRate: { ideal: 30 } // Framerate (useful for video streams)
}
})
Note these are “ideal” values. Actual resolution depends on what the user selects.
Canvas Drawing and Image Export#
Canvas drawImage is the core:
ctx.drawImage(
video, // Source: video element
0, 0, // Destination position
canvas.width, // Destination width
canvas.height // Destination height
)
toDataURL format options:
// PNG (lossless, larger files)
canvas.toDataURL('image/png')
// JPEG (lossy compression, smaller files)
canvas.toDataURL('image/jpeg', 0.9) // Second param is quality (0-1)
// WebP (recommended, balanced quality and size)
canvas.toDataURL('image/webp', 0.9)
Quality parameter recommendations:
- PNG: No quality param needed (lossless)
- JPEG: 0.8-0.9 balances quality and size
- WebP: 0.8-0.9 same as JPEG
User Authorization and Error Handling#
Permission Denied#
try {
const stream = await navigator.mediaDevices.getDisplayMedia({...})
} catch (error) {
if (error.name === 'NotAllowedError') {
// User cancelled the permission dialog
console.log('User denied screen sharing permission')
} else if (error.name === 'NotSupportedError') {
// Browser doesn't support it
console.log('Browser does not support screen capture')
}
}
Browser Compatibility#
// Check if API is available
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
alert('Your browser does not support screen capture. Use Chrome/Edge/Firefox')
return
}
Support status:
- Chrome 94+
- Edge 94+
- Firefox 66+
- Safari 13+ (partial support)
Complete Screenshot Tool Implementation#
Based on the principles above, here’s a complete implementation:
interface ScreenshotOptions {
format: 'png' | 'jpeg' | 'webp'
quality: number // 0-1
}
async function takeScreenshot(options: ScreenshotOptions) {
// 1. Check API support
if (!navigator.mediaDevices?.getDisplayMedia) {
throw new Error('Browser does not support screen capture')
}
// 2. Request permission
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' }
})
// 3. Create video and wait for load
const video = document.createElement('video')
video.srcObject = stream
await new Promise<void>((resolve) => {
video.onloadedmetadata = () => {
video.play()
resolve()
}
})
// 4. Draw to Canvas
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')!
ctx.drawImage(video, 0, 0)
// 5. Release resources (important!)
stream.getTracks().forEach(track => track.stop())
video.srcObject = null
// 6. Export image
return canvas.toDataURL(`image/${options.format}`, options.quality)
}
// Usage example
try {
const dataUrl = await takeScreenshot({
format: 'webp',
quality: 0.9
})
// Download
const a = document.createElement('a')
a.href = dataUrl
a.download = `screenshot-${Date.now()}.webp`
a.click()
} catch (error) {
console.error('Screenshot failed:', error)
}
Gotchas I Encountered#
Gotcha 1: Forgetting to Release the Stream#
// ❌ Wrong: Stream not released, resource leak
const stream = await getDisplayMedia({...})
// ... used but never stopped
// ✅ Correct: Stop immediately after use
stream.getTracks().forEach(track => track.stop())
If not released, the browser shows “Sharing screen” indicator, confusing users.
Gotcha 2: Blurry Screenshots on High DPI#
// ❌ Problem: High DPI screens get blurry screenshots
canvas.width = video.videoWidth // Might be logical pixels
// ✅ Solution: Use devicePixelRatio
const dpr = window.devicePixelRatio || 1
canvas.width = video.videoWidth * dpr
canvas.height = video.videoHeight * dpr
ctx.scale(dpr, dpr)
ctx.drawImage(video, 0, 0, canvas.width / dpr, canvas.height / dpr)
Wait, getDisplayMedia already returns physical pixels in videoWidth. No need to multiply by DPR. I wasted 30 minutes on this.
Gotcha 3: Black Screen#
Sometimes drawImage produces a black screen:
- Video not playing: Must call
video.play()before capturing - CORS restrictions: If video has
crossorigin="anonymous", drawing might fail - DRM content: Copyrighted content shows as black
Solutions:
// Ensure video is playing
await new Promise<void>((resolve) => {
video.onloadeddata = () => resolve()
video.play()
})
// Slight delay to ensure first frame is rendered
await new Promise(resolve => setTimeout(resolve, 100))
ctx.drawImage(video, 0, 0)
Use Cases and Limitations#
Good for:
- User-initiated screenshot tools
- Demo/tutorial scenarios
- User feedback screenshots
Not good for:
- Automated batch screenshots (use Puppeteer)
- Background captures without user interaction
- Capturing specific elements (use html2canvas)
The Result#
Based on this principle, I built: Web Screenshot Tool
Features:
- Capture any webpage
- PNG/JPEG/WebP formats
- Adjustable image quality
- Real-time preview
Implementation isn’t complex, but polishing the user experience requires attention to detail. Hope this helps.
Related: Image Filter | QR Code Generator