Back to Blog

Local-Only Image Processing Architecture via Offscreen Documents

July 2026~13 min read

Why Offscreen Documents Exist

In Manifest V3, Chrome made a critical architectural decision: Service Workers have no DOM access. This means no document, no window, no Canvas, no Audio, no clipboard API. For any extension that needs to manipulate media, this is a showstopper.

The Offscreen Document was introduced as the bridge between the headless Service Worker and DOM-dependent APIs. It's a hidden page that runs in a special context—visible to the extension, invisible to the user.

The Problem

// Service Worker — this will throw
const canvas = document.createElement('canvas'); // ❌ ReferenceError: document is not defined

// Even indirect DOM access fails
const img = new Image(); // ❌ Image is not defined in SW context

What Offscreen Enables

// background.js (Service Worker)
async function processImage(blob) {
  // Can't use Canvas here — delegate to offscreen
  await ensureOffscreenDocument();
  const result = await chrome.runtime.sendMessage({
    type: 'PROCESS_IMAGE',
    blob: blob
  });
  return result;
}

// offscreen.js (Offscreen Document)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'PROCESS_IMAGE') {
    convertImage(msg.blob).then(sendResponse);
    return true; // async response
  }
});

Offscreen Document Lifecycle

Chrome enforces a hard limit: only one Offscreen Document per extension at any time. Violating this throws "Only a single offscreen document may be created." You must manage the lifecycle explicitly.

Singleton Pattern

// background.js — singleton offscreen manager
class OffscreenManager {
  static #exists = false;

