Stop Recomputing Everything: Bevy Change Detection as a Data-Flow Tool
Bevy's ECS already tracks every component write. Filtering systems with Changed<T> and Added<T> turns wasteful per-frame polling into explicit, reactive data flow — if you avoid the false-positive traps.
11 Apr 2026, 06:18 UTC

Your game has 4,000 entities with a Health component, and every frame a system walks all of them to rebuild health bars. On any given frame, maybe three entities took damage. The other 3,997 iterations are pure waste — and worse, the system hides a real question: which entities actually need this work?
Bevy already knows the answer. Its ECS tracks every component write, and you can filter queries on that bookkeeping with Added<T> and Changed<T>. Used well, change detection is not just a micro-optimization — it makes your data flow explicit: systems declare what they react to, instead of polling the world and hoping.
How Bevy knows what changed
Every time Bevy observes mutable access to a component — typically through DerefMut on a Mut<T> query item — it stamps that component with the current change tick. Each system independently remembers the last tick it ran. A query filtered with Changed<Health> then matches only entities whose Health was written since that system last executed.
Two details matter here. First, detection is per-system, not global. If two systems both filter on Changed<Health>, each sees every change since its own last run — one system "consuming" the change never hides it from the other. Second, detection is write-based, not value-based. If you assign a field its current value, Bevy still counts it as a change, because it saw the mutable access, not the result. Changed answers "was this touched?", not "is this different?"
A worked example: health bars that only rebuild on damage
Here is the pattern in a form you can drop into a Bevy 0.14-era app (check the migration guide for your pinned version — these APIs do shift between releases):
use bevy::prelude::*;
#[derive(Component)]
struct Health { current: f32, max: f32 }
#[derive(Component)]
struct HealthBar;
// Runs for every matching entity, every frame. Wasteful.
fn update_all_bars(query: Query<(&Health, &mut Style), With>) {
for (health, mut style) in &query {
style.width = Val::Percent(100.0 * health.current / health.max);
}
}
// Runs the body only for entities whose Health was written
// since this system last ran.
fn update_changed_bars(
query: Query<(&Health, &mut Style), (With, Changed)>,
) {
for (health, mut style) in &query {
style.width = Val::Percent(100.0 * health.current / health.max);
}
}The only difference is the Changed<Health> filter, but the semantics change completely: the second system is now a reaction to damage, healing, and initialization writes, rather than a poll. The same pattern applies to anything derived — syncing a transform to a physics body, rebuilding a navmesh tile when an obstacle moves, refreshing a tooltip when stats change.
Added<T> is the companion filter: it matches components inserted since the system last ran. It cleanly replaces the ad-hoc initialized: bool flags people bolt onto components. A system with Query<&Transform, Added<Enemy>> runs its setup logic exactly once per spawned enemy, with no flag to reset and no risk of double-initialization.
For singleton state, resources get the same treatment through methods rather than filters: Res<GameSettings> exposes is_changed() and is_added(), so you can early-return from a system unless the settings were actually written this frame.
The false-positive trap
The most common way change detection silently stops helping is unconditional mutation upstream. If your damage system does this every frame:
fn regen(mut query: Query<&mut Health>) {
for mut health in &mut query {
// Marks Health as changed EVERY frame, even at full hp.
health.current = health.current.min(health.max);
}
}...then your Changed<Health> filter downstream matches everything, every frame, and you have paid the filter's cost for zero benefit. The fix is to guard the write:
fn regen(mut query: Query<&mut Health>) {
for mut health in &mut query {
if health.current < health.max {
health.current = (health.current + 0.5).min(health.max);
}
}
}Only mutable access that actually happens gets stamped, so skipping the DerefMut keeps the component "unchanged." A useful mental rule: treat every Mut<T> dereference as publishing an event. Don't publish noise.
For the rare case where you genuinely must write without notifying anyone, Mut<T> offers bypass_change_detection(). Reach for it deliberately — overusing it silently starves every reactive system downstream, and the failure mode (stale UI, desynced physics) is confusing to debug. Treat it as an explicit opt-out with a comment explaining why.
Limitations worth knowing before you commit
- Granularity is per component, not per field. Mutating one field of a fat component marks the whole thing changed. If only one field drives expensive derived work, consider splitting it into its own component so the filter stays selective.
- Infrequent systems see stale ticks. A system that runs on a timer, or one just added to the schedule, may see a backlog of "changes" on its first run. First-run behavior can differ from steady state — usually harmless, but don't be surprised by it.
- It is conservative by design. You cannot use it to detect meaningful changes, only writes. If you need value-level diffing, you have to store and compare the previous value yourself.
- APIs move. Bevy releases frequently; confirm filter names and tick behavior against the version in your
Cargo.tomland its migration guide.
Verify it actually helps
Don't take the frame-time win on faith. Two quick checks: first, add a temporary info! inside the gated system and confirm it logs only on frames where a write occurred — then deliberately assign a field its current value upstream and watch the log fire anyway, confirming the write-based semantics. Second, measure. Enable Bevy's LogDiagnosticsPlugin or wire up tracing spans around the system, and compare frame time with and without the Changed filter under a realistic entity count. If the gated system was cheap to begin with, the filter may not pay for its own bookkeeping — in that case, keep the filter only if the data-flow clarity is worth it, and say so in a comment.
The broader habit to build: whenever you write a system that scans everything to find the few entities that matter, ask whether the write that made them matter could have announced itself instead. In Bevy, it usually already did.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.