Nov 10, 2026

The Bazel Bet Behind Robinhood Legend: Infrastructure from First Principles

David Aghassi ·Max Rabiciuc
hero image

Optimizing Build Infrastructure for Developer Productivity

Our legacy web trading app served all of our customers, including our most active traders. For a long time, a one-line change to that app meant a 40-minute rebuild, and CI was both slow and unreliable. That cost engineers time and slowed every change we wanted to ship. This is the story of why, and what we did differently when we built Robinhood Legend.

Robinhood's web engineering team works out of one large monorepo holding many applications and the libraries they share. To build and test millions of lines of code, we use Bazel, a build system originally developed at Google. Bazel treats a codebase as a dependency graph and only rebuilds and retests the parts that changed. It shares cached results across machines, so a change one engineer already built doesn't need to be rebuilt by the next.

That only works if Bazel can see the pieces. In our legacy app, built with Webpack, it couldn't. We packaged the whole application as a single Bazel target, one unit of work that could be built, cached, or skipped. Bazel couldn't tell which part of a change mattered, so any change invalidated the entire cache and triggered the full rebuild.

Testing had the same problem from a different angle. The app's tests ran through Jest as one massive suite, so we couldn't cache individual test files or run only the tests a change affected. At our scale that made CI slow, and it also made it unreliable. Tests failed intermittently for reasons unrelated to the change being tested, including noisy neighbors, resource contention, and uneven test scheduling.

It wasn't for lack of effort. The team spent years tuning Webpack directly, splitting configs and adding every caching plugin available. None of it scaled for us on CI or improved stability meaningfully. The real bottleneck was the shape of the build itself: one target meant one unit of cache invalidation for the entire application.

When we started building Robinhood Legend, our desktop trading platform for active traders, we wanted to avoid carrying those problems forward. So we rebuilt our JavaScript build system on Bazel using a 1:1 folder mapping for BUILD.bazel files. The results so far: P95 CI times are ~50% faster (~25 min) and P50 is ~80% faster (~10 min).

Defining the Build Goals

Before starting work, we set north stars to help the team guide itself to a successful outcome.

  • Local developer experience couldn't get worse. IDE features like autocomplete and inline type information had to keep working the way they always had. If the new setup for Legend felt materially worse to work in than the old application, developers would reject it, even if it paid off in the long run.

  • CI and local builds had to stay identical. Any gap between what a developer saw on their laptop and what CI enforced would just become a new source of confusing, hard-to-reproduce failures.

  • The web ecosystem in the monorepo couldn't break. Robinhood's web codebase was years old by this point, and any new tooling had to work alongside the old tooling during a gradual migration instead of forcing everything to flip over at once.

With those constraints in mind, we looked at four options.

  1. Keep optimizing what we already had. That meant Bazel's existing JavaScript ruleset, called rules_nodejs, paired with Webpack. We ruled this out because the maintainers of rules_nodejs had already signaled they were winding down active development on it in favor of a newer ruleset. Investing further there meant we'd eventually be maintaining our own fork of an abandoned project.

  2. Turborepo, a build tool built specifically for JavaScript monorepos. It's popular, and it would have given us real improvements in caching and task running. But adopting it meant stepping outside Bazel's dependency graph, which spans our entire monorepo, not just the JavaScript parts of it. We would have solved the problem for JavaScript, but we would have become an island in a bigger tech stack. Every other part of the company ran on one dependency graph shared across every language and application, along with the shared backing infrastructure built on top of it, like remote caching and remote build workers. Being bespoke and cut off from that would have hindered our ability to scale quickly.

  3. Keep our existing tools but restructure the codebase itself into smaller pieces, without changing anything about how it was built. We actually tried this, and reverted it. Splitting up the file structure without new tooling to take advantage of it added friction for developers, without delivering the caching benefits we were after.

None of the first three options cleared the bar set by our north stars: forking an abandoned project, giving up Bazel's shared graph, or restructuring without the tooling to make it pay off. 

The fourth checked every box. It meant staying on Bazel, but replacing the JavaScript-specific pieces underneath it.

Option

What it meant

Why it didn't clear the bar

1. Keep tuning rules_nodejs + Webpack

Stay on Bazel's existing JavaScript ruleset

Maintainers were winding down development; we'd end up maintaining a fork of an abandoned project

2. Turborepo

Swap in a build tool built specifically for JS monorepos

Real caching and task-running wins, but stepping outside Bazel's shared graph would cut us off from company-wide infrastructure like remote caching and remote build workers

3. Restructure the file layout only

Same tools, smaller folders, no new tooling

Added friction for developers with none of the caching payoff; we tried it and reverted it