  static async ensure() {
    if (this.#exists) return;

    try {
      // Check if document already exists (e.g., after SW restart)
      const existingContexts = await chrome.runtime.getContexts({
        contextTypes: ['OFFSCREEN_DOCUMENT']
      });

      if (existingContexts.length > 0) {
        this.#exists = true;
        return;
      }

      await chrome.offscreen.createDocument({
        url: 'offscreen.html',
        reasons: ['DOM_PARSER', 'BLOBS', 'CLIPBOARD'],
        justification: 'Image processing and clipboard operations'
      });
      this.#exists = true;
    } catch (err) {
      if (!err.message.includes('already exists')) throw err;
      this.#exists = true;
    }
  }

  static async destroy() {
    if (!this.#exists) return;
    try {
      await chrome.offscreen.closeDocument();
    } catch (e) {
      // Document may already be closed
    }
    this.#exists = false;
  }
}

Auto-Destroy on Idle

Don't leave the Offscreen Document running indefinitely—it consumes memory. Destroy it after a period of inactivity:

// Auto-destroy after 5 seconds of no activity
let idleTimer = null;

async function withOffscreen(fn) {
  await OffscreenManager.ensure();
  clearTimeout(idleTimer);

  try {
    return await fn();
  } finally {
    idleTimer = setTimeout(() => OffscreenManager.destroy(), 5000);
  }
}

// Usage
const result = await withOffscreen(() =>
  chrome.runtime.sendMessage({ type: 'CONVERT_IMAGE', data })
);

Handling SW Restart Edge Cases

The Service Worker can die while the Offscreen Document is still alive. On restart, the #exists flag resets to false. The getContexts check handles this—always query Chrome rather than trusting your in-memory state.

// Robust check that survives SW restart
async function isOffscreenAlive() {
  try {
    const contexts = await chrome.runtime.getContexts({
      contextTypes: ['OFFSCREEN_DOCUMENT']
    });
    return contexts.length > 0;
  } catch {
    // getContexts not available in older Chrome versions
    // Fallback: try sending a ping
    try {
      await chrome.runtime.sendMessage({ type: 'PING_OFFSCREEN' });
      return true;
    } catch {
      return false;
    }
  }
}

Canvas-Based Image Conversion (No Upload)

Here's the complete pipeline for converting images entirely locally. No server calls, no cloud APIs—just Canvas and Blob.

Core Conversion Pipeline

// offscreen.js — full conversion engine
class ImageConverter {
  static async convert(inputBlob, targetFormat, quality = 0.92) {
    const bitmap = await createImageBitmap(inputBlob);
    const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
    const ctx = canvas.getContext('2d');

    // Draw with white background for JPG (no alpha)
    if (targetFormat === 'image/jpeg') {
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
    }

    ctx.drawImage(bitmap, 0, 0);
    bitmap.close(); // Free GPU memory immediately

    const mimeMap = {
      'png': 'image/png',
      'jpg': 'image/jpeg',
      'jpeg': 'image/jpeg',
      'webp': 'image/webp',
      'avif': 'image/avif'
    };

    const mime = mimeMap[targetFormat] || 'image/png';
    const blob = await canvas.convertToBlob({
      type: mime,
      quality: mime === 'image/png' ? undefined : quality
    });

    return blob;
  }

  static async resize(inputBlob, maxWidth, maxHeight) {
    const bitmap = await createImageBitmap(inputBlob);
    let { width, height } = bitmap;

    // Maintain aspect ratio
    if (width > maxWidth || height > maxHeight) {
      const ratio = Math.min(maxWidth / width, maxHeight / height);
      width = Math.round(width * ratio);
      height = Math.round(height * ratio);
    }

    const canvas = new OffscreenCanvas(width, height);
    const ctx = canvas.getContext('2d');

    // High-quality downscaling
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    ctx.drawImage(bitmap, 0, 0, width, height);
    bitmap.close();

    return canvas.convertToBlob({ type: 'image/png' });
  }
}

Handling Large Images (>4000px)

Browsers impose canvas size limits (typically 16384x16384, but practical limits are lower due to memory). For very large images, process in tiles:

static async convertLarge(inputBlob, targetFormat, quality = 0.92) {
  const bitmap = await createImageBitmap(inputBlob);
  const { width, height } = bitmap;

  // Check if we need tiled processing
  const MAX_DIM = 4096;
  if (width <= MAX_DIM && height <= MAX_DIM) {
    return this.convert(inputBlob, targetFormat, quality);
  }

  // Tile-based processing
  const tilesX = Math.ceil(width / MAX_DIM);
  const tilesY = Math.ceil(height / MAX_DIM);
  const canvas = new OffscreenCanvas(width, height);
  const ctx = canvas.getContext('2d');

  for (let ty = 0; ty < tilesY; ty++) {
    for (let tx = 0; tx < tilesX; tx++) {
      const sx = tx * MAX_DIM;
      const sy = ty * MAX_DIM;
      const sw = Math.min(MAX_DIM, width - sx);
      const sh = Math.min(MAX_DIM, height - sy);

      // Draw tile from source bitmap
      ctx.drawImage(bitmap, sx, sy, sw, sh, sx, sy, sw, sh);
    }
  }

  bitmap.close();

  const mime = targetFormat === 'png' ? 'image/png' :
               targetFormat === 'webp' ? 'image/webp' :
               targetFormat === 'avif' ? 'image/avif' : 'image/jpeg';

  return canvas.convertToBlob({ type: mime, quality });
}

Format-Specific Quality Control

// Quality presets for different use cases
const QUALITY_PRESETS = {
  'web-optimized': { format: 'webp', quality: 0.82 },
  'high-quality':  { format: 'png',  quality: 1.0 },
  'thumbnail':     { format: 'webp', quality: 0.60 },
  'print-ready':   { format: 'png',  quality: 1.0 },
  'archive':       { format: 'avif', quality: 0.75 }
};

// Apply preset
async function convertWithPreset(blob, presetName) {
  const preset = QUALITY_PRESETS[presetName];
  if (!preset) throw new Error(`Unknown preset: ${presetName}`);

  // Ensure PNG stays lossless
  const q = preset.format === 'png' ? undefined : preset.quality;
  return ImageConverter.convert(blob, preset.format, q);
}

CDP Full-Page Screenshots (No Third-Party Services)

Chrome's chrome.debugger API exposes the Chrome DevTools Protocol (CDP), which can capture full-page screenshots beyond the visible viewport—entirely locally.

Basic Full-Page Capture

// background.js — CDP screenshot via chrome.debugger
class FullPageScreenshot {
  static async capture(tabId) {
    // Attach debugger
    await chrome.debugger.attach({ tabId }, '1.3');

    try {
      // Get full page dimensions
      const { result: layoutMetrics } = await chrome.debugger.sendCommand(
        { tabId }, 'Page.getLayoutMetrics'
      );

      const { contentSize } = layoutMetrics;
      const width = Math.ceil(contentSize.width);
      const height = Math.ceil(contentSize.height);

      // Capture beyond viewport
      const { data } = await chrome.debugger.sendCommand(
        { tabId }, 'Page.captureScreenshot',
        {
          format: 'png',
          captureBeyondViewport: true,
          clip: {
            x: 0,
            y: 0,
            width,
            height,
            scale: 1
          }
        }
      );

      // Convert base64 to Blob
      const binary = atob(data);
      const bytes = new Uint8Array(binary.length);
      for (let i = 0; i < binary.length; i++) {
        bytes[i] = binary.charCodeAt(i);
      }

      return new Blob([bytes], { type: 'image/png' });
    } finally {
      await chrome.debugger.detach({ tabId });
    }
  }
}

Handling Fixed/Sticky Elements

Full-page captures duplicate fixed/sticky headers and footers. Use CDP to temporarily remove them:

static async captureClean(tabId) {
  await chrome.debugger.attach({ tabId }, '1.3');

  try {
    // Hide fixed/sticky elements via CDP
    await chrome.debugger.sendCommand(
      { tabId }, 'Runtime.evaluate',
      {
        expression: `
          (function() {
            const els = document.querySelectorAll('*');
            const hidden = [];
            for (const el of els) {
              const style = getComputedStyle(el);
              if (style.position === 'fixed' || style.position === 'sticky') {
                el.dataset._origVisibility = style.visibility;
                el.style.visibility = 'hidden';
                hidden.push(el);
              }
            }
            window.__hiddenFixed = hidden;
            return hidden.length;
          })()
        `
      }
    );

    // Capture
    const blob = await this.capture(tabId);

    // Restore fixed elements
    await chrome.debugger.sendCommand(
      { tabId }, 'Runtime.evaluate',
      {
        expression: `
          (function() {
            if (window.__hiddenFixed) {
              for (const el of window.__hiddenFixed) {
                el.style.visibility = el.dataset._origVisibility || '';
                delete el.dataset._origVisibility;
              }
              delete window.__hiddenFixed;
            }
          })()
        `
      }
    );

    return blob;
  } finally {
    await chrome.debugger.detach({ tabId });
  }
}

Stitched Viewport Capture (Fallback)

Some pages block captureBeyondViewport. In that case, stitch multiple viewport-height captures:

static async captureStitched(tabId) {
  await chrome.debugger.attach({ tabId }, '1.3');

  try {
    const { result: metrics } = await chrome.debugger.sendCommand(
      { tabId }, 'Page.getLayoutMetrics'
    );

    const fullWidth = Math.ceil(metrics.contentSize.width);
    const fullHeight = Math.ceil(metrics.contentSize.height);
    const viewportHeight = Math.ceil(metrics.visualViewport.height);

    // Get current scroll position to restore later
    const { result: scrollResult } = await chrome.debugger.sendCommand(
      { tabId }, 'Runtime.evaluate',
      { expression: '({ x: window.scrollX, y: window.scrollY })' }
    );
    const originalScroll = JSON.parse(scrollResult.value);

    const canvas = new OffscreenCanvas(fullWidth, fullHeight);
    const ctx = canvas.getContext('2d');

    for (let y = 0; y < fullHeight; y += viewportHeight) {
      // Scroll to position
      await chrome.debugger.sendCommand(
        { tabId }, 'Runtime.evaluate',
        { expression: `window.scrollTo(0, ${y})` }
      );
      // Wait for render
      await new Promise(r => setTimeout(r, 150));

      const { data } = await chrome.debugger.sendCommand(
        { tabId }, 'Page.captureScreenshot',
        { format: 'png' }
      );

      const chunkBlob = base64ToBlob(data, 'image/png');
      const chunkBitmap = await createImageBitmap(chunkBlob);
      const drawHeight = Math.min(viewportHeight, fullHeight - y);
      ctx.drawImage(chunkBitmap, 0, 0, fullWidth, drawHeight, 0, y, fullWidth, drawHeight);
      chunkBitmap.close();

      // Report progress
      chrome.runtime.sendMessage({
        type: 'SCREENSHOT_PROGRESS',
        current: Math.min(y + viewportHeight, fullHeight),
        total: fullHeight
      });
    }

    // Restore scroll
    await chrome.debugger.sendCommand(
      { tabId }, 'Runtime.evaluate',
      { expression: `window.scrollTo(${originalScroll.x}, ${originalScroll.y})` }
    );

    return canvas.convertToBlob({ type: 'image/png' });
  } finally {
    await chrome.debugger.detach({ tabId });
  }
}

Clipboard Operations Without Network

Copying images to the system clipboard requires DOM access—another job for Offscreen Documents.

Copy Image to Clipboard

// offscreen.js
async function copyImageToClipboard(blob) {
  // Ensure we have a PNG for clipboard (most compatible)
  let pngBlob = blob;
  if (blob.type !== 'image/png') {
    pngBlob = await ImageConverter.convert(blob, 'png');
  }

  try {
    await navigator.clipboard.write([
      new ClipboardItem({
        'image/png': pngBlob
      })
    ]);
    return { success: true };
  } catch (err) {
    // Permission denied — need user gesture in the offscreen context
    return { success: false, error: err.message };
  }
}

// Listen for requests from SW
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'COPY_TO_CLIPBOARD') {
    // Reconstruct blob from transferred data
    const blob = new Blob([msg.buffer], { type: msg.mimeType });
    copyImageToClipboard(blob).then(sendResponse);
    return true;
  }
});

