Article cover

Getting Started with Bazel: Core Concepts and Build Design for JavaScript πŸ“

Bazel offers a powerful, scalable build system for JavaScript projects. In this post, we break down its core concepts, explain how the build process works, and explore strategies to design efficient builds-tailored for modern JavaScript development.

1. What is Bazel?

Bazel is an open-source build and test tool developed by Google, designed to handle projects of any scale-from small apps to massive monorepos with millions of lines of code. Its primary goal is to make builds fast, reliable, and reproducible, even in complex environments.

Think of Bazel as the orchestrator of your project’s build process. Instead of manually running different build commands for different languages or packages, Bazel manages the entire workflow-compiling source code, running tests, packaging artifacts, and even deploying-while ensuring that only the necessary parts are rebuilt.

Key Characteristics:

  • Cross-language & cross-platform: Works with Java, JavaScript/TypeScript, Go, Python, C++, and more.
  • Incremental builds: Rebuilds only the code that has changed, saving time.
  • Remote caching & execution: Share build results between team members and CI/CD to avoid redundant work.
  • Deterministic outputs: Produces the same result regardless of where or when the build runs.
  • Parallel execution: Builds multiple independent targets at the same time.

2. Core Concepts in Bazel

Bazel organizes your code and build logic into a few fundamental pieces. If you’re new to Bazel, understanding these will make everything else click.

2.1 Workspace: The Project Root

A workspace is the root of your Bazel project. It contains a WORKSPACE file (or MODULE.bazel if you use bzlmod). This tells Bazel β€œstart here” and is where you declare external dependencies.

Key Characteristics:

  • Declares the repository as a Bazel project.
  • Optionally defines external deps (toolchains, rules, third‑party code).
  • Everything Bazel does is resolved relative to the workspace root.

Execution Flow:

  1. Bazel starts from the workspace root.
  2. It reads WORKSPACE / MODULE.bazel to learn about external repositories.
  3. It discovers packages via BUILD files inside subfolders.

2.2 Packages & BUILD Files

A package is any directory under the workspace that contains a BUILD file. A BUILD file declares what can be built (targets) and how.

Key Characteristics:

  • One BUILD file per package (folder).
  • Groups related source files and build rules.
  • Smallest unit Bazel can build.
my-bazel-app/
  WORKSPACE
  app/
    BUILD
    src/
      a.js
      b.js
  libs/
    BUILD
    util/
      index.js

2.3 Targets: Buildable Units

A target is a single thing Bazel can build, test, or run (e.g., a library, a bundle, a test). Targets live inside BUILD files.

Key Characteristics:

  • Identified by a unique label (more below).
  • Can depend on other targets.
  • Combine to form a dependency graph.
# app/BUILD
filegroup(
    name = "srcs",
    srcs = ["src/a.js", "src/b.js"],
)

genrule(
    name = "bundle",
    srcs = [":srcs"],
    outs = ["bundle.js"],
    cmd = "cat $(SRCS) > $@",  # Demo-only: concatenate files
)

Execution Flow:

  1. When you run bazel build //app:bundle, Bazel looks up the bundle target in app/BUILD.
  2. It sees bundle depends on :srcs, so it prepares a.js and b.js.
  3. It runs the genrule command, producing bundle.js as output.

2.4 Labels: Unique Addresses for Targets

A label uniquely identifies a target. It looks like a URL for your build graph.

Key Characteristics:

  • Format: @repo//path/to/package:target
  • In the main workspace, @repo is optional: //path/to/package:target
  • Inside the same package, you can shorthand with :target
# Full path from the workspace root:
//app:bundle

# Inside app/BUILD, referencing another target in the same package:
:srcs

# External repo target (if defined via WORKSPACE/MODULE.bazel):
@third_party//some/pkg:lib

2.5 Rules: How Stuff Gets Built

Rules tell Bazel how to transform inputs into outputs (compile, bundle, test, etc.). Bazel ships with core rules (e.g., filegroup, genrule), and ecosystems provide language-specific rules. You can also write custom rules.

Key Characteristics:

  • Encapsulate build logic for a language/tool.
  • Reusable and composable across packages.
  • The same target definition works on any machine (reproducible).
# libs/BUILD
filegroup(
    name = "util_srcs",
    srcs = ["util/index.js"],
)

genrule(
    name = "util_min",
    srcs = [":util_srcs"],
    outs = ["index.min.js"],
    cmd = "cat $(SRCS) | tr -d '\\n ' > $@",  # Demo-only "minify" by stripping spaces/newlines
)

Execution Flow:

  1. bazel build //libs:util_min resolves :util_srcs β†’ util/index.js.
  2. The genrule runs the command to produce index.min.js.
  3. Bazel caches the output to avoid redoing work next time.

