How Plasmo's Type-Safe Messaging Helped Me Ship Clipboard History IO in One Week
By Andy Young
•
December 20, 2024
After building over 50 Chrome extensions for clients on Fiverr, I thought I had extension development figured out. I'd even created my own React boilerplate to speed things up. But I was still spending too much time on the same repetitive tasks—especially around message passing.
I first discovered Plasmo while experimenting with a toy project. Within hours, I saw so many solutions to problems I'd been solving manually for years. When the opportunity came to build Clipboard History IO, I immediately knew Plasmo could enable me to ship a v1 in just one week.
While Plasmo offers many features—storage abstractions, auto-reloading, cross-platform builds—the one that fundamentally changed my development speed was the messaging API. It turned what used to be my biggest time sink into something I could implement in minutes. Let me show you why.
The Problem with Traditional Chrome Extension Messaging
In my previous extensions, message passing between contexts was always the most error-prone part. Here's what I was dealing with:
// From my old boilerplate
chrome.runtime.sendMessage(
{
action: "updateEntry",
data: { id: entry.id, conent: entry.content }, // Typos only caught at runtime
},
(response) => {
if (response?.succes) {
// More typos, more runtime errors
console.log("Updated!");
}
},
);
No type safety, no autocomplete, and errors that only showed up when users reported bugs. My boilerplate helped with structure, but it couldn't solve the fundamental issue.
The Chrome Extension Messaging Hell
If you've built Chrome extensions, you know the pain. The native messaging API is powerful but feels like it was designed in 2012 (because it was). You're constantly juggling:
- No type safety between background scripts and popups
- Callback hell that makes your eyes bleed
- Runtime errors from typos in message names
- Zero IDE support for what messages exist
- Hard to trace where messages come from or go to
- Everything mixed together in one giant switch statement
- Impossible to reason about as your extension grows
Building Clipboard History IO meant coordinating between three different contexts:
- The popup (where users interact)
- The background service worker (the brain)
- The offscreen document (for clipboard access in Manifest V3)
Every message between them was a potential landmine.
Enter Plasmo: Type Safety That Actually Works
Here's the same functionality with Plasmo:
// background/messages/updateEntry.ts
import type { PlasmoMessaging } from "@plasmohq/messaging";
export type UpdateEntryRequest = {
id: string;
content: string; // TypeScript catches typos at compile time!
};
export type UpdateEntryResponse = {
success: boolean;
};
const handler: PlasmoMessaging.MessageHandler<UpdateEntryRequest, UpdateEntryResponse> = async (
req,
res,
) => {
const { id, content } = req.body;
// Your logic here
res.send({ success: true });
};
export default handler;
Now from the popup:
import { sendToBackground } from "@plasmohq/messaging";
import type { UpdateEntryRequest, UpdateEntryResponse } from "~background/messages/updateEntry";
const response = await sendToBackground<UpdateEntryRequest, UpdateEntryResponse>({
name: "updateEntry",
body: {
id: entry.id,
content: entry.content, // IDE autocomplete! Type checking!
},
});
if (response.success) {
// TypeScript knows this exists!
console.log("Updated!");
}
The Real Magic: Cross-Context Type Safety
What blew my mind wasn't just the type safety—it was how Plasmo handles the complexity of Chrome's multi-context architecture.
In Clipboard History IO, I have this flow:
- User enables clipboard monitoring in the popup
- Popup sends message to background script
- Background script creates an offscreen document (for Manifest V3)
- Offscreen document monitors clipboard and sends data back
- Background script processes and stores the data
With vanilla Chrome APIs, this is a nightmare of nested callbacks and lost types. With Plasmo:
// In the offscreen document
import { sendToBackground } from "@plasmohq/messaging";
const watchClipboard = async () => {
window.addEventListener("paste", async (e) => {
const content = e.clipboardData?.getData("text/plain");
// This message is fully typed!
await sendToBackground({
name: "createEntry",
body: {
content,
timestamp: Date.now(),
},
});
});
};
Every message, every response, fully typed across all contexts. What used to take hours of debugging now just worked on the first try.
Platform-Specific Magic Without the Madness
Here's where it gets even better. Clipboard History IO needs to work on both Chrome (Manifest V3) and Firefox (still on Manifest V2). They handle clipboard access completely differently:
- Chrome MV3: Requires an offscreen document
- Firefox MV2: Can access clipboard directly from background page
With Plasmo, I just check process.env.PLASMO_TARGET
:
// background/index.ts
if (process.env.PLASMO_TARGET === "firefox-mv2") {
// Firefox: Direct clipboard access in background
watchClipboard(window, document, handleCreateEntry);
} else {
// Chrome: Create offscreen document
await chrome.offscreen.createDocument({
url: chrome.runtime.getURL("offscreen.html"),
reasons: ["CLIPBOARD"],
justification: "Monitor clipboard for user",
});
}
One codebase, two platforms, zero headaches.
The Performance Surprise
I was worried that all this abstraction would slow things down. The opposite happened.
Because Plasmo's messaging is built on Promises instead of callbacks, I could parallelize operations that were previously sequential:
// Before: Sequential callback hell
getSettings((settings) => {
getEntries((entries) => {
updateBadge(entries.length, () => {
updateIcon(settings.isEnabled, () => {
console.log("Finally done!");
});
});
});
});
// After: Parallel promise heaven
const [settings, entries] = await Promise.all([
sendToBackground({ name: "getSettings" }),
sendToBackground({ name: "getEntries" }),
]);
await Promise.all([
sendToBackground({ name: "updateBadge", body: { count: entries.length } }),
sendToBackground({ name: "updateIcon", body: { enabled: settings.isEnabled } }),
]);
Clipboard History IO's popup now opens 40% faster than it would have with my old callback-based approach.
The One-Week Sprint That Actually Worked
Thanks to Plasmo's abstractions, I shipped Clipboard History IO v1 in exactly one week. That included:
- Full clipboard monitoring across Chrome and Firefox
- Secure local storage with search and filtering
- Keyboard shortcuts for quick paste
- Import/export functionality
- A polished React UI
Now it's grown to over 4,000 users. The codebase is maintainable, type-safe, and easy to extend.
Here's what Plasmo gave me that I didn't even know I needed:
- Message organization: Each message type lives in its own file. No more 1000-line background scripts.
- Auto-reloading that actually works: Change a message handler, extension reloads, state preserved.
- Framework agnostic: I use React, but Plasmo doesn't force it on you.
The only hiccup? There's a bundling issue with certain icon libraries that increases bundle time when using @tabler/icons-react.
An Honest Assessment
After 20+ React-based extensions, Plasmo is the first framework that actually delivers on its promises. The messaging API alone would have saved me hundreds of hours across all my client projects.
Would I have shipped Clipboard History IO without Plasmo? Probably. But it would have taken three weeks instead of one, and the code would be far messier. The time saved on boilerplate and debugging went directly into features that users actually care about.
For anyone building Chrome extensions professionally, Plasmo isn't just nice to have—it's a competitive advantage. My development speed has essentially doubled, and the code quality is significantly better.
If you're building Chrome extensions, check out Plasmo. And if you need a reliable clipboard manager that actually respects your privacy, Clipboard History IO is the one I built with it. Have questions? Send me an email at andyluyoung@gmail.com.