4. Replace the JS-specific tooling under Bazel (chosen)

New package manager (pnpm) + new Bazel ruleset (rules_js)

Stayed on Bazel's shared graph, kept local developer experience intact, and allowed a staged rollout

Technical Alignment on Bazel

Getting here meant a handful of specific decisions: which package manager to standardize on, which Bazel tooling to build around it, how to roll that out safely across an existing monorepo, and how to structure Legend's own build once we had a clean slate to work with.

For each of these, we looked at what was already available in the open-source community before choosing. Our search landed us on the following tools:

  • pnpm for package management

  • rules_js for the Bazel side, along with its companion rulesets: rules_ts, rules_swc, and rules_jest

pnpm

rules_js

What it is

A package manager, an alternative to the npm and yarn most JavaScript developers already know

A Bazel ruleset for JavaScript/TypeScript, along with companion rulesets rules_ts, rules_swc, and rules_jest

Why it fit

Symlinks dependencies to a global store instead of copying them into every package, a layout Bazel could resolve efficiently

Compatible with pnpm and built on the fundamental building blocks its companion rulesets provided; laid out dependencies faster than the ruleset we were leaving behind; managed files under the hood in a way that worked better with Bazel

What it unlocked

An estimated 50%+ faster local installs, and near-instant CI dependency resolution after first setup

Automatic BUILD file generation via a Gazelle plugin, the same way other languages in the Bazel ecosystem already worked

Rolling this out across an existing, years-old monorepo meant doing it in stages. We started with existing libraries. They had a low volume of code, which paired well with the initial complexity of designing the architectural boundaries for this new approach. Once that pattern was confirmed to work, we moved on to full applications, saving the largest and most customer-facing ones for last. This allowed us to derisk migrations while validating our assumptions and building a strong foundation.

With this migration underway, Robinhood invested in building a new active trader product called Robinhood Legend. This new product would build on the foundation we had proven out, and do so with no legacy tech debt. That meant we could take the building blocks we'd already created for libraries and apply them directly, without the staged rollout or backwards-compatibility work older apps needed. That let the team build Legend the way the migration work had already shown the rest of Web should eventually look. The following is an overview of the pattern we created and how it works broadly.

Implementation Details
app/
├── BUILD.bazel          # workspace-level targets, plus the Gazelle config block below
├── macros.bzl           # app_source_folder / app_source_folder_test, defined once
├── package.json
├── tsconfig.json
└── src/
    ├── BUILD.bazel      # top-level entry point (App.tsx, index.tsx)
    ├── lib/
    │   ├── logger.ts
    │   └── BUILD.bazel  # no cross-folder deps
    └── state/
        ├── session.ts
        ├── session.test.ts
        ├── user.ts
        ├── user.test.ts
        └── BUILD.bazel  # depends on //app/src/lib

One BUILD.bazel per folder, mirroring the source tree exactly. macros.bzl lives once at the app root; every other BUILD.bazel just loads from it.

marco.bzl:

load("//bazel/js:source_folder.bzl", "source_folder")
load("//bazel/js:source_folder_test.bzl", "source_folder_test")

# Jest's globals (`describe`, `it`, `expect`, `jest`) are ambient, so no test file
# imports them and Gazelle cannot infer the dependency. Declared once here and
# injected into every test target instead of repeated in every BUILD file.
APP_JEST_TEST_DEPS = [
    "//app:node_modules/@types/jest",
]

def app_source_folder(name, srcs, data = [], **kwargs):
    source_folder(
        name = name,
        srcs = srcs,
        app_root = "app",
        data = data,
        **kwargs
    )

def app_source_folder_test(name, srcs, data = [], deps = [], **kwargs):
    source_folder_test(
        name = name,
        srcs = srcs,
        app_root = "app",
        data = data,
        deps = deps + APP_JEST_TEST_DEPS,
        **kwargs
    )

The Gazelle config block, at the top of the app's root BUILD.bazel:

# gazelle:js enabled
# gazelle:js_visibility //app:__subpackages__
# gazelle:js_validate_import_statements error
# gazelle:map_kind ts_project app_source_folder //app:macros.bzl
# gazelle:map_kind ts_project_test app_source_folder_test //app:macros.bzl
# gazelle:js_project_naming_convention {dirname}
# gazelle:exclude dist

This is the piece that connects Gazelle to the macro above. Without the map_kind lines, Gazelle would generate every folder's BUILD.bazel using the default rules_js macros (ts_project, ts_project_test). With them, it generates app_source_folder and app_source_folder_test instead, so every target in the app picks up the shared logic (the app_root pin, the injected Jest globals) automatically, with no per-folder copy-paste.

