Skip to content
Clipboard History IO Logo

Clipboard History IO

Clipboard History IO
FeaturesTestimonialsPlansBlogPro

Chrome Extension Clipboard Access: The Architecture Behind Clipboard History IO

By Andy Young

January 18, 2025

chrome-extensiontechnicaloffscreen-apiservice-workerperformance

When building Clipboard History IO, I discovered that reading the clipboard in a Chrome extension is surprisingly complex. Chrome's security model creates unique challenges that require a specific architecture. Here's how it works - and how I learned to optimize it after a user reported 100% CPU usage.

Why Chrome Extensions Can't Just Read the Clipboard

If you've ever tried to access the clipboard from a Chrome extension's service worker, you've probably seen this error:

Uncaught (in promise) ReferenceError: document is not defined

That's because service workers don't have access to the DOM APIs. Chrome's security model intentionally limits what service workers can do - they're meant to be lightweight event handlers, not full-featured environments.

Interestingly, Firefox still uses Manifest V2 with persistent background pages (via background.html), which DO have DOM access. This is why Clipboard History IO has different code paths for Firefox - it can access the clipboard directly without needing an offscreen document.

The Naive Solution That Worked (Until It Didn't)

My first implementation was straightforward. Since we can't use navigator.clipboard in a service worker, I used the classic execCommand('paste') trick:

// utils/background.ts - Initial naive implementation
export const watchClipboard = (w: Window, d: Document, cb: (content: string) => void) => {
  const textarea = d.createElement("textarea");
  d.body.appendChild(textarea);

  w.setInterval(() => {
    textarea.value = "";
    textarea.focus();
    d.execCommand("paste");

    cb(textarea.value);
  }, 1000);
};

For Chrome, this ran in an offscreen document (since offscreen documents also don't have access to Chrome APIs like storage):

// offscreen.ts
import { sendToBackground } from "@plasmohq/messaging";
import { watchClipboard } from "~utils/background";

watchClipboard(window, document, (content) =>
  sendToBackground({
    name: "createEntry",
    body: {
      content,
    },
  }),
);

For Firefox MV2, it ran directly in the background page:

// background/index.ts
if (process.env.PLASMO_TARGET === "firefox-mv2") {
  watchClipboard(window, document, (content) =>
    handleCreateEntryRequest({
      content,
    }),
  );
}

// For Chrome, we need to create an offscreen document
let creating: Promise<void> | null = null;
const setupOffscreenDocument = async () => {
  if (process.env.PLASMO_TARGET === "firefox-mv2") {
    return; // Firefox doesn't need this
  }

  if (await chrome.offscreen.hasDocument()) {
    return; // Already exists
  }

  if (creating) {
    await creating; // Wait for existing creation
  } else {
    creating = chrome.offscreen.createDocument({
      url: OFFSCREEN_DOCUMENT_PATH,
      reasons: [chrome.offscreen.Reason.CLIPBOARD],
      justification: "Read text from clipboard.",
    });
    await creating;
    creating = null;
  }
};

This worked great! The clipboard was polled every second, and new content was processed. But then users started reporting crashes when copying large database dumps or JSON files.

The Problem: When Reading Takes Longer Than Your Interval

Here's what actually happened when users copied large text (4-5MB JSON files, database dumps, etc.):

  1. First interval fires at 0ms - starts reading large clipboard content
  2. Reading takes 2+ seconds (yes, reading clipboard can be SLOW for large data!)
  3. Second interval fires at 1000ms - starts ANOTHER read operation
  4. Third interval fires at 2000ms - yet another concurrent read
  5. These operations stack up, each holding the entire clipboard content in memory
  6. Extension runs out of memory and crashes

Even popular extensions like Clipboard History Pro suffer from this - setting a character limit doesn't help because you still have to read the entire clipboard content into memory before you can check its length!

Failed Attempt #1: Dynamic Timeouts

My first instinct was to prevent the stacking issue by switching from setInterval to setTimeout with dynamic delays:

// utils/background.ts - Dynamic timeout attempt
export const watchClipboard = (
  w: Window,
  d: Document,
  getClipboardMonitorIsEnabled: () => Promise<boolean>,
  cb: (content: string) => Promise<void>,
) => {
  const textarea = d.createElement("textarea");
  d.body.appendChild(textarea);

  const handle = async () => {
    const a = Date.now();

    try {
      const clipboardMonitorIsEnabled = await getClipboardMonitorIsEnabled();

      if (clipboardMonitorIsEnabled) {
        textarea.value = "";
        textarea.focus();
        d.execCommand("paste");

        await cb(textarea.value);
      }
    } catch (e) {
      console.log(e);
    } finally {
      w.setTimeout(handle, Math.max(1000, (Date.now() - a) * 6));
    }
  };

  handle();
};

This seemed clever - adjust the polling frequency based on how long operations took. But the math was catastrophic:

  • 200ms operation → 1.2 second delay (reasonable)
  • 500ms operation → 3 second delay (getting long)
  • 1 second operation → 6 second delay (way too long!)
  • 2 second operation → 12 second delay (clipboard monitor is basically dead)

Users would copy multiple items during these huge delays and nothing would be captured. The clipboard monitor appeared to randomly stop working.

The Real Solution: Smarter State Management

After reverting the dynamic timeout disaster, I realized the problem wasn't about polling frequency - it was about preventing concurrent operations and redundant message passing. Here's what actually worked:

// utils/background.ts - Final solution with state management
export const watchClipboard = (
  w: Window,
  d: Document,
  getClipboardMonitorIsEnabled: () => Promise<boolean>,
  cb: (content: string) => Promise<void>,
) => {
  let pushing = false; // Prevents concurrent paste event handling
  let fetching = false; // Prevents concurrent polling

  // Listen for paste events triggered by execCommand
  w.addEventListener(
    "paste",
    async (e) => {
      e.preventDefault();

      if (pushing || !e.clipboardData) {
        return; // Skip if already processing
      }

      const curr = e.clipboardData.getData("text/plain");

      try {
        pushing = true;
        await cb(curr); // Send to service worker
      } catch (e) {
        console.log(e);
      } finally {
        pushing = false;
      }
    },
    { capture: true },
  );

  // Poll clipboard using execCommand
  w.setInterval(async () => {
    if (fetching) {
      return; // Skip if already fetching
    }

    try {
      fetching = true;

      if (await getClipboardMonitorIsEnabled()) {
        d.execCommand("paste"); // Triggers paste event above
      }
    } catch (e) {
      console.log(e);
    } finally {
      fetching = false;
    }
  }, 800);
};

The key insights:

  1. Separate concerns: The interval only triggers execCommand('paste'), while the paste event handler does the actual work
  2. State flags: pushing and fetching prevent concurrent operations that could cause race conditions
  3. No textarea manipulation: The paste event gives us the clipboard data directly via e.clipboardData

The Results

The difference was dramatic. By preventing operations from stacking up, we eliminated the memory explosion that was causing crashes. The state management approach meant that even when users copied 10MB of data, the extension would handle it gracefully - it might slow down for a moment, but it wouldn't crash.

The real win wasn't just fixing the CPU usage - it was making the extension reliable for power users who regularly work with large data sets. No more lost clipboard history when copying database dumps or massive JSON files.

Why This Matters

These optimizations mean Clipboard History IO can handle 10MB of clipboard data without crashing - something that causes other clipboard managers to fail. The state management approach prevents the cascade failures that occur when operations stack up.

If you're building a clipboard extension or debugging similar performance issues, I hope this helps. The full source code is available at github.com/clipboard-history-io/extension.


If you found this helpful or have questions about Chrome extension architecture, feel free to send me an email at andyluyoung@gmail.com.


Secure, fast, and elegant clipboard manager.

Built by Andy Young

BlogContactPrivacy Policy