Handling Permission Prompts

// Check clipboard permission state before attempting write
async function checkClipboardPermission() {
  try {
    const result = await navigator.permissions.query({ name: 'clipboard-write' });
    return result.state; // 'granted', 'denied', or 'prompt'
  } catch {
    return 'prompt'; // Assume prompt if API unavailable
  }
}

// Enhanced copy with permission handling
async function copyImageWithFallback(blob) {
  const permission = await checkClipboardPermission();

  if (permission === 'denied') {
    return {
      success: false,
      error: 'Clipboard permission denied. Check browser settings.',
      fallback: 'download' // Signal to SW: offer download instead
    };
  }

  return copyImageToClipboard(blob);
}

Batch Processing Memory Management

Processing dozens of images in parallel will crash the browser. Use chunked processing with explicit memory management.

Chunked Batch Processor

// background.js — orchestrates batch processing via offscreen
class BatchProcessor {
  static CHUNK_SIZE = 5; // Process 5 images at a time

  static async processBatch(items, transform) {
    const results = [];
    const total = items.length;

    for (let i = 0; i < total; i += this.CHUNK_SIZE) {
      const chunk = items.slice(i, i + this.CHUNK_SIZE);

      // Ensure offscreen exists for this chunk
      await OffscreenManager.ensure();

      const chunkResults = await Promise.all(
        chunk.map(item => this.processOne(item, transform))
      );

      results.push(...chunkResults);

      // Report progress
      chrome.runtime.sendMessage({
        type: 'BATCH_PROGRESS',
        completed: Math.min(i + this.CHUNK_SIZE, total),
        total,
        results: chunkResults
      }).catch(() => {
        // Popup may be closed — progress stored in session
        chrome.storage.session.set({
          batchProgress: {
            completed: Math.min(i + this.CHUNK_SIZE, total),
            total
          }
        });
      });

      // Release memory between chunks
      if (i + this.CHUNK_SIZE < total) {
        await OffscreenManager.destroy();
        // Brief pause for GC
        await new Promise(r => setTimeout(r, 100));
      }
    }

    return results;
  }

