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:

  1. Server-side - Use Puppeteer/Playwright to render and capture on the server
  2. html2canvas - Frontend library that converts DOM to Canvas
  3. 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:

  1. Video not playing: Must call video.play() before capturing
  2. CORS restrictions: If video has crossorigin="anonymous", drawing might fail
  3. 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