The Byte Problem: Why C++ Builds at Figma Kept Slowing Down

C++ build times were one of the top pain points in Figma’s annual internal developer survey in 2023. And the numbers made it clear why: while the codebase grew by only 10% in a 12-month period, build times jumped by 50%. Engineers tried stopgap measures—upgrading to M1 Max laptops, using Ccache, and setting up remote caching—but each time, build times reverted to their original pace. The team needed a more systemic fix.

A Quick Refresher on C++ Compilation

Before tackling the problem, it helps to understand how C++ compilation works. In the pre-processing step, every header file included in a source file gets mashed into a single mega-file that is passed to the compiler. This includes transitively included files—if file C includes file B, and file B includes file A, then file C drags in all the bytes of file A as well.

Build times are roughly proportional to the number of bytes the compiler receives after pre-processing. That means even a small amount of new code can have an outsized impact if it pulls in a large dependency tree.

Testing the Hypothesis

Figma engineers noticed that the ratio of post-pre-processing bytes to the amount of code actually added was growing quickly. This led to a suspicion: the codebase had many places where headers were included unnecessarily, either because they weren’t used at all, or because they were only needed for transitive dependencies.

To test this, they removed unnecessary includes from the largest files. The results were promising: a 31% decrease in compiled bytes and a 25% decrease in cold build time—the time required to compile from scratch, with no cache to lean on. This confirmed that unnecessary includes were a major culprit, and that compiled bytes were strongly correlated with build times.

A Relaxed Take on Include Hygiene

The team initially tried integrating Google’s open-source tool Include What You Use (IWYU) into their codebase, twice. IWYU is strict: it requires each file to include exactly the headers it needs and nothing more. That level of rigor proved difficult to apply retroactively to a large codebase.

So Figma built its own tool, named Don’t Include What You Don’t Use (DIWYDU). Instead of requiring a precise set of includes, the tool takes a more relaxed approach: it only verifies that the current file actually uses something directly from each header it includes. This makes it far easier to adopt incrementally across the codebase.

DIWYDU uses libclang’s Python bindings to parse every source and header file. It analyzes the Abstract Syntax Tree (AST) to identify the types, functions, and variables each file directly uses. When an included header isn’t directly depended upon, the tool flags it. DIWYDU runs on all feature branches, catching these wasteful includes before they land in master.

One key limitation: DIWYDU analyzes only Figma’s first-party files, excluding Standard Template Library (STL) header files. STL headers often rely on private includes—headers that are only used within a specific module, not exposed in the public interface. For instance, the vector.h header doesn’t actually define std::vector; that symbol lives in a private include. IWYU also struggles with this case.

There’s another wrinkle: libclang’s Python bindings sit on top of C bindings, which don’t offer full access to the Clang AST that the C++ compiler has. This sometimes surfaces as UNEXPOSED_EXPR nodes in the AST, requiring workarounds. A Future C++-based version could resolve this, but the current tool gets the job done.

Measuring What the Compiler Sees

DIWYDU catches unnecessary includes, but it misses a different kind of regression: cases where a large header is genuinely used, but still balloons the bytes sent to the compiler. These can often be fixed with forward declaration—declaring an identifier without its full definition—or by breaking up header files. Since the header is actually used in the file, DIWYDU won’t flag it.

To detect this class of problem, the team built a second tool called includes.py. Written purely in Python with no Clang dependency, it’s fast—usually running in a couple of seconds—which makes it suitable for Continuous Integration (CI). The question it answers: what’s the transitive byte count per source file?

includes.py crawls all first-party header and source files, including generated ones, and counts bytes in each. When it encounters standard library includes, it assumes they are 0 bytes. That’s a safe approximation for Figma’s codebase: engineers rarely include STL headers directly, and standard library usage is mostly confined to a single directory that exports wrapper containers.

The tool builds a dependency graph and sums the bytes of each file plus all of its direct and transitive header dependencies. CI runs it on every PR and, if a change causes a significant regression in bytes per source file, a warning is issued. This prevents the slowdown from being merged in the first place.

Centralizing Forward Declarations

A third piece of the strategy was creating Fwd.h files. Forward declarations are useful when a file only references a symbol’s name without needing to know about its size or inheritance structure. They bypass expensive includes, but scattered forward declarations hurt readability and make the codebase harder to search, since the same symbol appears in many places.

Figma structured its codebase into directories that resemble modules, with each directory built independently. The policy: every directory has its own Fwd.h file containing all forward declarations needed by that directory, and every header in that directory includes its local Fwd.h. This centralizes declarations in one place per directory and means engineers don’t need to think about forward declarations at all—anything that can benefit from them gets them organically.

// AnimalFwd.h

namespace Figma {
    struct Animal;
    struct Dog;
    struct Cat;
    enum struct AnimalType;
    using Feline = Cat;
};

An example Fwd.h file—such as an AnimalFwd.h for a directory dealing with animal classes—collects the forward declarations of types in that directory. A few rules apply: source files should never include a Fwd.h file, because forward declarations only save time in header files where they prevent unnecessary includes.

Complementary Caching

Reducing compiled bytes is the core strategy, but the team also added Bazel remote caching to the mix. The remote cache stores build outputs and retrieves them when the same inputs are used again. For local laptop builds, this approach wasn't something Figma had tried before, but the results were measurable: with logic in place to use the remote cache only when it makes sense, local builds shaved off more than two minutes each time the cache hit.

The Results

These tools, together, cut build times by 50%. And the system is self-sustaining: includes.py catches between 50 and 100 potential slowdowns every single day before they reach master, and DIWYDU runs continuously on feature branches to keep the include graph lean going forward.