A BUILD.bazel file Gazelle generates from that macro, for one folder of source:

load("//app:macros.bzl", "app_source_folder", "app_source_folder_test")

app_source_folder(
    name = "state",
    srcs = [
        "session.ts",
        "user.ts",
    ],
    visibility = ["//app:__subpackages__"],
    deps = [
        "//app:node_modules/zustand",
        "//app/src/lib",
    ],
)

app_source_folder_test(
    name = "state_tests",
    testonly = True,
    srcs = [
        "session.test.ts",
        "user.test.ts",
    ],
    deps = [":state"],
)

Two things worth calling out for a reader unfamiliar with Bazel:

  • The deps list mixes two kinds of dependencies the same way: a third-party package pulled in through node_modules (zustand), and first-party code in a different folder of the same app (//app/src/lib). Bazel doesn't care which kind it is — both just become edges in the graph.

  • The test target depends on the source target by name (:state) rather than duplicating its deps, so it inherits everything the source folder already declared.

Stacking that up across the whole application creates a dependency graph, running all the way up to a single top-level entry point. When code changes, Bazel walks that graph and triggers only the pieces the change actually touches. The very last step in that graph is RSPack, a JavaScript bundler that plays the same role Webpack used to, packaging code for the browser. By the time code reaches RSPack, Bazel has done the work to typecheck and transpile everything, so RSPack's only job is bundling. Gazelle scans the source code and generates those BUILD.bazel files automatically, so developers write TypeScript and never have to hand-write Bazel's build configuration themselves.

Building Legend this way, with a fine-grained target graph from day one, is what makes iterative improvement possible and provides us with fine-grained control. An example of this is when we started to see long type-checking times, with P95 times climbing to 16 minutes. It forced us to consider more efficient ways of type-checking our code, and that investigation landed us on isolatedDeclarations, a TypeScript compiler option.

TypeScript normally checks an entire program's types together. To confirm one file is correct, the compiler has to look at everything that file depends on, and everything those files depend on, and so on. isolatedDeclarations changes that. It requires a developer to write out a function's or variable's type explicitly wherever other code depends on it, so the compiler can check each file against those written-out types directly, instead of reading through the full source of everything that file depends on. That only pays off if the build is already broken into small, independent pieces, which is exactly what Legend's Bazel setup gave us. Early analysis suggested it could cut roughly 80% off our type-check time, with Bazel able to run the now-independent checks in parallel on top of that.

This fine-grained structure also paid off for caching. Because each piece of Legend's code is its own target, changing one widget doesn't invalidate the cache for the rest of the application. Engineers get cache hits on Buildkite for everything upstream of their actual change, instead of rebuilding the whole application for a small edit.

We rolled isolatedDeclarations out gradually, one folder at a time, using an open-source tool called ts-fix to make the mechanical code changes it required, and marking the handful of cases it couldn't fix automatically with @ts-expect-error for a developer to clean up later. That mirrors the staged approach we used everywhere else in this migration: prove a change on the smallest safe piece of code first, then expand it.

Taken together, this approach held to the north stars we set out with. Developers kept the local experience they were used to, since nothing about day-to-day development got worse. CI and local builds stayed identical, because both ran through the same Bazel graph instead of two separate systems. And the rest of the monorepo kept working throughout, since the migration happened in stages instead of all at once. That let us deliver Legend as a high-quality product for our traders, without trading away the reliability the rest of Web depended on.

Performance Gains at Scale

With this new foundation, Legend built in a fraction of the time compared to our legacy web application, which took upwards of 40 minutes. 

Enabling isolatedDeclarations came in close to its projected ~80% cut in type-check time, and that improvement rippled out into a ~25% reduction in build times across the whole monorepo. Caching improved the same way: most changes now only trigger rebuilds and retests for the code that actually depends on them, instead of the whole application. The install-time goal mentioned above, a 50%+ improvement in CI and on developer machines, came in ahead of target: confirmed installs are ~75% faster on average.

None of these gains are possible without the change in build structure described above. Builds and type-checks that used to happen as one large unit now happen piece by piece, so most changes only need to rebuild or retest themselves, instead of the rest of the application.

Takeaways

  • Sometimes the answer is to start fresh with first principles thinking, set that as the goal to iterate toward, and backport what you learn to the prior system. Not all code can be evolved out of its current state.

  • Robinhood adopted pnpm and the rules_js Bazel tooling to give JavaScript and TypeScript code the same fine-grained build graph the rest of the monorepo already had.

  • Building Legend as a new application, rather than migrating an old one, let the team apply that fine-grained structure from day one, without carrying legacy migration debt.

  • TypeScript's isolatedDeclarations and fine-grained caching both depend on the same thing: an underlying build that's already broken into small, independent pieces.

About the Authors

Max Rabiciuc, Senior Engineering Lead. Max leads the Frontend Platform team who are responsible for all frontend infrastructure within Robinhood, from CI/CD to Networking and Design-Systems libraries across the many iOS, Android, and Web apps shipped to our customers.

David Aghassi, Senior Software Engineer, Web Platform Lead. David led Bazel and build-infrastructure strategy for Robinhood's Web Platform team, including the migration work described here.

Disclaimer

While there is no cost to use Robinhood Legend (“Legend”), there are other fees associated with your brokerage account. Review Robinhood Financial’s fee schedule,Robinhood Crypto’s fee schedule, and Robinhood UK’s fee schedule for more information. Legend is only available to Robinhood investing customers.  

As Legend operates across multiple jurisdictions, certain products displayed on the platform may not be available to UK customers. Price data for products unavailable for trading in the UK is provided for informational purposes only.

Robinhood does not endorse or adopt any particular investment strategy or approach to evaluating securities or other assets. Information displayed on Legend does not constitute an offer, recommendation, inducement or invitation to buy, sell or hold any securities or other investments, or to engage in any investment activity or strategy. 

Certain features of Legend may differ from, or may not be available on, the Robinhood mobile app or classic web platform. Customers should familiarise themselves with these differences before using Legend.

All investing involves risk, including the potential loss of principal. Options are complex products, involve significant risk and are not suitable for all investors. You could lose more than your initial invested capital. You should only invest in financial products that match your knowledge and experience. Review Characteristics and Risks of Standardized Options prior to engaging in options trading.

See more, including the Legend Agreement and Acknowledgment, at robinhood.com/legend.

Third-party corporations referenced are not partners with Robinhood. Their affiliation with build systems is not an endorsement of Legend. Use of their names is for identification purposes only.

Share this:

Robinhood takes pride in being an equal opportunity employer in the regions where we operate.

Robinhood Markets, Inc. and our global business entities are proud to be equal opportunity employers. We do not discriminate based on race, color, religion, national origin, age, sex, marital status, ancestry, neurotype, disability, veteran status, gender identity, sexual orientation or any other category protected by local law.

If you need additional assistance throughout the hiring process related to a health condition or there is something our team can do to enable a more accessible experience, please notify our team by completing this Applicant Accommodation Form.

Review our commitment to inclusivity and equal access through our Canada General Integrated Accessibility Standards Policy.

Under the U.S. Transparency in Coverage act, Robinhood is required to provide U.S. pricing information to U.S. consumers before they receive care under our insurance plans. To review Robinhood’s U.S. medical insurance pricing structure, please click here: https://transparency-in-coverage.collectivehealth.com/index.html.

To learn more about how we process your personal information and your rights in regards to your personal information as a Robinhood Applicant, please visit our Robinhood Applicant Privacy Policy page.

About Robinhood

Robinhood Markets, Inc. (NASDAQ: HOOD) is a global leader in financial services offering retail brokerage, crypto, advisory, digital banking services, and private markets access to a new generation of investors.

Additional information about Robinhood can be found at www.robinhood.com.

Robinhood, 85 Willow Road, Menlo Park, CA 94025. © 2026 Robinhood. All rights reserved.
Follow us on

Robinhood takes pride in being an equal opportunity employer in the regions where we operate.

Robinhood Markets, Inc. and our global business entities are proud to be equal opportunity employers. We do not discriminate based on race, color, religion, national origin, age, sex, marital status, ancestry, neurotype, disability, veteran status, gender identity, sexual orientation or any other category protected by local law.

If you need additional assistance throughout the hiring process related to a health condition or there is something our team can do to enable a more accessible experience, please notify our team by completing this Applicant Accommodation Form.

Review our commitment to inclusivity and equal access through our Canada General Integrated Accessibility Standards Policy.

Under the U.S. Transparency in Coverage act, Robinhood is required to provide U.S. pricing information to U.S. consumers before they receive care under our insurance plans. To review Robinhood’s U.S. medical insurance pricing structure, please click here: https://transparency-in-coverage.collectivehealth.com/index.html.

To learn more about how we process your personal information and your rights in regards to your personal information as a Robinhood Applicant, please visit our Robinhood Applicant Privacy Policy page.

About Robinhood

Robinhood Markets, Inc. (NASDAQ: HOOD) is a global leader in financial services offering retail brokerage, crypto, advisory, digital banking services, and private markets access to a new generation of investors.

Additional information about Robinhood can be found at www.robinhood.com.

Robinhood, 85 Willow Road, Menlo Park, CA 94025. © 2026 Robinhood. All rights reserved.