See all that green? Every green cell is a part of the screen the browser just repainted. The snake moves about 6 times a second, but the board below gets repainted 60 times a second.
All 176 cells get repainted on every frame, even when nothing moved.
Now switch to Dirty flag. The board goes calm. Only the cells the snake actually touched flash, and about 90% of the frames paint nothing at all.
Same game. Same picture. A fraction of the work. The trick is one of the simplest patterns in game development: the dirty flag.
Where I found it
I’m building a Factorio-style game in the browser. While working on it, I read the Dirty Flag chapter from Robert Nystrom’s Game Programming Patterns. The book is free online and really good. The chapter explains the pattern with a pirate ship and a scene graph full of matrix math.
In this post I want to explain it in the simplest way I can: with the Snake game above.
The problem: two loops, two speeds
A Snake game in the browser has two loops:
- The game loop moves the snake. A comfortable speed is one step every 150 ms, so about 7 moves per second.
- The draw loop runs on
requestAnimationFrame. That’s about 60 frames per second.
Notice the gap. The screen gets drawn 60 times a second, but the board only changes 7 times.
❌ The naive version: draw every frame
Here’s how most of us write our first Snake:
// snake.ts
setInterval(() => {
moveSnake(); // 7× per second
}, 150);
function frame() {
drawBoard(); // 60× per second: grid, snake, food, score
requestAnimationFrame(frame);
}
frame();
That’s the Draw every frame mode in the demo. It works. But out of every 60 frames, about 53 paint the exact same picture as the frame before.
That’s almost 90% wasted work. On a laptop you won’t notice. On a phone, the battery notices.
✅ The fix: a dirty flag
Add one boolean. It answers a single question: is the picture on screen out of date?
// snake.ts
let dirty = true; // nothing is drawn yet, so we start out of date
setInterval(() => {
moveSnake();
dirty = true; // the board changed
}, 150);
function frame() {
if (dirty) {
drawBoard();
dirty = false; // the screen matches the game again
}
requestAnimationFrame(frame);
}
frame();
That’s the whole pattern. Nystrom puts it like this:
- There’s primary data that changes over time. Here, that’s the snake and the food.
- There’s derived data that’s expensive to produce. Here, that’s the picture on the canvas.
- The dirty flag tracks when the derived data is out of sync with the primary data.
“Dirty” means out of date. “Clean” means you can reuse what you already have.
The demo at the top goes one step further. Instead of one flag for the whole board, it keeps one dirty flag per cell. A move only touches three cells: the new head, the old head and the tail that got freed up. That’s why only those cells flash.
It only pays off when changes are rare
Scroll back up and drag the speed slider. The faster the snake, the less the flag saves. At 1 move per second, almost every frame gets skipped.
A dirty flag only pays off when the data changes less often than you read it. If the board changed on every frame, the flag would never skip anything.
There’s also a free bonus. If the snake moves, eats and the score goes up in the same tick, all three set dirty = true. You still draw once, not three times.
Many changes, one recompute
Snake only has one thing to redraw. Nystrom’s pirate ship is where the pattern gets interesting, because the data is a tree.
A ship carries a crow’s nest. A pirate stands in the nest. A parrot sits on his shoulder. Each one stores its position relative to its parent, and the world position is the parent’s world position combined with its own. So when the ship moves, everything on it is out of date, even though the parrot itself didn’t do anything.
This is Robert Nystrom’s example from Game Programming Patterns. I drew my own ship and added counters so you can see the work.
Every move recomputes that node and all its children right away. The picture follows immediately.
Nothing has moved yet.
Example from Robert Nystrom's Game Programming Patterns, Dirty Flag chapter.
Try this:
- Pick Eager, press Move all four. The ship recomputes itself and all its children, then the nest does the same, and so on. The parrot ends up at 4, the total at 10.
- Press Reset counters, pick Dirty flag, press Move all four. Four dots turn red and nothing is recomputed. The picture is still old. Press Render and every node counts 1, the total is 4.
- Reset, then press only Move ship, then Render. The total is still 4. Only the ship got flagged, but the render passes the dirt down, so the nest, pirate and parrot recompute too. That’s correct. They really were out of date.
The trap: forgetting to set the flag
This is the bug you’ll actually ship. Say you add a power-up where the food teleports every five seconds:
// snake.ts
setInterval(() => {
food = randomFreeCell();
// forgot: dirty = true
}, 5000);
Nothing crashes. No error in the console. The food moved in the game state, but the screen still shows the old spot until the snake happens to move. The player steers toward food that isn’t there.
Try it in the demo at the top: switch to Forgot the flag, pause the snake and click Teleport food. The old apple stays on screen, the new one is invisible, and the stats turn red.
That’s what makes the bug nasty. A dirty flag is a cache, and the hard part of any cache is invalidation. Miss one place and you get a quietly wrong screen.
✅ The better fix: one door for every change
Nystrom’s advice is to put all changes behind a narrow API. If the only way to change the board also sets the flag, forgetting becomes impossible:
// snake.ts
let dirty = true;
function setFood(pos: Cell) {
food = pos;
dirty = true;
}
function setSnake(next: Cell[]) {
snake = next;
dirty = true;
}
Game code never writes food = ... directly again. It calls setFood(...).
I like this much more than the plain flag. Remembering things is a bad strategy, for humans and for coding agents.
Why a number and not a boolean
Snake uses true/false. That works as long as only one thing reads the flag.
Now add a minimap next to the board. Both the board and the minimap check dirty. The board redraws first and sets dirty = false. The minimap looks next, sees a clean flag and never updates.
The fix is a counter instead of a boolean:
// snake.ts
let version = 0;
function setFood(pos: Cell) {
food = pos;
version++; // never reset, only counts up
}
let boardSeen = -1;
let minimapSeen = -1;
function frame() {
if (boardSeen !== version) { drawBoard(); boardSeen = version; }
if (minimapSeen !== version) { drawMinimap(); minimapSeen = version; }
requestAnimationFrame(frame);
}
Each reader remembers the last number it saw. Nobody clears anything, so readers can’t steal the update from each other.
Rule of thumb: one reader, use a boolean. Several readers, use a counter.
You already use this every day: computed
If you write Vue, you’ve used dirty flags for years without seeing them.
const items = ref<Item[]>([]);
const total = computed(() => items.value.reduce((sum, i) => sum + i.price, 0));
Changing items doesn’t recompute total. It only marks total as dirty. The reduce runs the next time someone reads total.value, and only once, no matter how many times items changed in between. That’s exactly the Snake draw loop.
Vue 3.5 has a DIRTY bit on every computed, plus version counters next to it. It’s the boolean and the counter from the section above, in one place.
alien-signals, the signal library behind Vue 3.6’s reactivity rewrite, takes it one step further with two flags:
- Dirty: a value I read definitely changed. Recompute.
- Pending: something further up the chain changed. Maybe I’m out of date, maybe not.
Remember Nystrom’s pirate ship, where a parent moving makes every child out of date? Pending is that idea for a graph of signals. Let’s look at the actual code. The snippets are from alien-signals v3.2.0 (MIT), shortened, with my comments.
The flags are bits
All flags live in one number. Each flag is one bit:
// src/system.ts
export const enum ReactiveFlags {
None = 0,
Mutable = 1,
Watching = 2,
RecursedCheck = 4,
Recursed = 8,
Dirty = 16,
Pending = 32,
}
That’s the “dirty bit” from the book, literally. flags & ReactiveFlags.Dirty checks the bit, flags | ReactiveFlags.Pending sets it, flags & ~ReactiveFlags.Pending clears it. One number per node, no extra objects.
Writing a signal: mark, don’t compute
When you write a new value, the signal marks itself Dirty and hands off to propagate:
// src/index.ts — signalOper, the setter branch
if (this.pendingValue !== (this.pendingValue = value[0])) {
this.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty;
const subs = this.subs;
if (subs !== undefined) {
propagate(subs, !!runDepth); // walk down to everyone who reads me
// ...
}
}
propagate walks down the graph, and every subscriber that’s still clean only gets the Pending bit:
// src/system.ts — propagate
if (!(flags & (ReactiveFlags.RecursedCheck | ReactiveFlags.Recursed | ReactiveFlags.Dirty | ReactiveFlags.Pending))) {
sub.flags = flags | ReactiveFlags.Pending; // "maybe out of date"
// ...
}
Notice what’s missing: no getter runs here. Writing a signal is just setting bits. Same as dirty = true in Snake.
Reading a computed: check, then maybe recompute
The work happens when someone reads a computed:
// src/index.ts — computedOper
const flags = this.flags;
if (
flags & ReactiveFlags.Dirty // definitely stale: recompute
|| (
flags & ReactiveFlags.Pending // maybe stale:
&& (
checkDirty(this.deps!, this) // ask my sources if they really changed
|| (this.flags = flags & ~ReactiveFlags.Pending, false) // they didn't: just clear Pending
)
)
) {
updateComputed(this); // run the getter, once
}
// ...
return this.value!;
That’s the if (dirty) from the Snake draw loop, with one extra step. A Pending computed first runs checkDirty, which walks up its sources. If none of them actually produced a new value, it clears the bit and returns the cached value. The getter never runs.
Here’s when that pays off:
const count = signal(1);
const isOdd = computed(() => count() % 2 === 1);
const label = computed(() => (isOdd() ? "odd" : "even"));
count(3); // isOdd and label become Pending
label(); // isOdd recomputes: still true. label stays cached.
With a single Dirty bit, label would have to recompute too. The Pending bit lets the library stop as soon as a value turns out unchanged.
Here’s that example with the flags visible. I rebuilt the idea as a tiny TypeScript model so you can poke at it. It’s a re-implementation, not the library itself, but I checked that its run counts match alien-signals 3.2.0.
Writing a signal only flips flags. A getter runs when someone reads, and only if a source really changed.
the source of truth
reads count
reads isOdd
Press a button and watch the flags.
A simplified model of alien-signals' Dirty and Pending flags. Run counts match the real library.
Try this:
- Press count + 2.
countgoes from 1 to 3 and both computeds turn Pending. Nothing re-ran. Press Read label:isOddruns again and is stilltrue, solabelstays cached. - Press count + 1, then Read label. Now
isOddflips tofalse, solabelhas to run too. - Press count + 1 a few times without reading, then Read label once.
isOddruns once, no matter how many writes you made.
Source links: ReactiveFlags, propagate, computedOper, signalOper.
So the pattern from a 2014 game programming book is sitting in the core of a modern frontend framework.
When not to use a dirty flag
The pattern adds code and a new way to have bugs. So don’t reach for it by default.
- The data changes as often as you read it. Think of a game clock that ticks every frame. It’s always dirty, so a flag would never skip anything.
- You can update the result directly. Nystrom’s example is the total weight of loot on a ship. Don’t recount everything when it’s needed. Keep a running total and add or subtract as items come and go. In Snake, the score works the same way:
score++on every apple, no recount. - It’s already fast enough. Honestly, drawing a 16×11 Snake board 60 times a second is fine. I used Snake because it’s easy to see, not because it needs the optimization. The pattern earns its keep when the derived work is actually slow, like painting a big tile map or rebuilding a graph of conveyor belts.
See it in your own code
Put console.count("draw") at the top of whatever expensive function you think is cached. Stand still, then change one thing. If the counter keeps climbing while nothing happens, you’ve found your wasted work.
Summary
A dirty flag is one bit, or one number, that says “this result is out of date”. Set it when the source changes. Check it when you need the result. Redo the work only when it’s set.
The pattern itself takes five minutes to learn. The discipline is the hard part: every change has to set the flag. So don’t rely on discipline. Route every change through one function and let the code remember for you.
If you want to go deeper, read the Dirty Flag chapter in Game Programming Patterns. It covers the parts I skipped, like when to clean the flag and how fine-grained your tracking should be.