Canvas State: save & restore
The 2D canvas keeps a single, mutable “drawing state” — every fill color, stroke width, font, and transformation you set sticks around until you change it again. As drawings grow, that global mutability becomes a liability: a rotation applied for one shape quietly bends every shape after it. The save() and restore() methods solve this by giving you a stack of state snapshots, so you can change anything temporarily and roll back to exactly where you were.
What lives in the drawing state
When you call ctx.save(), the canvas pushes a snapshot of the current drawing state onto an internal stack. ctx.restore() pops the most recent snapshot and makes it active again. The state captured is everything except the actual pixels on the canvas and the current path — those are not part of the saved state.
| Captured by save() | Not captured |
|---|---|
fillStyle, strokeStyle | The bitmap (pixels already drawn) |
lineWidth, lineCap, lineJoin, miterLimit | The current path (subpaths) |
lineDashOffset and the dash pattern | width / height of the canvas |
font, textAlign, textBaseline, direction | |
globalAlpha, globalCompositeOperation | |
shadowColor, shadowBlur, shadowOffsetX/Y | |
| The current transformation matrix | |
| The current clipping region |
Because the transform and the clip are on this list, save/restore is the standard tool for scoping a rotation, translation, or clip to one part of your scene.
Basic pairing
Always treat save() and restore() as a matched pair, like opening and closing a bracket. Change whatever you need after save(), draw, then restore() to undo all of those changes at once.
ctx.fillStyle = "navy";
ctx.fillRect(10, 10, 40, 40); // navy
ctx.save(); // snapshot: fillStyle is "navy"
ctx.fillStyle = "crimson";
ctx.globalAlpha = 0.4;
ctx.fillRect(60, 10, 40, 40); // translucent crimson
ctx.restore(); // back to navy, alpha 1
ctx.fillRect(110, 10, 40, 40); // navy again, fully opaque
You never have to manually reset fillStyle or globalAlpha — restore() does it for the whole state in one call.
Why it is essential when transforming
Transforms are cumulative. translate, rotate, and scale multiply into the current matrix rather than replacing it, so without isolation a single rotate(0.1) keeps tilting everything drawn afterward. Wrapping each transformed object in save/restore keeps the matrix changes local.
<canvas id="c" width="400" height="200" style="background:#0f172a"></canvas>
<script>
const ctx = document.getElementById("c").getContext("2d");
const colors = ["#f87171", "#fbbf24", "#34d399", "#60a5fa", "#a78bfa"];
colors.forEach((color, i) => {
ctx.save(); // isolate this card's transform
ctx.translate(50 + i * 70, 100); // move origin to the card center
ctx.rotate((i - 2) * 0.18); // tilt only this card
ctx.fillStyle = color;
ctx.fillRect(-25, -35, 50, 70); // draw around the new origin
ctx.restore(); // matrix back to identity
});
</script>
Each card is positioned and rotated independently. Remove the save/restore lines and the rotations compound into an unreadable spiral — proof of why the pairing matters.
The stack is nested (LIFO)
The state stack is last-in, first-out, so calls nest cleanly. Each restore() matches the most recent unmatched save(). This lets a parent transform stay in effect while a child applies and undoes its own additional transform.
<canvas id="scene" width="420" height="220" style="background:#111827"></canvas>
<script>
const ctx = document.getElementById("scene").getContext("2d");
ctx.save(); // outer: scene transform
ctx.translate(210, 110); // move to center for everything below
for (let i = 0; i < 8; i++) {
ctx.save(); // inner: per-spoke transform
ctx.rotate((i / 8) * Math.PI * 2);
ctx.fillStyle = `hsl(${i * 45}, 80%, 60%)`;
ctx.fillRect(20, -6, 70, 12); // each spoke radiates from center
ctx.restore(); // undo only the spoke rotation
}
ctx.fillStyle = "#fff";
ctx.beginPath();
ctx.arc(0, 0, 14, 0, Math.PI * 2); // hub stays centered, no rotation
ctx.fill();
ctx.restore(); // undo the scene translate
</script>
Output:
A spoked wheel: 8 colored bars radiate from a white center hub.
The outer save keeps the origin centered for the whole wheel; each inner save/restore confines a single spoke’s rotation so the hub is drawn upright.
Unbalanced calls
restore() is forgiving but the imbalance bites later. Calling restore() with an empty stack does nothing (no error, no change). Calling save() without a matching restore() leaves stale state on the stack — usually harmless on its own, but it leaks transforms and styles into whatever runs next, especially inside an animation loop where the leak accumulates frame after frame.
Gotcha: Inside
requestAnimationFrame, an extrasave()per frame slowly grows the stack and can degrade performance or leak transforms. Audit each frame so everysave()has exactly onerestore().
A common safety pattern is to wrap risky drawing in try/finally:
function drawWidget(ctx) {
ctx.save();
try {
ctx.translate(100, 100);
ctx.rotate(Math.PI / 6);
ctx.fillRect(-20, -20, 40, 40);
} finally {
ctx.restore(); // runs even if drawing throws
}
}
Resetting versus restoring
save/restore is scoped and relative. If you instead want to wipe state entirely, modern browsers expose ctx.reset() (clears the bitmap and resets all state to defaults) and ctx.getTransform() / ctx.setTransform(1, 0, 0, 1, 0, 0) to reset only the matrix. Reach for reset() when starting a fresh scene, but use save/restore for local, nestable isolation during a draw.
Best practices
- Treat
save()andrestore()as a balanced pair, onerestore()for everysave(). - Call
save()immediately before any transform you want to scope, andrestore()right after the affected draw. - Prefer
save/restoreover manually re-setting individual properties — it is less error-prone and covers the transform and clip too. - Keep saves shallow: do not push state you do not need, and never let saves accumulate across animation frames.
- Use a
try/finallyaround drawing code that might throw so the stack never leaks. - Remember the current path is not saved — call
beginPath()to start fresh, notrestore(). - For a full reset of the context, use
ctx.reset()orsetTransform(1,0,0,1,0,0)rather than spammingrestore().