2.6 Dependency Graph (DAG): What Needs to Rebuild?

Bazel models everything as a directed acyclic graph (DAG). Nodes are targets; edges are dependencies.

Key Characteristics:

  • Bazel traces exactly which inputs affect which outputs.
  • If nothing relevant changed, Bazel reuses cached results.
  • Independent nodes build in parallel. Mini Example (ASCII Graph):
        //app:bundle
            |
          :srcs
          /   \
   src/a.js   src/b.js

Execution Flow:

  1. Change src/a.js β†’ only targets depending on a.js rebuild.
  2. src/b.js untouched β†’ no rebuild from that edge.
  3. bundle.js is regenerated, but anything outside this subgraph is unchanged.

2.7 Caching & Incremental Builds

Bazel is cache-first. It fingerprints inputs (sources, flags, env) and skips work if a matching output already exists.

Key Characteristics:

  • Local cache: Speeds up repeated builds on your machine.
  • Remote cache: Share results across CI and teammates.
  • Incremental: Only rebuilds the impacted targets.

Try it:

# First build (cold):
bazel build //app:bundle

# Run again without changes (should be instant due to cache):
bazel build //app:bundle

Output (conceptual):

INFO: Analyzed target //app:bundle (0 packages loaded).
INFO: Found 1 target...
Target //app:bundle up-to-date (nothing to build)

3. JavaScript-Specific Concepts in Bazel

While Bazel is language-agnostic, JavaScript projects introduce unique challenges around package management, execution, and caching. The JS ecosystem in Bazel is typically powered by rules_nodejs or its modern successor rules_js.

3.1 How node_modules Are Managed

In traditional Node.js projects, node_modules is a physical folder in the repository root, often large, non-deterministic, and sensitive to environment differences. Bazel replaces this with a virtualized, hermetic node_modules tree.

Key Characteristics:

  • Lockfile-driven installs: Dependencies are resolved strictly from package.json + lockfile, ensuring consistent versions.
  • Hermeticity: The node_modules tree is generated by Bazel in a controlled environment, so the same inputs always produce the same structure.
  • Immutable storage: Once fetched, packages are stored in a content-addressed cache, avoiding repeated downloads.
  • No pollution of source tree: The physical node_modules folder is absent from your repo, preventing accidental edits or drift.

3.2 How Node.js Code Runs

Before understanding how Node.js executes inside Bazel, you need to know what a sandboxed action is.

A sandboxed action is Bazel’s way of running a build or test step in a completely isolated environment. In this sandbox, the action:

  1. Can only see the files and dependencies explicitly declared in its BUILD rule.
  2. Has no access to your system’s global packages, random environment variables, or unlisted files.
  3. Produces outputs only in a controlled directory managed by Bazel.

This ensures that:

  1. Builds are hermetic (fully determined by declared inputs).
  2. Results are reproducible (same output everywhere).
  3. There are no hidden dependencies on your local machine.

When running Node.js tools (like Webpack, Jest, or TypeScript compiler) in Bazel, these tools run as sandboxed actions using a Bazel-managed Node.js toolchain.

Key Characteristics:

  • Isolated execution: Tools run in a temporary sandbox with only the files Bazel knows about.
  • Toolchain-controlled Node.js: Bazel provides the Node runtime, ensuring consistent versions across all environments.
  • No global state leakage: Local node_modules, system binaries, or undeclared files are invisible to the action.
  • Consistent outputs: The same action produces identical results on any machine or CI server.
  • Parallel-safe: Multiple Node-based actions can run at the same time without interfering with each other.

3.3 Where node_modules Are Stored

Instead of scattering dependencies across multiple projects, Bazel centralizes them in its cache.

Key Characteristics:

  • Local cache: Shared between workspaces on the same machine, speeding up installs.
  • Remote cache: Optional but powerful-allows CI and developers to reuse each other’s build results.
  • Content-addressed storage: Identical dependencies are stored only once, even across different projects.
  • Separation from source: Eliminates β€œit works on my machine” issues caused by stale or mismatched node_modules.

3.4 Running npm/Yarn/Pnpm Scripts

In Bazel, package manager scripts aren’t run directly. Instead, they are wrapped as Bazel targets.

Key Characteristics:

  • Dependency graph integration: Scripts become part of the build DAG, allowing precise rebuilds.
  • Hermetic runs: Scripts execute in the same controlled environment as any other Bazel action.
  • Reproducibility: The same script produces identical results regardless of developer machine or CI environment.
  • Consistent inputs: Scripts only see dependencies and files explicitly declared to Bazel.

3.5 JS-Specific Caching & Incremental Builds

Bazel’s caching system tracks not just source files, but also the exact state of node_modules.

