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
- Canvas operations — image conversion, resizing, watermarking
- Clipboard access — copying images to system clipboard
- Audio playback — notification sounds
- Parsing HTML/XML — using DOMParser in a real DOM context
// 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
- Singleton offscreen — always use
getContexts()before creating; never assume in-memory state survives SW restart - Auto-destroy — close Offscreen Document after idle period; don't leak memory
- Chunked batch processing — max 5 images per chunk; destroy offscreen between chunks
- Explicit cleanup — call
bitmap.close()and clear canvas contexts infinallyblocks - Large image tiling — tile-based processing for images exceeding 4096px
- Clipboard fallback — check permission state before writing; offer download as fallback
- Progress reporting — store progress in
storage.sessionso popup can read it even if reopened - CDP cleanup — always
chrome.debugger.detach()infinallyblocks; lingering debugger sessions show warning bars - Format compatibility — clipboard only accepts PNG; convert before writing
- Error boundaries — wrap each image in try/catch; one failure shouldn't abort the entire batch