Controlling Frame Time in Bevy with Chunked Query Iteration
Bevy's ECS lets you cap per-frame entity updates with chunked query iteration. See a worked Rust example, chunk-size trade-offs, and common pitfalls when mutating components.
05 Aug 2026, 04:46 UTC

The problem: one frame, thousands of updates
When a Bevy system queries tens of thousands of entities and mutates their components in a single frame, frame time can exceed your target budget. The ECS makes it easy to write a system that touches every matching entity at once, but that all-at-once approach can cause visible stutter, especially on low-end hardware. The practical fix is to cap the amount of work per frame using Bevy's chunked query iteration.
Useful takeaway
Bevy's Query API supports iter_mut().chunk(n), which returns an iterator that yields slices of at most n items. By consuming only one chunk per frame—for example, 100 entities—you spread the total work across multiple frames. This behaves like pagination for your entity updates: each frame processes the next batch, keeping the per-frame cost predictable.
Worked example
Assume a Bevy 0.13 project. Define two components and a startup system that spawns a large population:
use bevy::prelude::*;
#[derive(Component)]
struct Position { x: f32, y: f32 }
#[derive(Component)]
struct Velocity { dx: f32, dy: f32 }
fn setup(mut commands: Commands) {
for i in 0..10_000 {
commands.spawn((
Position { x: i as f32, y: 0.0 },
Velocity { dx: 1.0, dy: 0.5 },
));
}
}
fn move_entities(mut query: Query<(&mut Position, &Velocity)>) {
// Process at most 100 entities per frame
for (mut pos, vel) in query.iter_mut().chunk(100).next().unwrap_or(&[]) {
pos.x += vel.dx;
pos.y += vel.dy;
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, move_entities)
.run();
}
Run this with cargo run from your project directory; no elevated permissions are required. The key line is query.iter_mut().chunk(100).next(): chunk(100) produces an iterator over slices of up to 100 items, and .next() takes only the first slice, leaving the rest for subsequent frames. Because the query is re-created each frame, the next call resumes from where the ECS leaves off—though Bevy does not guarantee a stable ordering across frames.
Why this works
Bevy stores components in contiguous tables (archetypes). A query iterator walks those tables. Without chunking, the system visits every matching entity in one pass. Calling chunk(n) inserts a boundary into that walk. The ECS still has to find the matching archetypes, but the component mutation work is bounded by n. That keeps the system's CPU time roughly constant per frame instead of scaling linearly with entity count.
Limits and trade-offs
- Chunk size is a budget knob. Too small and per-frame overhead (iterator setup, scheduling) dominates. Too large and you reintroduce the frame spike you were trying to avoid. There is no universal correct number; measure.
- No ordering guarantee. Bevy does not promise that chunk iteration preserves entity order across frames or across runs. Do not rely on chunk boundaries aligning with spawn order or any particular sequence.
- Exclusive access is required for mutation. The example uses
Query<(&mut Position, &Velocity)>. If you use a shared reference for a component you intend to change, the compiler will reject it. If you hold a shared query reference and mutate component storage by another path, Bevy will panic at runtime. - Not a persistence mechanism. Chunking controls scheduling, not storage. If you stop calling the system, progress pauses; it does not resume from a saved cursor.
Common mistakes
- Forgetting to register the system. Defining
move_entitiesdoes nothing until you call.add_systems(Update, move_entities)on theApp. - Using a non-exclusive query for writes.
Query<(&Position, &Velocity)>compiles, but you cannot mutatePositionthrough it. The borrow checker enforces this at compile time. - Assuming deterministic order. Code that depends on "the first 100 entities" being the same entities every frame is fragile. Archetype layout and internal bookkeeping can change.
- Stale iterators. Do not store a chunk iterator in a resource and reuse it across frames. Queries are re-created each frame; a stored iterator may reference stale table data or fail to compile due to lifetime rules.
How to verify the behavior
In a terminal inside your Bevy project, run cargo run. Add a temporary info! or println! inside the loop to confirm that at most 100 entities are touched per frame. To check frame timing, use Bevy's built-in FrameCount resource or an external profiler. If you remove .chunk(100).next() and iterate the full query, you should observe longer frame times as the entity count grows. If you change &mut Position to &Position while keeping the mutation, the project will fail to compile, confirming the exclusivity rule.
Before adopting this pattern, confirm that the chunk method is available on the query iterator in your exact Bevy version; iterator adapters can shift between minor releases. If the method has moved to an extension trait or been renamed, adjust the call accordingly.
Risk checklist
Before shipping, confirm that your chosen chunk size keeps frame time under budget on your lowest-target hardware. Verify that no other system mutates Position while move_entities runs, or Bevy will panic. Finally, profile with the chunk call present and absent to ensure the overhead is worth the stability gain.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.