Key Characteristics:

  • Fine-grained invalidation: Only the targets affected by a change are rebuilt.
  • Dependency-aware caching: Updating a single package in node_modules won’t invalidate unrelated builds.
  • Lockfile fingerprints: Changes in package.json or lockfile trigger re-install only when necessary.
  • Remote cache synergy: Teams and CI pipelines can skip entire build steps if cache hits are found.

3.6 Conclusion

[Developer starts Bazel build]
            |
            v
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  1. Workspace Root    β”‚  <- WORKSPACE / MODULE.bazel
   β”‚  - Declares project   β”‚
   β”‚  - Defines deps/tools β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            |
            v
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ 2. Package Discovery      β”‚  <- BUILD files in subfolders
   β”‚  - Groups related sources β”‚
   β”‚  - Defines build targets  β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            |
            v
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ 3. Target Resolution      β”‚  <- Labels like //app:bundle
   β”‚  - Finds all dependencies β”‚
   β”‚  - Builds DAG of targets  β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            |
            v
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ 4. Dependency Fetch & Cache  β”‚
   β”‚  - Reads package.json/lock   β”‚
   β”‚  - Installs npm packages     β”‚
   β”‚  - Stores in Bazel cache     β”‚
   β”‚  - Creates virtual           β”‚
   β”‚    node_modules tree         β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            |
            v
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ 5. Rule Execution         β”‚  <- filegroup, genrule, js_binary...
   β”‚  - Each rule describes    β”‚
   β”‚    how to transform inputsβ”‚
   β”‚    into outputs           β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            |
            v
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ 6. Sandboxed Actions          β”‚
   β”‚  - Run tools in isolation     β”‚
   β”‚  - Only declared files visibleβ”‚
   β”‚  - Node.js from toolchain     β”‚
   β”‚  - Outputs to bazel-bin       β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            |
            v
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ 7. Incremental + Remote   β”‚
   β”‚    Caching                β”‚
   β”‚  - If inputs unchanged,   β”‚
   β”‚    reuse previous outputs β”‚
   β”‚  - Share build results    β”‚
   β”‚    via remote cache       β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            |
            v
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ 8. Build Success           β”‚
   β”‚  - Outputs ready in        β”‚
   β”‚    bazel-bin/              β”‚
   β”‚  - Deterministic artifacts β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

4. Best Practices for NestJS Projects with Bazel

Let’s imagine we have a basic NestJS project:

my-nest-app/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ app.module.ts
β”‚   β”œβ”€β”€ main.ts
β”‚   └── users/
β”‚       β”œβ”€β”€ users.module.ts
β”‚       β”œβ”€β”€ users.controller.ts
β”‚       └── users.service.ts
β”œβ”€β”€ test/
β”‚   β”œβ”€β”€ app.e2e-spec.ts
β”‚   └── jest-e2e.json
β”œβ”€β”€ package.json
β”œβ”€β”€ tsconfig.json

Step 1 - Start from package.json
As a Bazel beginner, the first place to look is package.json. Why? Because it tells us:

  • What dependencies need to be installed (dependencies, devDependencies).
  • Which scripts are important (start, build, test).
  • What entry points the app uses

Example:

{
  "scripts": {
    "start": "nest start",
    "build": "nest build",
    "test": "jest"
  },
  "dependencies": {
    "@nestjs/common": "^9.0.0",
    "@nestjs/core": "^9.0.0",
    "reflect-metadata": "^0.1.13",
    "rxjs": "^7.5.5"
  },
  "devDependencies": {
    "@nestjs/cli": "^9.0.0",
    "typescript": "^4.7.4",
    "jest": "^29.0.0"
  }
}

From this, we know:

  • We will need TypeScript compilation for src/ β†’ dist/.
  • We need to run Node.js with Bazel’s toolchain.
  • We need Jest tests (can also be run as Bazel test actions).
  • We need to fetch npm dependencies (node_modules).

Step 2 - Decide what Bazel should build
From package.json + project structure, we can break it down:

  1. Install npm packages β†’ Bazel will use yarn_install or npm_install to fetch and lock them in cache.
  2. Compile TypeScript β†’ We’ll define Bazel targets for the src/ folder using rules_nodejs or rules_ts.
  3. Bundle application β†’ Bazel produces an output folder (like bazel-bin/apps/my-nest-app) ready to run.
  4. Run tests β†’ Bazel can execute Jest tests in sandboxed mode.

