Offscreen Documents in Chrome Extensions — Complete Guide
25 min readOffscreen Documents in Chrome Extensions
Overview
Manifest V3 introduced service workers as the replacement for background pages, bringing significant benefits in memory efficiency and security. However, this change introduced a fundamental limitation: service workers cannot access the DOM. Many browser APIs that extension developers rely on—such as AudioContext, Clipboard API, canvas operations, and geolocation—require a DOM environment to function.
The chrome.offscreen API solves this problem by allowing extensions to create hidden documents that have access to the full DOM. These offscreen documents run in their own context, enabling you to perform DOM operations, play audio, access the clipboard, manipulate canvas, and use geolocation from within your extension’s service worker workflow.
This guide covers everything you need to know about offscreen documents: the API methods, use cases, lifecycle management, messaging patterns, limitations, and practical workarounds.
Prerequisites
Before using offscreen documents, you need to declare the offscreen permission in your manifest.json:
{
"name": "My Extension",
"version": "1.0.0",
"manifest_version": 3,
"permissions": ["offscreen"],
"background": {
"service_worker": "background.js"
}
}
You also need to include the offscreen HTML file in your extension’s files:
{
"web_accessible_resources": [
{
"resources": ["offscreen.html"],
"matches": ["<all_urls>"]
}
]
}
Understanding the chrome.offscreen API
The chrome.offscreen API provides methods to create, manage, and close offscreen documents. Here’s an overview of the core methods:
Creating an Offscreen Document
async function createOffscreenDocument() {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['AUDIO_PLAYBACK', 'CLIPBOARD', 'DOM_PARSING'],
justification: 'Need DOM access for audio processing, clipboard operations, and HTML parsing'
});
}
Checking if an Offscreen Document Exists
async function checkOffscreenExists() {
const hasDocument = await chrome.offscreen.hasDocument();
console.log('Offscreen document exists:', hasDocument);
}
// Alternative using chrome.runtime.getContexts
async function getOffscreenContexts() {
const contexts = await chrome.runtime.getContexts({
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT]
});
console.log('Offscreen contexts:', contexts.length);
}
Closing an Offscreen Document
async function closeOffscreenDocument() {
await chrome.offscreen.closeDocument();
}
Reasons for Offscreen Documents
When creating an offscreen document, you must specify at least one reason from the chrome.offscreen.Reason enum. These reasons justify why your extension needs DOM access:
Available Reasons
| Reason | Description |
|---|---|
AUDIO_PLAYBACK |
For playing audio using the Web Audio API |
CLIPBOARD |
For clipboard read/write operations |
DOM_PARSING |
For parsing HTML/XML documents |
DOM_SCRAPING |
For extracting data from web pages |
IFRAME |
For embedding cross-origin iframes |
WEB_RTC |
For WebRTC peer connections |
GEOLOCATION |
For accessing the Geolocation API |
Use Case Examples
DOM Parsing
// In service worker
async function parseHTML(htmlString) {
// Ensure offscreen document exists
if (!await chrome.offscreen.hasDocument()) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['DOM_PARSING'],
justification: 'Parse HTML content from fetched pages'
});
}
// Send message to offscreen document
const response = await chrome.runtime.sendMessage({
type: 'parse-html',
html: htmlString
});
return response;
}
<!-- offscreen.html -->
<!DOCTYPE html>
<html>
<body>
<script>
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'parse-html') {
const parser = new DOMParser();
const doc = parser.parseFromString(message.html, 'text/html');
sendResponse({
title: doc.querySelector('title')?.textContent || '',
links: Array.from(doc.querySelectorAll('a')).map(a => a.href)
});
}
});
</script>
</body>
</html>
Audio Playback
// In service worker
async function playAudio(audioUrl) {
if (!await chrome.offscreen.hasDocument()) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['AUDIO_PLAYBACK'],
justification: 'Play audio notifications'
});
}
await chrome.runtime.sendMessage({
type: 'play-audio',
url: audioUrl,
volume: 0.5
});
}
<!-- offscreen.html -->
<!DOCTYPE html>
<html>
<body>
<audio id="audioPlayer"></audio>
<script>
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'play-audio') {
const audio = document.getElementById('audioPlayer');
audio.volume = message.volume;
audio.src = message.url;
audio.play().then(() => sendResponse({ success: true }))
.catch(err => sendResponse({ success: false, error: err.message }));
return true;
}
});
</script>
</body>
</html>
Clipboard Operations
// In service worker
async function copyToClipboard(text) {
if (!await chrome.offscreen.hasDocument()) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['CLIPBOARD'],
justification: 'Copy formatted content to clipboard'
});
}
return chrome.runtime.sendMessage({
type: 'copy-to-clipboard',
text: text
});
}
async function readFromClipboard() {
if (!await chrome.offscreen.hasDocument()) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['CLIPBOARD'],
justification: 'Read clipboard content'
});
}
return chrome.runtime.sendMessage({
type: 'read-clipboard'
});
}
<!-- offscreen.html -->
<!DOCTYPE html>
<html>
<body>
<script>
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'copy-to-clipboard') {
navigator.clipboard.writeText(message.text)
.then(() => sendResponse({ success: true }))
.catch(err => sendResponse({ success: false, error: err.message }));
return true;
}
if (message.type === 'read-clipboard') {
navigator.clipboard.readText()
.then(text => sendResponse({ text }))
.catch(err => sendResponse({ error: err.message }));
return true;
}
});
</script>
</body>
</html>
Canvas Operations
// In service worker
async function processImage(imageDataUrl, options = {}) {
if (!await chrome.offscreen.hasDocument()) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['DOM_PARSING'], // Canvas uses DOM APIs
justification: 'Process images using canvas API'
});
}
return chrome.runtime.sendMessage({
type: 'process-canvas',
imageData: imageDataUrl,
options: options
});
}
<!-- offscreen.html -->
<!DOCTYPE html>
<html>
<body>
<canvas id="canvas"></canvas>
<script>
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'process-canvas') {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// Resize image
const maxWidth = message.options.maxWidth || 800;
const scale = Math.min(1, maxWidth / img.width);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Get processed image
const processedDataUrl = canvas.toDataURL('image/png');
sendResponse({
dataUrl: processedDataUrl,
width: canvas.width,
height: canvas.height
});
};
img.src = message.imageData;
return true;
}
});
</script>
</body>
</html>
Geolocation
// In service worker
async function getLocation() {
if (!await chrome.offscreen.hasDocument()) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['GEOLOCATION'],
justification: 'Get user location for extension features'
});
}
return chrome.runtime.sendMessage({
type: 'get-location'
});
}
<!-- offscreen.html -->
<!DOCTYPE html>
<html>
<body>
<script>
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'get-location') {
navigator.geolocation.getCurrentPosition(
(position) => {
sendResponse({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy
});
},
(error) => {
sendResponse({ error: error.message });
},
{ enableHighAccuracy: true }
);
return true;
}
});
</script>
</body>
</html>
Messaging Between Offscreen and Service Worker
Communication between your service worker and offscreen document works through chrome.runtime message passing. Here are the patterns:
Basic Message Passing
// Service worker → Offscreen document
async function sendToOffscreen(message) {
// First ensure offscreen exists
if (!await chrome.offscreen.hasDocument()) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['DOM_PARSING'],
justification: 'Process data in offscreen context'
});
}
// Small delay to let the document initialize
await new Promise(resolve => setTimeout(resolve, 100));
return chrome.runtime.sendMessage(message);
}
// Offscreen document receives message
// In offscreen.html
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Handle message and optionally respond
sendResponse({ processed: true });
return true; // Indicates async response
});
Using Message Channels
For more efficient communication, you can use message channels:
// In service worker
async function createMessageChannel() {
const { port1, port2 } = new MessageChannel();
// Send port to offscreen document
await chrome.runtime.sendMessage({
type: 'init-channel',
port: port1
}, { includeTlsChannelId: false }); // For MV3
// Use port2 for communication
port2.onmessage = (event) => {
console.log('Received from offscreen:', event.data);
};
port2.postMessage({ action: 'start-processing' });
}
<!-- offscreen.html -->
<!DOCTYPE html>
<html>
<body>
<script>
let port = null;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'init-channel') {
port = message.port;
port.onmessage = (event) => {
console.log('Received from service worker:', event.data);
// Process and respond
port.postMessage({ result: 'processed' });
};
}
});
</script>
</body>
</html>
Bidirectional Communication
// Service worker
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (sender.url === chrome.runtime.getURL('offscreen.html')) {
// Message from offscreen document
console.log('From offscreen:', message);
// Process and respond if needed
sendResponse({ acknowledged: true });
}
return true;
});
<!-- offscreen.html -->
<!DOCTYPE html>
<html>
<body>
<script>
// Send message back to service worker
function notifyServiceWorker(data) {
chrome.runtime.sendMessage({
from: 'offscreen',
data: data
});
}
// Example: notify when processing is complete
setTimeout(() => {
notifyServiceWorker({ status: 'complete', results: [1, 2, 3] });
}, 1000);
</script>
</body>
</html>
Lifetime Management
Managing the lifecycle of offscreen documents is crucial for building robust extensions.
Understanding Lifetime
- Document lifetime: The offscreen document exists until you call
closeDocument(), the extension is updated, or Chrome exits. - Service worker lifecycle: The document survives service worker termination but not extension updates.
- One document limit: Only ONE offscreen document can exist at a time per extension.
Automatic Creation on Service Worker Wake
Service workers in MV3 can be terminated after periods of inactivity. When your service worker wakes up, check if an offscreen document exists:
// background.js
chrome.runtime.onStartup.addListener(async () => {
await ensureOffscreenDocument();
});
chrome.runtime.onInstalled.addListener(async () => {
await ensureOffscreenDocument();
});
async function ensureOffscreenDocument() {
if (!await chrome.offscreen.hasDocument()) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['DOM_PARSING'],
justification: 'Initialize offscreen document on startup'
});
}
}
Graceful Cleanup
Always close offscreen documents when they’re no longer needed to free resources:
// After completing a task
async function processAndClose(html) {
try {
const result = await sendToOffscreen({ type: 'parse', html });
return result;
} finally {
// Clean up
if (await chrome.offscreen.hasDocument()) {
await chrome.offscreen.closeDocument();
}
}
}
Handling Service Worker Termination
The service worker can be terminated at any time. Design your offscreen communication to be resilient:
// Use timeouts and error handling
async function sendWithTimeout(message, timeout = 5000) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await chrome.runtime.sendMessage(message);
clearTimeout(timeoutId);
return response;
} catch (error) {
if (error.message.includes('No tab with id')) {
// Recreate offscreen document and retry
await ensureOffscreenDocument();
return chrome.runtime.sendMessage(message);
}
throw error;
}
}
Single Document Limitation
Chrome restricts extensions to only one offscreen document at a time. Understanding this limitation is crucial:
The Limitation
// This will REPLACE the existing document, not create a new one
await chrome.offscreen.createDocument({
url: 'audio.html',
reasons: ['AUDIO_PLAYBACK'],
justification: 'Play audio'
});
// Calling again with different URL/reasons replaces the first
await chrome.offscreen.createDocument({
url: 'clipboard.html',
reasons: ['CLIPBOARD'],
justification: 'Access clipboard'
});
Workarounds for the Limitation
1. Single Page for All Operations
Create a single offscreen.html that handles all operations:
<!-- offscreen.html - Universal handler -->
<!DOCTYPE html>
<html>
<body>
<audio id="audio"></audio>
<canvas id="canvas"></canvas>
<script>
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
switch (message.type) {
case 'parse-html':
const parser = new DOMParser();
const doc = parser.parseFromString(message.html, 'text/html');
sendResponse({ title: doc.title, links: [...doc.querySelectorAll('a')].map(a => a.href) });
break;
case 'play-audio':
const audio = document.getElementById('audio');
audio.src = message.url;
audio.play().then(() => sendResponse({ success: true }))
.catch(e => sendResponse({ error: e.message }));
break;
case 'copy-clipboard':
navigator.clipboard.writeText(message.text)
.then(() => sendResponse({ success: true }))
.catch(e => sendResponse({ error: e.message }));
break;
case 'process-canvas':
// Canvas processing
sendResponse({ processed: true });
break;
}
return true;
});
</script>
</body>
</html>
2. Reason Aggregation
When creating an offscreen document, aggregate all reasons you might need:
async function ensureOffscreen(reason) {
// Check existing contexts
const contexts = await chrome.runtime.getContexts({
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT]
});
if (contexts.length > 0) {
const existingReasons = contexts[0].documentUrl?.split('?')[1];
// Document exists, can reuse for most operations
return;
}
// Create with all potential reasons
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: [
'AUDIO_PLAYBACK',
'CLIPBOARD',
'DOM_PARSING',
'GEOLOCATION'
],
justification: 'Need DOM access for multiple operations'
});
}
3. Priority-Based Document Management
For complex extensions, implement a priority system:
class OffscreenManager {
constructor() {
this.currentOperation = null;
this.pendingOperations = [];
}
async execute(operation) {
// Check if we can reuse existing document
if (await chrome.offscreen.hasDocument()) {
return this.performOperation(operation);
}
// Create new document with required reason
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: [operation.reason],
justification: operation.justification
});
return this.performOperation(operation);
}
async performOperation(operation) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout')), 10000);
chrome.runtime.sendMessage(operation.message, (response) => {
clearTimeout(timeout);
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(response);
}
});
});
}
async close() {
if (await chrome.offscreen.hasDocument()) {
await chrome.offscreen.closeDocument();
}
}
}
export const offscreenManager = new OffscreenManager();
Use Cases and Workarounds Summary
| Use Case | Workaround |
|---|---|
| Multiple simultaneous DOM operations | Single universal offscreen.html handling all operations |
| Audio + Clipboard at same time | Aggregate both reasons when creating document |
| Long-running tasks | Keep document open during session, close on idle |
| Memory optimization | Create on-demand, close immediately after use |
| Race conditions | Use async locks or queue system |
Best Practices
1. Create Documents On-Demand
// ❌ Bad: Creating on every call
async function parseHTML(html) {
await chrome.offscreen.createDocument({ ... }); // Always creates new
// ... process
}
// ✅ Good: Check first, create only if needed
async function parseHTML(html) {
if (!await chrome.offscreen.hasDocument()) {
await chrome.offscreen.createDocument({ ... });
}
// ... process
}
2. Handle Errors Gracefully
async function safeOffscreenOperation(message) {
try {
if (!await chrome.offscreen.hasDocument()) {
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['DOM_PARSING'],
justification: 'Process data'
});
// Wait for initialization
await new Promise(r => setTimeout(r, 100));
}
return await chrome.runtime.sendMessage(message);
} catch (error) {
console.error('Offscreen operation failed:', error);
// Fallback or retry logic
throw error;
}
}
3. Document Your Justification
// ✅ Good: Clear justification helps reviewers
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['AUDIO_PLAYBACK'],
justification: 'Play notification sounds when user receives new messages in the chat extension'
});
4. Clean Up Resources
// Close after batch operations
async function processBatch(items) {
try {
for (const item of items) {
await processInOffscreen(item);
}
} finally {
// Always clean up
if (await chrome.offscreen.hasDocument()) {
await chrome.offscreen.closeDocument();
}
}
}
Manifest Configuration Reference
Here’s a complete manifest.json example for an extension using offscreen documents:
{
"manifest_version": 3,
"name": "Offscreen Document Example",
"version": "1.0.0",
"description": "Demonstrates offscreen document capabilities",
"permissions": [
"offscreen"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"web_accessible_resources": [
{
"resources": ["offscreen.html", "offscreen.js"],
"matches": ["<all_urls>"]
}
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
}
Related Articles
- Offscreen Documents — API Reference — Complete API reference for chrome.offscreen methods and properties
- Offscreen Document Patterns — Production-ready patterns for lifecycle management and typed communication
- Service Workers Deep Dive — Understanding service worker lifecycle and how it interacts with offscreen documents
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.