Bazel doesn’t have a watch mode.
That surprises people. You’ve got this hyper-optimized build system that knows your entire dependency graph, can cache everything, can rebuild only what changed — and it doesn’t have a --watch flag. You have to invoke bazel build every time, like some kind of animal.
The reason is architectural. Bazel is designed as a hermetic, one-shot computation: you give it a target, it figures out the minimal work, does it, and exits. It doesn’t “stay alive.” The Bazel server process sticks around for performance (so analysis state is warm), but the command itself is fire-and-forget.
Enter ibazel — short for “iterative Bazel.” It’s the official answer to “I just want it to rebuild when I save.”
How ibazel works
The mechanism is more interesting than “watches files, reruns build.” ibazel actually talks to Bazel to figure out which files to watch.
Here’s the loop:
-
Query phase — ibazel runs
bazel query(specifically, it uses the build event stream) to discover every source file that contributes to your target. Not just the files in your package — the full transitive closure of inputs. -
Watch phase — It sets up OS-level file watchers (inotify on Linux, FSEvents on macOS) on exactly those files. Not your whole repo. Not some glob pattern. The precise set of files Bazel told it about.
-
Trigger phase — When a watched file changes, ibazel re-invokes
bazel build(ortest, orrun) on the same target. -
Re-query — After the build completes, ibazel re-queries the dependency graph. Why? Because your edit might have changed the deps. Maybe you added an
importstatement that pulls in a new source file. The watch set needs to stay current.
This is the key insight that makes ibazel fundamentally different from generic file watchers. It doesn’t watch “all .py files in src/.” It watches exactly the files Bazel says matter for this specific target. Change a file in an unrelated package? ibazel doesn’t flinch.
┌──────────────┐ "what files matter ┌───────────┐
│ ibazel │ ──── for //my:target?" ────▶ │ Bazel │
│ (watcher) │ │ (query) │
│ │ ◀──── [file1, file2, ...]──── │ │
└──────┬───────┘ └───────────┘
│
│ inotify / FSEvents
▼
┌──────────────┐
│ OS kernel │ ── "file2 changed!" ──▶ ibazel re-triggers build
└──────────────┘
Usage patterns
ibazel is a drop-in replacement for the bazel command. Same syntax, same targets. You just swap the binary name:
# One-shot build (normal Bazel)
bazel build //src/server:app
# Continuous build (ibazel)
ibazel build //src/server:app
The three modes map to the three Bazel commands you’d expect:
ibazel build
Rebuilds on change. That’s it. Useful when you just want fast feedback on whether your code compiles.
ibazel build //src/lib:core
ibazel test
Rebuilds and reruns tests. This is the one you’ll use most during development. Save a file, tests run automatically, results show up in your terminal.
ibazel test //src/lib:core_test
You can pass through Bazel flags too:
ibazel test //src/lib:core_test --test_output=errors --test_filter="TestParsing"
ibazel run
This is where it gets interesting. ibazel run starts a long-running process (like a dev server), and when files change, it kills the old process and starts a new one.
ibazel run //src/server:devserver
The restart behavior depends on the binary. ibazel sends SIGTERM, waits, then relaunches. For web servers, this means you get automatic server restart on code changes — the poor person’s hot reload.
The live reload story
ibazel has a built-in livereload protocol. If you’re building a web frontend, you can wire it up so the browser auto-refreshes when assets change.
Here’s how it works: ibazel can inject a livereload server (on port 35729 by default) that speaks the LiveReload protocol. Your page includes a small JS snippet that connects to the livereload server via WebSocket. When ibazel detects a successful rebuild, it sends a reload signal, and the browser refreshes.
ibazel run //web:devserver --livereload
The flow looks like:
- You edit
style.css - ibazel detects the change, triggers
bazel build - Build succeeds
- ibazel pings the livereload WebSocket: “hey, reload”
- Browser refreshes
It’s not hot module replacement (HMR) — it’s a full page reload. But for many workflows, that’s good enough. And unlike framework-specific HMR setups, it works with any Bazel target that produces web assets.
Some teams go further. The rules_js and rules_ts ecosystems have targets specifically designed to work with ibazel’s reload signal. The concatjs_devserver (from the old rules_nodejs) was built around this exact workflow.
The gotchas
ibazel is useful, but it has some sharp edges that’ll bite you if you don’t know about them.
BUILD files aren’t watched by default
This is the big one. You edit BUILD.bazel to add a new dependency, save, and… nothing happens. ibazel watched the source files that Bazel told it about, and BUILD files aren’t source files in that sense — they’re build configuration.
There’s a flag for this (--watch_build_files or similar depending on version), but it’s not on by default because modifying BUILD files often triggers expensive re-analysis.
Workaround: Just Ctrl+C and restart ibazel after BUILD file changes. It’s annoying, but it’s predictable.
Generated sources are tricky
If your build generates source files (protobuf codegen, template expansion, etc.), ibazel watches the inputs to the generation step, not the generated files themselves. This is actually correct behavior — you want changes to my_service.proto to trigger a rebuild, not changes to the generated my_service.pb.go.
But it can be confusing when you’re debugging the codegen step itself. If the generator tool changes but the proto file doesn’t, ibazel might not notice (depending on how your rules declare their dependencies).
Remote execution complications
ibazel assumes builds are fast. That’s the whole point — quick feedback loop. But if your builds are routed through remote execution (RBE), every build has network latency. The “type, save, see result” loop that feels instant with local builds can become sluggish.
Worse, some RBE setups have authentication tokens that expire. ibazel running for hours in the background might suddenly start failing with auth errors and give you confusing output.
One Bazel command at a time
Bazel has a single-server model. Only one Bazel command can run at a time per workspace. If ibazel is mid-build and you try to run bazel query in another terminal, you’ll get a lock error.
This is a Bazel limitation, not an ibazel one, but it bites harder with ibazel because the build is running automatically. You save a file, ibazel kicks off a build, and now your manual bazel build in the other terminal has to wait.
Debouncing and rapid saves
If you save a file multiple times in quick succession (or your editor does atomic saves with temp files), ibazel might trigger multiple builds. It does some debouncing, but the window is short. You’ll occasionally see a build get interrupted and restarted, which wastes the work from the first build.
How it compares to generic watchers
You could skip ibazel entirely and use a generic file watcher. Let’s look at the tradeoffs.
entr
entr is a Unix utility — you pipe file paths to it and it runs a command when they change:
find src/ -name '*.py' | entr bazel test //src:test
It works. But notice what’s missing: find src/ -name '*.py' is a guess at which files matter. If your target depends on files outside src/, you won’t catch those changes. If there are .py files in src/ that aren’t part of the target, you’ll trigger unnecessary rebuilds.
ibazel’s query-based approach eliminates both problems. It watches exactly the right files, no more, no less.
watchexec
watchexec is more sophisticated than entr — it has glob patterns, ignore rules, debouncing. But it’s still pattern-based:
watchexec -e py,pyi -- bazel test //src:test
Same fundamental issue. You’re approximating the dependency graph with file extensions.
IDE-based watchers
Your IDE probably has a “run on save” feature. VS Code tasks, IntelliJ file watchers, etc. These work fine for simple setups, but they:
- Don’t know about Bazel’s dependency graph
- Are tied to your editor (CI or teammates might use different editors)
- Usually can’t do the livereload protocol trick
The comparison table
| Feature | ibazel | entr | watchexec | IDE watcher |
|---|---|---|---|---|
| Watches exact deps | ✅ | ❌ | ❌ | ❌ |
| Auto-updates watch set | ✅ | ❌ | ❌ | ❌ |
| Livereload protocol | ✅ | ❌ | ❌ | Sometimes |
| Zero config | ✅ | Needs file list | Needs patterns | Needs setup |
| Works outside Bazel | ❌ | ✅ | ✅ | ✅ |
| OS support | Linux/macOS | Linux/macOS/BSD | Everywhere | Per-editor |
The auto-gazelle connection
Here’s a pattern I’ve seen teams adopt: wiring Gazelle into the ibazel loop.
Gazelle is the tool that auto-generates BUILD files from your source code. Normally you run it manually (bazel run //:gazelle) after adding imports or creating new files. But some teams set up a workflow where:
- ibazel watches source files
- On change, a wrapper script runs
gazellefirst to regenerate BUILD files - Then runs the actual
bazel buildorbazel test
This way, you add a new import, save, and the BUILD file updates itself before the build runs. No more “oh right, I need to run gazelle” interruptions.
The implementation varies. Some teams use a shell script wrapper. Some use ibazel’s --run_output hooks. Some put a gazelle invocation inside a custom Bazel rule that runs before the main build.
It’s not officially supported as a first-class workflow, and it adds latency (gazelle isn’t free, especially on large repos). But for teams where “forgetting to run gazelle” is a constant friction point, it’s a nice quality-of-life improvement.
#!/bin/bash
# Wrapper script: gazelle-then-build.sh
bazel run //:gazelle -- src/
bazel test //src/...
The smarter version scopes gazelle to only the packages that changed, but that requires parsing ibazel’s output or the filesystem events — at which point you’re basically writing your own build orchestrator on top of ibazel.
Installing and getting started
ibazel is a Go binary distributed separately from Bazel:
# Via npm (common in web-focused Bazel projects)
npm install -g @bazel/ibazel
# Via Go
go install github.com/bazelbuild/bazel-watcher/cmd/ibazel@latest
# Or download a release binary from GitHub
# https://github.com/bazelbuild/bazel-watcher/releases
Once installed, just use it like bazel:
# Start watching and testing
ibazel test //my/package:tests
# With Bazel flags passed through
ibazel build //my/package:target --config=debug
There’s no configuration file. No .ibazelrc. It reads your .bazelrc like normal Bazel does. The only ibazel-specific flags control things like the livereload port and notification behavior.
When to use it, when not to
Use ibazel when:
- You’re doing active development with short edit-compile-test cycles
- You’re building a web frontend and want live reload
- You want “save and see” without Alt-Tabbing to run a command
- Your builds are fast enough that the feedback loop feels instant (a few seconds)
Skip ibazel when:
- Your builds take minutes (the feedback loop becomes meaningless)
- You’re using remote execution heavily (latency kills the UX)
- You’re editing BUILD files more than source files (ibazel won’t help)
- You prefer explicit control over when builds run
The sweet spot is local development on a well-cached project where incremental rebuilds are under 10 seconds. In that zone, ibazel makes the experience feel almost like an interpreted language — save and see.
For everything else, there’s Ctrl+Up, Enter.