A Figma plugin is not a script that runs inside Figma’s editor. That is the most common misconception beginners bring to the topic, and it produces the second most common problem: writing code that references document, window, or figma in the wrong file and then wondering why nothing happens at runtime. A plugin is a small program made of two distinct execution contexts that communicate through messages, and the file that draws the UI cannot directly touch the document. Understanding that split — and the boundary between the two halves — is what separates plugins that work from plugins that fail silently.
This post walks through the anatomy in myth-versus-reality form, because most of the confusion beginners encounter comes from assuming the mental model of a browser extension or a Node script applies. It does not.
Myth: A Plugin Is a Single File
Reality: A plugin has a manifest, at minimum one code file, and optionally a UI file. Each piece has a different job and a different runtime.
The manifest is manifest.json. It is required, it must be at the root of the plugin folder, and Figma reads it before anything else runs. It declares the plugin’s name, its id (assigned when you register the plugin), the entry point for the main thread (main), the optional UI file (ui), and the network domains the plugin is allowed to reach (networkAccess).
A minimal manifest looks like this:
{
"name": "My First Plugin",
"id": "1234567890123456789",
"api": "1.0.0",
"main": "code.js",
"ui": "ui.html",
"editorType": ["figma"],
"networkAccess": {
"allowedDomains": ["none"]
}
}
Three fields cause the most trouble. First, id must match the plugin ID Figma assigned when you created the plugin — if you copy a manifest from a tutorial and forget to replace the ID, Figma will reject the plugin or attach it to the wrong listing. Second, main and ui are file paths relative to the manifest, not module names; a typo produces a generic “failed to load” error rather than a helpful one. Third, networkAccess defaults to blocking all outbound requests, so code that calls fetch() will fail with a network error unless the target domain is listed.
If you skip the manifest entirely, Figma shows nothing. There is no fallback.
Myth: The UI Can Access figma and the Main Thread Can Access the DOM
Reality: The two contexts are isolated by design, and each can only reach its own set of globals.
- The main thread runs in a JavaScript sandbox without a DOM. It has access to the
figmaglobal object and toconsole. It does not havewindow,document, orfetch(unless network access is granted). - The UI iframe is a real HTML document. It has
window,document, andfetch. It does not have thefigmaglobal, because Figma’s document model is not exposed to the iframe.
| Capability | Main thread (code.js) | UI iframe (ui.html) |
|---|---|---|
Read/modify figma.currentPage | Yes | No |
Access document, window | No | Yes |
| Render buttons, inputs, canvas | No | Yes |
Call fetch() | Only if listed in networkAccess | Yes |
Access the plugin’s stored data via figma.clientStorage | Yes | No |
The practical consequence: a beginner who writes <button onclick="figma.currentPage.selection[0].name = 'x'"> inside ui.html will get a ReferenceError: figma is not defined. The button must send a message to the main thread, which then performs the operation and, if needed, sends a result back.
Myth: The Two Halves Talk by Sharing Variables
Reality: Communication is message-based, one direction at a time, using postMessage and an event listener on each side.
The mechanism is the same on both ends, but the sender and receiver differ. From the UI, you use parent.postMessage with a pluginMessage field; from the main thread, you use figma.ui.postMessage. Both sides listen with window.onmessage (UI) or figma.ui.onmessage (main thread).
Here is a complete, minimal, working example. It has three files.
manifest.json — as shown above, with main: "code.js" and ui: "ui.html".
code.js — the main thread:
figma.showUI(__html__, { width: 240, height: 120 });
figma.ui.onmessage = (msg) => {
if (msg.type === "rename-selection") {
const selection = figma.currentPage.selection;
if (selection.length === 0) {
figma.ui.postMessage({ type: "error", message: "Select a layer first." });
return;
}
selection[0].name = msg.newName;
figma.ui.postMessage({ type: "done", count: selection.length });
}
};
ui.html — the iframe:
<style>
body { font: 12px sans-serif; padding: 12px; }
input, button { width: 100%; margin-top: 6px; box-sizing: border-box; }
</style>
<input id="name" placeholder="New layer name" />
<button id="apply">Rename selected layer</button>
<p id="status" role="status"></p>
<script>
const nameInput = document.getElementById("name");
const status = document.getElementById("status");
document.getElementById("apply").onclick = () => {
parent.postMessage(
{ pluginMessage: { type: "rename-selection", newName: nameInput.value } },
"*"
);
};
window.onmessage = (event) => {
const msg = event.data.pluginMessage;
if (!msg) return;
if (msg.type === "done") status.textContent = `Renamed 1 of ${msg.count} layers.`;
if (msg.type === "error") status.textContent = msg.message;
};
</script>
Setup → change → verify: load the plugin in Figma via Plugins → Development → Import plugin from manifest. Select a rectangle, type a name, click the button. The rectangle renames, and the status paragraph prints “Renamed 1 of N layers.” If you select nothing, it prints the error message instead. That round trip is the entire pattern — every plugin you build is a variation on it.
Myth: Any Message You Send Will Be Received
Reality: Messages are asynchronous, untyped, and fire-and-forget. Nothing in the runtime checks that a receiver exists.
Three failure modes show up repeatedly:
Unhandled message shapes. If the main thread listens for msg.type === "rename" and the UI sends msg.type === "rename-selection", nothing happens — no error, no log. The message arrives, the handler’s condition fails, and execution continues. Beginners commonly see this as “the button does nothing” when the real issue is a string mismatch. Log the message on receipt during development.
The pluginMessage envelope. Figma requires the payload to sit under a pluginMessage key when sending from the UI. If you send parent.postMessage({ type: "rename" }, "*"), the main thread’s figma.ui.onmessage receives undefined. The fix is always to wrap: { pluginMessage: { type: "rename" } }.
Awaiting the wrong thing. figma.ui.postMessage does not return a Promise. You cannot await the main thread’s response. If your UI needs a result, it must listen for a reply message and react to it, as the example above does with the done message.
Myth: The Main Thread Can Take As Long As It Needs
Reality: The main thread blocks the Figma editor while it runs. Long synchronous loops freeze the UI for the user.
Figma does not run plugin code in a worker thread that can be preempted. A for loop that iterates over thousands of nodes and touches the document on each iteration will make the editor unresponsive until it finishes. For anything beyond a few hundred operations, the standard approach is to chunk the work:
async function renameMany(nodes, prefix) {
const CHUNK = 200;
for (let i = 0; i < nodes.length; i += CHUNK) {
const slice = nodes.slice(i, i + CHUNK);
for (const node of slice) node.name = `${prefix}-${node.id}`;
await new Promise((r) => setTimeout(r, 0));
}
}
The await new Promise(...) yields control back to the host so the editor can repaint between chunks. It does not make the operation faster in total; it makes the editor stay responsive throughout. For plugins that only affect the current selection, this rarely matters. For plugins that walk the entire document, it matters immediately.
When not to use chunking: if your plugin operates on fewer than roughly a thousand nodes and completes in under a second, adding the yield overhead is unnecessary complexity. Keep the synchronous loop.
Myth: Plugin Data Persists Automatically
Reality: Plugin state is not saved anywhere by default. If you want it to survive a reload or a Figma restart, you store it explicitly.
Two storage locations exist, and they serve different purposes:
| API | Scope | Survives reload? | Survives Figma restart? | Shared with collaborators? |
|---|---|---|---|---|
figma.clientStorage | Per-user, per-plugin | Yes | Yes | No |
figma.root.setPluginData | Per-document | Yes | Yes | Yes (anyone opening the file) |
Use clientStorage for user preferences — theme, last used options, recent selections. Use setPluginData for data that belongs to the document, such as a generated ID your plugin wrote into a specific layer. Neither is a general-purpose database. Both store strings, so anything structured must be serialized with JSON.stringify and parsed back on read.
A common beginner mistake is calling figma.clientStorage.setItem from the UI iframe. It will fail — clientStorage is only available on the main thread. The UI must message the main thread and let it perform the write.
Myth: Loading the Plugin in Figma Is the Whole Dev Loop
Reality: The main thread and the UI reload differently, and one of them caches aggressively enough to mislead you.
Loading via Plugins → Development → Import plugin from manifest registers the plugin once. After that, editing code.js requires either re-importing or pressing the “Run last plugin” shortcut (Ctrl/Cmd + Option/Alt + P) to pick up changes. Editing ui.html sometimes requires no reload at all, because the iframe is re-created each time the plugin runs.
The failure mode this creates: you fix a bug in code.js, forget to re-run the plugin, and conclude the fix did not work. A quick way to check is to add a console.log at the top of code.js and confirm it prints on every run. If it prints only once, the file is being cached by Figma’s plugin loader, not by your code.
Figma’s developer documentation refers to the running instance as a “plugin run” — each invocation is a fresh execution, and the state from the previous run is discarded unless you persisted it.
Building Your Mental Model from the Split
The unifying idea behind every myth above is the same: a plugin is a small distributed system with two nodes and a message channel between them. Once you internalize that the main thread owns the document and has no DOM, and the UI owns the DOM and has no document access, most beginner errors become predictable rather than mysterious.
Three questions clarify almost any plugin bug:
- Which context does this code run in, and does that context have the global it references?
- If the two contexts need to cooperate, what message is being sent, and does the receiver’s check match it?
- If the operation is slow, is it blocking the main thread while touching the document?
The next step after this post is to take the rename example and extend it — add a dropdown of name prefixes in ui.html, send the chosen prefix to the main thread, and have the main thread apply it to the whole selection. That exercise touches the manifest, both execution contexts, message passing, and the document API in a single loop, which is the shortest path from “understands the anatomy” to “can build on it.”
Which half of the plugin split is giving you the most trouble right now — the main thread touching the document, or the UI messaging it correctly? That answer usually points to the next thing worth reading.