  static async processOne(item, transform) {
    return chrome.runtime.sendMessage({
      type: 'PROCESS_SINGLE',
      data: item,
      transform
    });
  }
}

Offscreen-Side Memory Management

// offscreen.js — memory-conscious image processing
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'PROCESS_SINGLE') {
    processSafely(msg.data, msg.transform).then(sendResponse);
    return true;
  }
});

async function processSafely(data, transform) {
  let bitmap = null;
  let canvas = null;

  try {
    const blob = new Blob([data.buffer], { type: data.mimeType });
    bitmap = await createImageBitmap(blob);

    canvas = new OffscreenCanvas(
      transform.width || bitmap.width,
      transform.height || bitmap.height
    );
    const ctx = canvas.getContext('2d');

    // Apply transformations
    ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);

    // Convert and return
    const resultBlob = await canvas.convertToBlob({
      type: transform.format || 'image/webp',
      quality: transform.quality || 0.85
    });

    const buffer = await resultBlob.arrayBuffer();
    return {
      success: true,
      buffer,
      mimeType: resultBlob.type,
      width: canvas.width,
      height: canvas.height
    };
  } catch (err) {
    return { success: false, error: err.message };
  } finally {
    // Explicit cleanup
    if (bitmap) bitmap.close();
    if (canvas) {
      const ctx = canvas.getContext('2d');
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    }
  }
}