Thinking as a Bazel Newbie: How to Implement the Build Here’s the thought process step-by-step:

  1. Node.js toolchain
  • We can’t rely on the system’s node version. Bazel should define and download the Node.js runtime.
  1. Install npm dependencies
  • Instead of running npm install, we use Bazel’s yarn_install or npm_install.
  • This locks dependencies in Bazel cache, so the build is reproducible everywhere.
  1. Compile TypeScript
  • Use Bazel’s ts_project or ts_library rules.
  • Point them to tsconfig.json and our src/ files.
  1. Bundle and Run
  • For NestJS, we can output the compiled JavaScript in bazel-bin/.
  • We can create a Bazel nodejs_binary that starts the app.
  1. Testing
  • Wrap jest in a Bazel test target.
  • Bazel will run tests in a sandbox, only with declared inputs.

Step 3 - Initialization
Before starting with Bazel in the project, you need to add two files: .bazelignore and .bazelrc.
.bazelignore tells Bazel what folders to skip during package scanning.
For NestJS, common ignored folders:

node_modules
dist

Why ignore?

  • node_modules is handled by Bazel’s dependency management (not local npm/yarn installs).
  • dist is build output, not a source package. If you don’t ignore these, Bazel will waste time scanning huge folders and slow down builds.

.bazelrc stores default Bazel flags for consistent builds.
For NestJS, you might configure:

  • Default Node.js toolchain.
  • Caching / remote cache settings.
  • Build verbosity for debugging.
  • Test timeout defaults.

Example:

build --strategy=TypeScriptCompile=worker
test --test_output=errors
common --experimental_ui_deduplicate

Step 4 - Choosing the Bazel Rules
We’ll use two main rules from rules_nodejs / rules_ts:

  1. ts_project Why?
  • It’s the most direct Bazel wrapper around tsc (TypeScript compiler).
  • It respects your tsconfig.json.
  • It outputs .js + .d.ts in a Bazel-managed directory (sandbox).
  • It avoids rebuilding unchanged files thanks to Bazel’s cache.
  1. nodejs_binary Why?
  • Once we have compiled JS, we need a runnable entry point.
  • nodejs_binary tells Bazel β€œthis target is executable with Node.js”.
  • It handles setting up the runtime environment, require paths, and symlinking dependencies from node_modules.

So the idea:

ts_project β†’ compiles TS β†’ nodejs_binary β†’ runs compiled code

Step 5 - Minimal WORKSPACE Setup
We need to declare rules for Node and TypeScript.

# WORKSPACE
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

# Node.js rules
http_archive(
    name = "build_bazel_rules_nodejs",
    url = "https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.2/rules_nodejs-5.8.2.tar.gz",
    sha256 = "<hash>",
)

load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories")
node_repositories(package_json = "//:package.json")

# TypeScript rules
http_archive(
    name = "aspect_rules_ts",
    url = "https://github.com/aspect-build/rules_ts/releases/download/v1.0.0/rules_ts-v1.0.0.tar.gz",
    sha256 = "<hash>",
)

load("@aspect_rules_ts//ts:repositories.bzl", "rules_ts_dependencies")
rules_ts_dependencies()

Why?

  • node_repositories reads your package.json & package-lock.json (or yarn.lock) to fetch npm dependencies into Bazel’s system.
  • rules_ts_dependencies sets up TypeScript compilation support.

Step 6 - BUILD File for NestJS
Inside src/BUILD.bazel:

load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")

# 1. Compile the TypeScript files
ts_project(
    name = "app_lib",
    srcs = glob(["**/*.ts"]),
    tsconfig = "//:tsconfig.json",
    deps = [
        "@npm//@nestjs/common",
        "@npm//@nestjs/core",
        "@npm//reflect-metadata",
        "@npm//rxjs",
    ],
)

# 2. Define the runnable binary (compiled JS entry point)
nodejs_binary(
    name = "app",
    entry_point = ":app_lib",  # Bazel automatically finds compiled main.js
)

Why this works:

  • ts_project compiles all .ts sources, respecting tsconfig.json.
  • The deps are the npm packages needed at compile time.
  • nodejs_binary wraps the compiled output into a Node.js runtime target you can run via:
bazel run //src:app

Step 7 – Building & Running

# Install deps for Bazel
npm install

# Build the app
bazel build //src:app

# Run the app
bazel <b>run //src:app</b>

Bazel will:

  1. Read package.json β†’ resolve npm deps into Bazel.
  2. Compile .ts files into .js inside sandbox.
  3. Link compiled output + dependencies into an executable.

Conclusion The Build Flow in Your Head

[.bazelrc + .bazelignore set the rules]
           |
           v
[WORKSPACE fetches Node.js + npm packages]
           |
           v
[BUILD files declare TS compilation targets]
           |
           v
[Bazel runs sandboxed actions for build + test]
           |
           v
[Outputs in bazel-bin β†’ Ready to run]
Ant Engineer
By Ant Engineer
I write bite-sized articles for developers