Five walls of the Figma plugin sandbox, and the way round each
I have shipped five Figma plugins. Most of my plugin bugs were not in my code. They came from the sandbox the code runs in, and one of them hid behind a fix that only removed the symptom.

In this story6 sections
A Figma plugin is two programs. One runs on Figma’s main thread with access to the document and nothing else. The other is your UI, an iframe that can draw and talk to the network, but only through the doors Figma leaves open.
Across Mokker, Gradients Lab, Stardust, Tailwind Tokens and MyLayout, most of the bugs I lost time to came from those doors. Here are the five walls I hit, and what got me round each one.
1. Your UI has no origin
The plugin UI is loaded without a real origin. When it calls your server, the browser sends Origin: null.
Gradients Lab sells a Pro licence, checked by a small server. Its CORS setup took 22 commits to settle, and it ended where it had to: allow Figma’s own site, and allow null.
cors({ origin: ["https://www.figma.com", "null"], methods: ["GET", "POST"] })The lesson is the second half. null is the origin of every sandboxed iframe on the web, so it proves nothing about who is calling. Treat the origin check as plumbing, not security. The licence endpoints carry their own protection: a rate limit on verification, and keys that are checked on the server.
2. Your UI ships as one HTML file
Figma loads the plugin UI from a single HTML string, so the build has to inline its JavaScript and CSS into it. Stardust did it the obvious way:
html.replace("<!-- JS_PLACEHOLDER -->", `<script>${js}</script>`);On 1 January 2026 the plugin threw “Unexpected identifier ‘flex’” in Figma. Turning minification off made the error go away, and the commit says so.
That fix was hiding the real bug. When the second argument of replace is a string, JavaScript reads $&, $' and $` inside it as instructions: insert the match, insert the text after it, insert the text before it. React’s own code contains "$&/", so the bundle was being edited as it was inlined.
I rebuilt it both ways to be sure. Minified, the inlined script does not parse. Unminified, it parses, with the placeholder comment spliced into React’s key handling: broken silently, in code that only runs for some React keys.
The fix is one argument. Pass a function, and the text goes in as it is:
html.replace("<!-- JS_PLACEHOLDER -->", () => `<script>${js}</script>`);If you inline a bundle with String.replace, check this today. You may have the bug and no symptom.
3. Analytics SDKs do not work there
Four of the plugins measure usage with the same product analytics service. Its browser SDK does not work inside Figma’s iframe, and Mokker’s analytics notes say it in one line. The plugins send events to the HTTP API directly instead.
That rule is worth generalising. Any SDK that assumes a normal page, with cookies, storage and a real origin, may fail quietly inside the sandbox. Test that an event actually arrives, not that the call returned.
4. Every domain is declared, so fewer is better
The manifest lists every domain the UI may reach. Anything else is blocked. Mokker allows three: a CDN for its 3D models, Google’s host for the 3D compression decoder, and the analytics endpoint.
Tailwind Tokens used to load its font list from Google Fonts. In June 2025 it switched to the fonts Figma already has, and both font domains left the manifest. One less dependency, fewer permissions, and a list that matches what the designer can actually use in the file.
Before adding a domain, ask whether Figma already has the thing you are about to fetch.
5. Images stop at 4,096 pixels
Mokker renders a device in 3D and places the result on the canvas. Figma’s createImage has a hard limit around 4,096 pixels, and a large export simply fails. Since January 2026 the export sizes are clamped to it, with a Max option that stops exactly at the limit.
The same limit applies to the frame the plugin reads as the device’s screen. A big frame exported at a high scale would cross it, so the export scale drops until the texture fits inside 4,096 pixels.
Clamp everything that crosses into the canvas, and give the user the largest size that works instead of an error.
What I do first now
When I start a plugin, I check these five before building features:
- Point the UI at my server and expect
Origin: null. - Inline the bundle with a replacer function, never a string.
- Send one analytics event and watch it arrive.
- Keep the manifest’s domain list as short as the plugin allows.
- Clamp every image I create, and test with the biggest frame a user might pick.
None of it is in the feature list. All of it decides whether the features work.



