Optimizing Bevy Systems: Balancing Query Access and Parallelism
Learn how to optimize Bevy engine performance by balancing Query access patterns and avoiding common pitfalls like archetype moving and scheduler bottlenecks.
03 Feb 2026, 00:42 UTC

The Bottleneck of Exclusive Access
In a Bevy application, the goal is often to maximize the number of systems running in parallel across available CPU cores. However, developers frequently encounter a performance plateau where adding more systems doesn't improve frame times. The culprit is usually exclusive access.
Because Bevy's scheduler uses the types declared in your Query to determine which systems can run simultaneously, requesting mutable access (&mut T) to a component locks that component for every other system that needs it. If five different systems all require &mut Transform, Bevy must run them sequentially, effectively turning your multi-threaded engine into a single-threaded one for those operations.
How Queries Drive the Scheduler
Bevy uses an Entity Component System (ECS) where Entities are simple IDs and Components are plain data structures. A System is a function that asks the world for specific data via a Query.
The scheduler analyzes these queries to build a dependency graph. It follows two primary rules:
- Multiple systems can read the same component (
&T) simultaneously. - Only one system can write to a component (
&mut T) at a time, and no other system can read it while it is being written.
With<T> and Without<T> allow you to filter entities based on the presence of a component without actually accessing its data, which avoids unnecessary locks on the scheduler.
Practical Example: Movement and Collision
Consider a scenario where you have a Position component and a Velocity component. You want one system to update positions and another to check for collisions. If both systems request &mut Position, they cannot run in parallel.
// Run this in a Bevy app (v0.13+) with the default scheduler
use bevy::prelude::*;
#[derive(Component)]
struct Position(Vec2);
#[derive(Component)]
struct Velocity(Vec2);
#[derive(Component)]
struct Collider;
// System 1: Updates position based on velocity
// Access: Read Velocity, Write Position
fn apply_velocity(mut query: Query<(&Velocity, &mut Position)>) {
for (vel, mut pos) in query.iter_mut() {
pos.0 += vel.0;
}
}
// System 2: Checks for collisions
// Access: Read Position, Read Collider
// Note: We use &Position instead of &mut Position to allow parallelism
fn check_collisions(query: Query<(&Position, &Collider)>) {
for (pos, _collider) in query.iter() {
// Perform read-only collision detection logic
}
}
In this configuration, apply_velocity and check_collisions cannot run at the exact same time because the first requires mutable access to Position. However, if you split the collision logic into a Detection phase (read-only) and a Resolution phase (mutable), you can maximize throughput.
The Cost of Archetype Moving
While optimizing queries, be mindful of how you modify entities. Bevy stores entities with the same set of components in Archetypes—contiguous blocks of memory that optimize CPU cache hits.
When you add or remove a component from an entity at runtime (e.g., adding a Frozen component to a player), Bevy must move that entity from one archetype to another. This "archetype moving" involves copying data between memory locations. If you are adding and removing components every frame for hundreds of entities, the overhead of moving data can outweigh the benefits of the ECS structure.
Comparison: Component Toggle vs. State Flag
| Approach | Mechanism | Performance Impact | Scheduler Effect |
|---|---|---|---|
| Component Toggle | commands.entity(e).insert(Frozen) |
High (Archetype move) | Changes Query eligibility |
| State Flag | struct Status { is_frozen: bool } |
Low (Data update) | Consistent Query access |
Verification and Limitations
To verify if your queries are causing bottlenecks, you can use a profiling tool or the bevy_inspector_egui plugin to monitor entity components. A practical check for parallelism is to observe the system execution order in logs; if systems that don't share mutable data are still running sequentially, check for a shared ResMut<T> (Resource) that might be locking the entire system.
Limitation: Bevy's API is evolving rapidly. The specific syntax for queries and the internal behavior of the scheduler may change between minor versions. Always verify your component access patterns against the current version's documentation.
Actionable Summary
To keep your Bevy game performant:
- Use
&Tinstead of&mut Twhenever possible to allow the scheduler to parallelize systems. - Use
With<T>andWithout<T>for filtering to avoid locking components you don't need to read or write. - Prefer updating a boolean flag inside a component over adding/removing components frequently to avoid archetype migration costs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.