Chrome Extension Media Handling: Image Upload, Processing and Screenshot Capture (2026)
Media handling is one of the more nuanced areas of Chrome extension development. Whether you’re building a screenshot tool, an image editor, a design utility, or a productivity extension that processes user-uploaded files, you’ll hit the same set of challenges: capturing page content, processing pixels efficiently, handling user input across drag-and-drop and clipboard, and writing files to disk cleanly. This guide covers each of these in depth with working code.
1. Screenshot Capture with chrome.tabs.captureVisibleTab
The most direct way to capture a screenshot in a Chrome extension is chrome.tabs.captureVisibleTab. It captures the visible portion of the currently active tab and returns a base64-encoded data URL.
// manifest.json - required permission
// "permissions": ["activeTab", "tabs"]
async function captureVisibleTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
format: "png", // or "jpeg"
quality: 92, // only applies to jpeg
});
return dataUrl; // "data:image/png;base64,..."
}One important constraint: captureVisibleTab only works from a background service worker or extension page (popup, options page). You cannot call it from a content script. If you need to trigger capture from a content script, send a message to the service worker:
// content-script.js
chrome.runtime.sendMessage({ type: "CAPTURE_TAB" }, (response) => {
console.log("Screenshot data URL:", response.dataUrl);
});
// service-worker.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "CAPTURE_TAB") {
chrome.tabs.captureVisibleTab(sender.tab.windowId, { format: "png" })
.then((dataUrl) => sendResponse({ dataUrl }));
return true; // keep message channel open for async response
}
});2. Full-Page Screenshot Techniques
Capturing beyond the visible viewport requires a scrolling capture approach. The idea: scroll the page in increments, capture each viewport, then stitch the images together on a canvas.
// service-worker.js
async function captureFullPage(tabId) {
// Get page dimensions from content script
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: () => ({
scrollHeight: document.documentElement.scrollHeight,
scrollWidth: document.documentElement.scrollWidth,
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth,
devicePixelRatio: window.devicePixelRatio,
}),
});
const { scrollHeight, viewportHeight, viewportWidth, devicePixelRatio } = result;
const captures = [];
let scrollY = 0;
while (scrollY < scrollHeight) {
// Scroll to position
await chrome.scripting.executeScript({
target: { tabId },
func: (y) => window.scrollTo(0, y),
args: [scrollY],
});
// Small delay to allow repaint
await new Promise((r) => setTimeout(r, 150));
const dataUrl = await chrome.tabs.captureVisibleTab({ format: "png" });
captures.push({ dataUrl, scrollY });
scrollY += viewportHeight;
}
// Restore scroll position
await chrome.scripting.executeScript({
target: { tabId },
func: () => window.scrollTo(0, 0),
});
return stitchCaptures(captures, {
viewportWidth,
viewportHeight,
scrollHeight,
devicePixelRatio,
});
}Stitching happens on an OffscreenCanvas (covered in the next section) to avoid blocking the UI thread.
3. Image Processing with Canvas API and OffscreenCanvas
For any non-trivial image manipulation — cropping, compositing, color adjustment, annotation — reach for the Canvas API. In service workers where there is no DOM, use OffscreenCanvas.
Converting a data URL to ImageBitmap:
async function dataUrlToImageBitmap(dataUrl) {
const res = await fetch(dataUrl);
const blob = await res.blob();
return createImageBitmap(blob);
}Stitching multiple viewport screenshots:
async function stitchCaptures(captures, { viewportWidth, viewportHeight, scrollHeight, devicePixelRatio }) {
const scale = devicePixelRatio;
const canvas = new OffscreenCanvas(viewportWidth * scale, scrollHeight * scale);
const ctx = canvas.getContext("2d");
for (const { dataUrl, scrollY } of captures) {
const bitmap = await dataUrlToImageBitmap(dataUrl);
ctx.drawImage(bitmap, 0, scrollY * scale);
bitmap.close();
}
const blob = await canvas.convertToBlob({ type: "image/png" });
return URL.createObjectURL(blob);
}Cropping a region from a screenshot:
function cropImage(sourceCanvas, x, y, width, height) {
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
ctx.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height);
return canvas;
}OffscreenCanvas is available in service workers in Chrome 69+. Use it whenever you process images in the background to keep your extension responsive.
4. Drag-and-Drop Image Upload in Popups
Extension popups are standard HTML pages, so the File API and drag-and-drop events work exactly as they do on the web.
<!-- popup.html -->
<div id="drop-zone" class="drop-zone">
<p>Drop image here or <label for="file-input">browse</label></p>
<input type="file" id="file-input" accept="image/*" hidden />
</div>// popup.js
const dropZone = document.getElementById("drop-zone");
const fileInput = document.getElementById("file-input");
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("drag-over");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("drag-over");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("drag-over");
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith("image/")) {
processImageFile(file);
}
});
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (file) processImageFile(file);
});
async function processImageFile(file) {
const bitmap = await createImageBitmap(file);
console.log(`Image: ${bitmap.width}x${bitmap.height}, type: ${file.type}`);
// Pass bitmap to your canvas pipeline
}One popup-specific gotcha: popups close when they lose focus. If you open a file picker dialog, the popup may close before the user selects a file. Work around this by opening a dedicated extension page (chrome.windows.create) for any file-heavy workflows instead of using the popup.
5. Image Compression and Format Conversion to WebP
Reducing image file size before upload or storage is straightforward with the Canvas API. WebP typically achieves 25–34% smaller file sizes than PNG at equivalent quality.
async function convertToWebP(inputBlob, quality = 0.85) {
const bitmap = await createImageBitmap(inputBlob);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
return canvas.convertToBlob({
type: "image/webp",
quality,
});
}
async function resizeAndCompress(inputBlob, maxWidth = 1280, quality = 0.85) {
const bitmap = await createImageBitmap(inputBlob);
const scale = Math.min(1, maxWidth / bitmap.width);
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await canvas.convertToBlob({ type: "image/webp", quality });
console.log(`Compressed: ${inputBlob.size} → ${blob.size} bytes`);
return blob;
}For very large images, consider processing in chunks or using createImageBitmap with a resizeWidth option to offload the scaling work:
const bitmap = await createImageBitmap(inputBlob, {
resizeWidth: 1280,
resizeQuality: "high",
});6. Clipboard Image Handling (Paste from Clipboard)
Many power users expect to paste screenshots directly from their clipboard. The Clipboard API makes this straightforward in extension pages.
// popup.js or options page
document.addEventListener("paste", async (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith("image/")) {
const blob = item.getAsFile();
if (blob) {
await handleClipboardImage(blob);
break;
}
}
}
});
// Programmatic clipboard read (requires "clipboardRead" permission)
async function readImageFromClipboard() {
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageType = item.types.find((t) => t.startsWith("image/"));
if (imageType) {
const blob = await item.getType(imageType);
return blob;
}
}
} catch (err) {
console.error("Clipboard read failed:", err);
}
return null;
}Add "clipboardRead" to your permissions array in manifest.json if you’re using the programmatic API. The paste event listener works without extra permissions.
7. Saving Images to Disk with chrome.downloads
Once you have a processed image blob or data URL, saving it to the user’s filesystem is handled by chrome.downloads.download.
// manifest.json: "permissions": ["downloads"]
async function saveImageToDisk(blob, filename = "screenshot.png") {
const url = URL.createObjectURL(blob);
const downloadId = await chrome.downloads.download({
url,
filename,
saveAs: true, // prompt user with Save As dialog
conflictAction: "uniquify",
});
// Clean up object URL after download starts
chrome.downloads.onChanged.addListener(function onChanged({ id, state }) {
if (id === downloadId && state?.current === "complete") {
URL.revokeObjectURL(url);
chrome.downloads.onChanged.removeListener(onChanged);
}
});
return downloadId;
}
// Save without prompting (silent save to Downloads folder)
async function silentSave(blob, filename) {
const url = URL.createObjectURL(blob);
await chrome.downloads.download({ url, filename, saveAs: false });
// Revoke after a short delay since we can't track completion as easily
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}Always revoke object URLs after use. Leaked object URLs hold references to blobs in memory and can cause significant memory growth in long-running extensions.
8. Practical Example: Screenshot Annotation Tool
Here’s a minimal but complete annotation tool that ties all the above concepts together. It captures the active tab, lets the user draw on it, and saves the result.
Architecture:
- Popup triggers capture and opens a dedicated annotation page
- Annotation page receives the screenshot via
chrome.storage.session - User draws on a canvas overlay
- Export saves the merged image
// service-worker.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "START_ANNOTATION") {
(async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" });
// Store screenshot temporarily in session storage
await chrome.storage.session.set({ pendingScreenshot: dataUrl });
// Open annotation page
await chrome.windows.create({
url: chrome.runtime.getURL("annotation.html"),
type: "popup",
width: 1024,
height: 768,
});
sendResponse({ ok: true });
})();
return true;
}
});// annotation.js (runs in annotation.html)
const canvas = document.getElementById("annotation-canvas");
const ctx = canvas.getContext("2d");
let isDrawing = false;
let image = null;
async function init() {
const { pendingScreenshot } = await chrome.storage.session.get("pendingScreenshot");
if (!pendingScreenshot) return;
const bitmap = await dataUrlToImageBitmap(pendingScreenshot);
canvas.width = bitmap.width;
canvas.height = bitmap.height;
ctx.drawImage(bitmap, 0, 0);
image = bitmap;
// Clear from session storage
await chrome.storage.session.remove("pendingScreenshot");
}
// Drawing tools
canvas.addEventListener("mousedown", (e) => {
isDrawing = true;
ctx.beginPath();
ctx.moveTo(e.offsetX, e.offsetY);
});
canvas.addEventListener("mousemove", (e) => {
if (!isDrawing) return;
ctx.lineTo(e.offsetX, e.offsetY);
ctx.strokeStyle = "#ef4444";
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.stroke();
});
canvas.addEventListener("mouseup", () => { isDrawing = false; });
// Export
document.getElementById("save-btn").addEventListener("click", async () => {
canvas.toBlob(async (blob) => {
const compressed = await convertToWebP(blob, 0.92);
await saveImageToDisk(compressed, `annotation-${Date.now()}.webp`);
}, "image/png");
});
async function dataUrlToImageBitmap(dataUrl) {
const res = await fetch(dataUrl);
const blob = await res.blob();
return createImageBitmap(blob);
}
init();This pattern — capture in the service worker, pass via chrome.storage.session, process in a dedicated page — keeps concerns separated and avoids the popup-closes-on-blur problem entirely.
Key Takeaways
- Use
captureVisibleTabfrom service workers only; relay through message passing from content scripts. - Full-page screenshots require scroll-and-stitch; use
OffscreenCanvasto composite without blocking. OffscreenCanvas+convertToBlobis your workhorse for image processing in service workers.- Drag-and-drop and paste work in extension pages out of the box — use a dedicated window for file-heavy UIs.
- Always convert to WebP for storage and upload to minimize size.
- Revoke object URLs after
chrome.downloads.downloadcompletes to prevent memory leaks. - For multi-step media workflows,
chrome.storage.sessionis the clean way to pass blobs between extension pages without serializing to base64 unnecessarily.
With these building blocks you can implement anything from a quick screenshot grabber to a full in-extension image editor. The APIs are stable, performant, and available without any third-party dependencies.
Share this article
Build better extensions with free tools
Icon generator, MV3 converter, review exporter, and more — no signup needed.
Related Articles
I Built the Same Chrome Extension With 5 Different Frameworks. Here's What Actually Happened.
WXT vs Plasmo vs CRXJS vs Extension.js vs Bedframe. Real benchmarks, honest opinions, and the framework with 12K stars that's quietly dying.
5 Best Email Marketing Services to Grow Your Chrome Extension (2026)
Compare the top email marketing platforms for SaaS and Chrome extension developers. MailerLite, Mailchimp, Brevo, ActiveCampaign, and Drip reviewed.
15 Best Practices to Build a Browser Extension That Users Love (2026 Guide)
Master browser extension development in 2026. Manifest V3, security, performance, and UX best practices to build extensions users love.