Progress Reporting to Popup

// popup.js — display batch progress
function listenForProgress() {
  chrome.runtime.onMessage.addListener((msg) => {
    if (msg.type === 'BATCH_PROGRESS') {
      const pct = Math.round((msg.completed / msg.total) * 100);
      document.getElementById('progress-bar').style.width = `${pct}%`;
      document.getElementById('progress-text').textContent =
        `${msg.completed} / ${msg.total} images`;
    }
  });

  // Also check stored progress (in case popup opened mid-batch)
  chrome.storage.session.get('batchProgress').then(({ batchProgress }) => {
    if (batchProgress) {
      const pct = Math.round((batchProgress.completed / batchProgress.total) * 100);
      document.getElementById('progress-bar').style.width = `${pct}%`;
      document.getElementById('progress-text').textContent =
        `${batchProgress.completed} / ${batchProgress.total} images`;
    }
  });
}

Complete Architecture Diagram

Here's the full data flow for a zero-network image processing pipeline:

┌─────────────────────────────────────────────────────────────────────┐
│                        ZERO-NETWORK PIPELINE                        │
│                                                                     │
│  ┌──────────┐    ┌──────────────────┐    ┌───────────────────────┐ │
│  │  Popup   │───▶│ Service Worker   │───▶│ Offscreen Document   │ │
│  │  (UI)    │◀───│ (Orchestrator)   │◀───│ (DOM + Canvas)       │ │
│  └──────────┘    └──────────────────┘    └───────────────────────┘ │
│       │                  │                         │                │
│       │                  │                         │                │
│       ▼                  ▼                         ▼                │
│  ┌──────────┐    ┌──────────────────┐    ┌───────────────────────┐ │
│  │  chrome  │    │  chrome.storage  │    │  Canvas API           │ │
│  │  .downloads│  │  .session        │    │  OffscreenCanvas      │ │
│  │  (save)  │    │  (progress)      │    │  createImageBitmap    │ │
│  └──────────┘    └──────────────────┘    └───────────────────────┘ │
│                                                                     │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │              chrome.debugger (CDP)                           │  │
│  │  Page.captureScreenshot → base64 → Blob → Canvas → download │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                     │
│  ══════════════════════════════════════════════════════════════════ │
│  NETWORK REQUESTS: 0    CLOUD APIs: 0    THIRD-PARTY SERVICES: 0  │
└─────────────────────────────────────────────────────────────────────┘

Message Flow Example

// 1. User clicks "Convert" in popup
popup.js → chrome.runtime.sendMessage({ type: 'CONVERT', files, format: 'webp' })

// 2. Service Worker orchestrates
background.js:
  receive message → load files from chrome.downloads →
  BatchProcessor.processBatch(files, { format: 'webp', quality: 0.85 })

// 3. Each chunk goes to offscreen
background.js → chrome.runtime.sendMessage({ type: 'PROCESS_SINGLE', ... })

// 4. Offscreen does the actual work
offscreen.js:
  createImageBitmap → OffscreenCanvas → drawImage → convertToBlob

// 5. Results flow back
offscreen.js → sendResponse({ buffer, mimeType })
background.js → chrome.downloads.download({ url: URL.createObjectURL(blob) })

// 6. Popup shows completion
background.js → chrome.runtime.sendMessage({ type: 'BATCH_COMPLETE' })

Production Checklist

  1. Singleton offscreen — always use getContexts() before creating; never assume in-memory state survives SW restart
  2. Auto-destroy — close Offscreen Document after idle period; don't leak memory
  3. Chunked batch processing — max 5 images per chunk; destroy offscreen between chunks
  4. Explicit cleanup — call bitmap.close() and clear canvas contexts in finally blocks
  5. Large image tiling — tile-based processing for images exceeding 4096px
  6. Clipboard fallback — check permission state before writing; offer download as fallback
  7. Progress reporting — store progress in storage.session so popup can read it even if reopened
  8. CDP cleanup — always chrome.debugger.detach() in finally blocks; lingering debugger sessions show warning bars
  9. Format compatibility — clipboard only accepts PNG; convert before writing
  10. Error boundaries — wrap each image in try/catch; one failure shouldn't abort the entire batch

References