Say you are trying to load a plugin you just modified, and instead of opening its UI, Figma shows a red banner: Plugin error: Cannot read property 'name' of undefined. You reopen the plugin, change one line, reload, and get the same error with no line number and no stack trace. The instinct at that moment is usually to change something else and hope the error moves. That instinct is the single largest source of wasted debugging time with Figma plugins, because the message Figma shows is the last symptom in a chain, not the first cause.
This guide walks through the troubleshooting process in the order it should happen, with the specific failure classes that account for most plugin bugs, the code patterns that fix each one, and a verification step so you know the fix held.
Step 1: Stop Reading the Figma Error First
Figma’s plugin error overlay is designed for end users, not developers. It reports the exception that bubbled up to the sandbox boundary, which means it strips the call stack, loses the source-mapped line number in production builds, and often reports a generic TypeError when the real problem was a missing manifest field three steps earlier.
Do this instead: open the browser DevTools console before you run the plugin. Figma Desktop is an Electron app, and Figma in the browser is a normal web app. In both cases, the plugin sandbox logs to the same console.
- Figma Desktop:
Plugins → Development → Open Console(or pressCtrl/Cmd + Shift + I, then select the Console tab). - Figma in browser: open DevTools normally with
F12orCmd + Option + I.
The console prints the same exception Figma showed you, but with an expandable stack trace and the source-mapped line number. This one habit removes the guesswork from every subsequent step.
Common mistake: treating the Figma error banner as a complete error report. It is a summary, not a diagnosis.
Step 2: Verify the Manifest Before Touching Any Code
A surprising share of “plugin won’t load” reports trace to manifest.json, not to the plugin logic. The manifest is Figma’s contract with your plugin, and if it lies about file paths, API version, or capabilities, Figma fails before your code runs.
Open manifest.json and check these fields in order:
{
"name": "My Plugin",
"id": "1234567890123456789",
"api": "1.0.0",
"main": "dist/code.js",
"ui": "dist/ui.html",
"editorType": ["figma"],
"networkAccess": {
"allowedDomains": ["https://api.example.com"]
}
}
| Field | What to Check | Failure Mode If Wrong |
|---|---|---|
main | Path is relative to the manifest, and the file exists after a clean build | Plugin fails to load with no console output |
ui | Same as above; for projects using a bundler, points to the built HTML, not the source | UI panel opens blank |
api | Matches the API version you are coding against | API methods silently return undefined |
networkAccess.allowedDomains | Every domain your plugin fetches from is listed, or "none" is set explicitly | fetch calls reject with a CORS-shaped error even when the remote server sends correct headers |
editorType | Includes "figjam" if the plugin runs in FigJam, "dev" for Dev Mode | Plugin is hidden from the relevant menu |
Verification step. Run your production build, then check the paths the manifest declares:
npm run build
node -e "
const m = require('./manifest.json');
const fs = require('fs');
for (const k of ['main', 'ui']) {
if (m[k] && !fs.existsSync(m[k])) {
throw new Error(\`manifest.\${k} points to missing file: \${m[k]}\`);
}
}
console.log('manifest paths OK');
"
If this script passes and the plugin still fails to load, the problem is in your code, not your configuration. Move to Step 3 with confidence that you are now debugging the right layer.
Common mistake: editing plugin logic when the manifest is stale. Rebuild first, then reason about behavior.
Step 3: Kill the Async Assumptions
The majority of plugin bugs that appear intermittently, or that only reproduce on slower machines, are timing bugs. Two patterns cause most of them.
Pattern one: reading figma.currentPage.selection before the plugin has loaded. Figma exposes the document synchronously in most cases, but a plugin loaded via figma.parameters or a FigJam context may see the selection update after onrun begins. The safe pattern is to read the selection at the top of onrun and store it in a local variable:
// good: snapshot once, use the snapshot
figma.showUI(__html__, { width: 320, height: 240 });
const selection = figma.currentPage.selection.slice();
figma.ui.onmessage = (msg) => {
if (msg.type === 'apply') {
for (const node of selection) {
if ('opacity' in node) node.opacity = 0.5;
}
}
};
Reading figma.currentPage.selection inside onmessage looks equivalent but is not. Between plugin load and the user clicking your UI, the selection can change, and any node in the earlier selection may have been deleted. Holding a snapshot makes the behavior deterministic.
Pattern two: sequencing await calls that have no ordering requirement. Chained awaits across figma.clientStorage, fetch, and node mutations add latency, and any single rejection aborts the rest of the chain. Where the calls are independent, batch them:
// instead of awaiting each independent read in sequence
const [theme, prefs, remoteConfig] = await Promise.all([
figma.clientStorage.getAsync('theme'),
figma.clientStorage.getAsync('prefs'),
fetch('https://api.example.com/config').then(r => r.json()),
]);
This cuts wall-clock time and, more importantly, ensures that a failure in one read does not silently prevent the other two from resolving.
Common mistake: assuming that because something works on your machine, the timing is correct. It is not correct; it is fast enough there.
Step 4: Isolate the Sandbox vs. UI Boundary
Figma plugins run in two distinct JavaScript contexts that communicate only through figma.ui.postMessage and figma.ui.onmessage. This boundary is a common hidden failure point because the two contexts do not share memory, cannot call each other’s functions, and serialize every message.
Symptom: UI button click does nothing, and the plugin’s main context logs nothing.
Diagnose with this check. On both sides, log every message with a short tag:
// ui.js — inside the iframe
parent.postMessage({ pluginMessage: { type: 'inspect', tag: 'ui-out' } }, '*');
// code.js — the plugin's main context
figma.ui.onmessage = (msg) => {
console.log('[main received]', msg);
// ... handle msg.type
};
If [main received] never appears, the message shape is wrong. figma.ui.postMessage requires the exact envelope { pluginMessage: <payload> } when sent from the UI to the main context. Sending the payload directly — parent.postMessage({ type: 'inspect' }, '*') — compiles and runs, but Figma drops it silently.
Verification step. After fixing the envelope, open the console, click the button, and confirm the tag appears on both sides in order: [main received] { type: 'inspect', tag: 'ui-out' }, then any response you send back.
When not to use postMessage: for anything that can run entirely inside one context. If your logic has no reason to touch the UI, keep it in the main context and skip the serialization round-trip. Every message crossing the boundary is a place for a bug.
Step 5: Handle API Surface Differently in FigJam and Dev Mode
figma.editorType is not just a visibility flag. It determines which parts of the plugin API are present. A plugin that works in Figma Design can crash in FigJam on the very first API call if you assume the Design surface.
Guard with a runtime check, not just a manifest field:
const isFigJam = figma.editorType === 'figjam';
if (isFigJam) {
// FigJam exposes shapes: rectangle, ellipse, connector, sticky, text, etc.
// It does NOT expose components, styles, or variants.
} else {
// Figma Design surface
}
Places this bites:
- Components.
figma.createComponent()is Design-only. A plugin that calls it unconditionally and is loaded in FigJam throws immediately. - Styles.
figma.getLocalTextStylesAsync()and friends are Design-only. - Connectors.
figma.createConnector()is FigJam-only. - Dev Mode. If
editorTypeincludes"dev", the plugin runs in a read-only inspection context, and mutation calls likenode.resize()fail with a permission error.
Common mistake: checking editorType in the manifest and assuming the runtime matches. It does, but the API surface still differs, and the manifest check does not prevent the API call from being attempted.
Step 6: Watch for Silent Async Rejections
figma.closePlugin() ends the plugin process. Any await that resolves after that call is ignored, and any promise still pending is cancelled. This produces a class of bug that looks like “the plugin sometimes doesn’t save the result,” because the close happens before the write completes.
The fix is the sequencing pattern: await every promise whose completion matters, then close.
async function saveAndClose(payload) {
try {
await figma.clientStorage.setAsync('lastRun', payload);
await postAnalytics(payload); // optional; failures should not block close
} catch (err) {
console.error('save failed, closing anyway', err);
}
figma.closePlugin();
}
Two things to notice. First, the analytics call is inside the same try but does not prevent close if it rejects, because the catch handles it and control continues. Second, no await exists after closePlugin(). In practice, placing anything after closePlugin() is the second most common cause of lost state in Figma plugins.
Trade-off. Awaiting every call before close adds a perceptible delay for slow network operations. If the operation is optional, fire it without awaiting and accept that it may not complete:
postAnalytics(payload); // not awaited; best-effort
figma.closePlugin();
The decision is whether you would rather lose the data or delay the close. Most plugins should prefer the delay for state the user cares about, and best-effort for telemetry.
Step 7: Reproduce with the Production Build, Not the Dev Server
If your plugin behaves correctly in the development build and fails in the production build, the fix is almost never in your source logic. It is in the build pipeline. Common divergences:
- Source maps. Production builds usually ship without them, so errors point to bundled line numbers. Keep source maps for internal builds and add an upload step for your error reporting if you use one.
- Minification. Minifiers rename local variables in ways that break code relying on
Function.prototype.name,arguments.callee, or dynamic property lookups based on string matching. - Dead code elimination. Bundlers drop code paths they prove unreachable. A plugin that only calls a function inside
if (figma.editorType === 'figjam')can have that function tree-shaken away if you have also configurededitorTypeto["figma"]in the manifest. - Node polyfills. Plugin sandboxes do not have
process,Buffer, or__dirname. Bundlers that auto-polyfill for web targets may include those polyfills, which load but fail at runtime because the underlying modules are not present.
Verification step. After every production build, run the manifest validator from Step 2 and load the built plugin manually in Figma Desktop. Do not rely on the dev server as the final check.
Step 8: Rebuild the Test Harness Around the Boundary You Keep Hitting
If the same boundary — sandbox-to-UI, Design-to-FigJam, dev-to-production — causes a failure more than twice, the fix is not another patch. It is a small test that exercises the boundary so regressions surface immediately.
For the UI boundary, a cheap harness is a mock of figma in your test runner:
// test/helpers/figma-mock.js — Vitest or Jest
export function makeFigmaMock() {
const sent = [];
return {
editorType: 'figma',
currentPage: { selection: [] },
ui: {
postMessage: (msg) => sent.push(msg),
onmessage: null,
},
clientStorage: {
getAsync: async () => undefined,
setAsync: async () => {},
},
closePlugin: () => { /* record if needed */ },
_sent: sent,
};
}
Then for each test, set globalThis.figma = makeFigmaMock() and assert on figma._sent for the messages your main context emits, and on figma.closePlugin having been called. This catches envelope-shape bugs, missing awaits, and close-before-save patterns in milliseconds instead of during a manual reload cycle.
When not to build a harness. If your plugin is under roughly two hundred lines and has no UI, a harness costs more than it saves. The break-even point is when the same class of bug has occurred more than twice, or when the plugin has more than a handful of user-visible flows.
Step 9: Document the Failure and Move On
Once a bug is fixed, add a one-line comment at the site of the fix that names the failure mode. Not a paragraph — a line.
// Figma drops postMessage without the { pluginMessage } envelope.
parent.postMessage({ pluginMessage: { type: 'ready' } }, '*');
Comments like this do not explain the obvious. They explain the non-obvious constraint that a future edit will otherwise break. Over a few months, a plugin accumulates a set of these one-liners, and new contributors stop re-discovering the same constraints.
A Fast Checklist for the Next Time a Plugin Fails
Run these in order before making a code change:
| Order | Check | Typical Time |
|---|---|---|
| 1 | Open DevTools console; read the real stack trace | 30 seconds |
| 2 | Run the manifest path validator | 10 seconds |
| 3 | Confirm you are testing a fresh production build | 1 minute |
| 4 | Verify editorType matches the surface the plugin is loaded into | 15 seconds |
| 5 | Check that every await before closePlugin resolves | 30 seconds |
| 6 | Reproduce in the build the user is running, not your dev build | 1 minute |
Following this order catches the majority of Figma plugin failures before any source file is opened. The two mistakes that waste the most time are jumping to Step 3 code edits while the manifest is stale, and trusting a dev build to behave like the shipped one.
Which of these checks is the one you skipped the last time something broke? Fixing that habit first is usually more valuable than memorizing every API quirk in this list.