Optimizing Bevy System Performance via Query Access Patterns
Learn how to optimize Bevy engine performance by refining Query access patterns, using change detection, and reducing synchronization bottlenecks in the ECS scheduler.
23 Feb 2026, 22:27 UTC

The Parallelism Bottleneck
In Bevy, the engine automatically parallelizes your systems across available CPU cores. However, this parallelism is not magic; it is governed by the Query parameters you define in your system functions. The most common cause of unexpected frame drops or CPU under-utilization in Bevy is exclusive access contention.
When you request Query<&mut Transform>, you are telling the scheduler that this system needs mutable access to every Transform component in the world. If another system also requests &mut Transform, Bevy must run them sequentially to prevent data races. If you only needed to read the transform to calculate a distance, but used &mut by habit, you have unnecessarily blocked other systems from running.
Refining Access with Read-Only Queries
To maximize throughput, you should always default to read-only access (&T) and only upgrade to mutable access (&mut T) when a value actually needs to change. Bevy's scheduler treats Res<T> and Query<&T> as shared locks, meaning dozens of systems can read the same data simultaneously.
Beyond simple read/write distinctions, use Filters to narrow the scope of your query. Filters like With<T> and Without<T> do not provide access to the component data itself, but they tell the engine to only iterate over entities that possess (or lack) those specific components. This reduces the number of entities the system must process per frame.
Reactive Systems with Change Detection
Iterating over thousands of entities every frame is wasteful if only five of them moved. Bevy provides Changed<T> as a query filter to implement a reactive pattern. This allows a system to skip entities whose components haven't been modified since the last time the system ran.
// Run this on the main application thread
// Required permissions: Standard Bevy App setup
fn update_sprite_position(mut query: Query<&mut Transform, Changed<Transform>>) {
for mut transform in &mut query {
// This block only executes for entities where Transform was mutated
// by another system in the previous stage.
println!("Transform changed: {:?}", transform.translation);
}
}
Example: Decoupling Physics and Rendering
Consider a scenario where you have a physics system updating positions and a rendering system updating visual effects. If both use Query<&mut Transform>, they cannot run in parallel.
| System | Inefficient Query | Optimized Query | Impact |
|---|---|---|---|
| Physics | Query<&mut Transform> |
Query<&mut Transform, With<RigidBody>> |
Limits mutation to physics objects only. |
| Visuals | Query<&mut Transform> |
Query<&Transform> |
Allows parallel execution with Physics. |
Trade-offs and Limitations
While narrowing queries improves parallelism, there is a trade-off in archetype fragmentation. In Bevy, entities with the same set of components are stored together in "archetypes." If you create too many unique combinations of components to satisfy specific With/Without filters, the engine must jump between more memory locations, which can increase cache misses.
Additionally, be aware that Changed<T> is triggered any time a mut T is accessed, even if the value wasn't actually changed. If you frequently call &mut Transform but rarely change the values, your "reactive" systems will still run for every entity.
Verifying Your System Schedule
To check if your query changes actually improved parallelism, you can use a profiling tool or the Bevy Inspector plugin. A practical way to verify if a system is being blocked is to temporarily add a std::thread::sleep to a suspected bottleneck system; if other unrelated systems also slow down, they are likely contending for the same Query access.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.