Figma plugin performance optimization is the practice of reducing the time a plugin spends traversing the document tree, communicating across the sandbox/UI boundary, and rendering its own interface — the three places where nearly every slow plugin loses its speed. It is not primarily about writing “faster” JavaScript in the abstract sense. It’s about understanding which operations are expensive in Figma’s specific execution model and restructuring code to avoid triggering them more often than necessary.
The clearest way to explain what that looks like in practice is to walk through a real optimization pass on a plugin, start to finish, rather than list rules in isolation.
The Plugin: A Design System Auditor
The plugin in question — call it Component Auditor — scans a file for components that have drifted from their published master: overridden fills, detached instances, inconsistent corner radii, that kind of thing. It reports the drift in a sidebar list so a design systems team can clean it up before a release.
On a test file with a few hundred components, it ran in under a second. On the actual production library file — roughly 40,000 nodes, several hundred component instances, deeply nested frames — it took eleven seconds to complete a scan, and the UI froze solid for most of that time. The team’s first assumption was that Figma’s API itself was slow. It wasn’t. The plugin was asking it to do far more work than the task required.
Step One: Measure Before Guessing
Before touching a line of logic, the fix started with console.time() wrapped around each major phase of the scan: tree traversal, comparison logic, and message dispatch to the UI. This is a step worth insisting on even when a bottleneck feels obvious, because intuition about where JavaScript spends time is wrong often enough to be unreliable on its own.
The timing broke down like this: traversal accounted for 7.2 of the 11 seconds. Comparison logic took under a second. The remaining time was almost entirely message-passing overhead between the plugin sandbox and the UI thread. Two very different problems, and two very different fixes — which is exactly why measurement has to come first.
Fixing the Traversal Bottleneck
The original code called figma.currentPage.findAll() once, then filtered the entire returned array in JavaScript to identify component instances. findAll() walks the complete node tree and evaluates a callback against every single node, including text layers, vectors, and groups that were never candidates for the audit in the first place.
Switching to findAllWithCriteria({ types: ["INSTANCE"] }) cut traversal time by more than half, because the filtering happens inside Figma’s native traversal rather than in a JavaScript callback invoked tens of thousands of times. This is a distinction worth internalizing broadly: findAll with a custom predicate function is the most flexible option and also the slowest one, since every node still gets visited and evaluated through a callback boundary. findAllWithCriteria should be the default choice whenever the target node types are known in advance.
The second traversal fix addressed something subtler. The plugin was calling a helper function to walk up the parent chain and check ancestor visibility for every single instance found — a reasonable-looking check that, at scale, meant re-walking overlapping portions of the tree thousands of times over. Caching ancestor visibility results in a Map keyed by node ID, rather than recomputing them per instance, removed nearly all of the redundant work. Traversal time dropped from 7.2 seconds to under a second.
Fixing the UI Freeze
Even after traversal improved, the interface still locked up noticeably during the scan. The cause here wasn’t traversal at all — it was that the plugin’s main thread never yielded control back to the browser while processing results, so the UI had no opportunity to repaint or respond to input until the entire loop finished.
The fix involved chunking the instance list and processing it in batches of roughly 200 nodes, with a setTimeout(resolve, 0) between batches to hand control back to the event loop. Processing time barely changed in total, but perceived responsiveness changed completely — the interface stayed interactive, a progress indicator could update between batches, and the plugin no longer felt broken even though the underlying work was similar in scope. This distinction between total processing time and perceived responsiveness matters more than most performance discussions give it credit for; users tolerate a five-second operation with visible progress far better than a two-second one that freezes the screen.
Fixing the Message-Passing Overhead
The last bottleneck was structural rather than algorithmic. The plugin sandbox and the plugin’s UI run in separate contexts and communicate exclusively through postMessage, which means every payload gets serialized, passed across the boundary, and deserialized on the other side. The original implementation posted a message to the UI after every single instance was evaluated — thousands of small messages instead of one larger one.
Batching those into a single message per chunk, sent after each group of 200 nodes finished processing, reduced the number of cross-boundary calls from several thousand to roughly 200. Serialization overhead dropped accordingly, and the UI thread had far fewer incoming messages to parse and render against. As a general principle: message-passing between the sandbox and the UI should be treated as a network call in terms of how often it’s invoked, even though it’s local. Frequent small messages carry more overhead than infrequent large ones, almost without exception.
What About Storage and External Calls?
Component Auditor didn’t hit this problem directly, but it’s common enough in similar plugins to be worth folding in here. Plugins that read or write figma.clientStorage inside a loop — once per node, for instance — pay an async round-trip cost on every call, even though the API is designed to feel synchronous in how it’s typically used. Reading all needed values once before a loop starts, and writing all results once after it ends, avoids turning storage into an accidental bottleneck of its own.
The same principle extends to any plugin that calls an external API for enrichment data. Caching responses for the duration of a session, and batching requests where the API supports it, prevents the plugin’s performance ceiling from becoming whatever the slowest network round-trip happens to be that day.
Results After the Optimization Pass
| Phase | Before | After |
|---|---|---|
| Tree traversal | 7.2s | 0.9s |
| Comparison logic | 0.6s | 0.5s |
| Message passing to UI | ~2.8s (blocking) | ~0.4s (batched) |
| UI responsiveness during scan | Frozen | Interactive, with progress updates |
| Total wall-clock time | ~11s | ~1.8s |
None of these fixes required new algorithms or a different architecture. Each one targeted a specific, measurable cost inside Figma’s plugin execution model — native filtering over manual filtering, cached lookups over repeated tree walks, batched updates over per-item updates, and fewer cross-boundary messages carrying more data each.
A Short Checklist for Your Own Plugin
Before optimizing anything, confirm where the time is actually going with console.time() around each major phase — traversal, logic, and message dispatch tend to have very different profiles. From there:
- Replace predicate-based
findAll()withfindAllWithCriteria()wherever node types are known ahead of time. - Cache repeated lookups, especially ancestor or parent-chain checks, instead of recomputing them per node.
- Chunk long-running loops and yield control back to the event loop so the UI stays responsive.
- Batch messages across the sandbox/UI boundary rather than sending one per item.
- Read and write
clientStoragein bulk, outside of loops, not inside them.
Most performance complaints about Figma plugins trace back to one of these five patterns. Which one shows up first in your own plugin usually depends on file size more than code style — small test files hide traversal costs, and small result sets hide message-passing costs, so problems that seem absent during development often surface only once real production files are involved.