Under the Hood
Canvas

Paths: the build-then-paint two-phase model

Drawing a shape on the canvas is never one step — you first build an abstract path out of moveTo, lineTo, and curve calls into a piece of hidden context state, and only a separate fill or stroke call rasterizes that accumulated path into pixels, which is exactly the mechanism behind the single most common canvas bug.

Paths: the build-then-paint two-phase model

Every canvas tutorial shows you ctx.arc(...) followed by ctx.fill() and lets you assume that's one drawing operation. It isn't. It's two, with a real boundary between them, and the boundary is exactly where the API's most common bug lives. Miss it, and you'll eventually write code where drawing a second shape somehow recolors the first one too — with no idea why.

Here's the fact that explains it: the canvas keeps a hidden, accumulating piece of state called the current path — a list of lines and curves you build with one set of calls — and a completely separate call, fill() or stroke(), is what actually paints it. Building and painting are two phases. The flagship lesson told you the context forgets shapes the instant they're painted; this lesson is about the split-second before that, where something does get remembered — just not what most people assume.

Phase 1: building a path is pure bookkeeping

Calling ctx.moveTo(10, 10) or ctx.lineTo(100, 100) paints nothing. No pixel changes color. What happens instead is that the context appends a segment to an internal list — the current path — that lives on the context exactly like fillStyle or lineWidth do: as state, not as output.

ctx.beginPath();      // start a fresh current path
ctx.moveTo(20, 20);    // move the "pen" without drawing a line
ctx.lineTo(120, 20);   // append a straight-line subpath segment
ctx.lineTo(120, 100);  // append another segment
ctx.closePath();       // append a segment back to the subpath's start

Every one of those four calls only edits the list. The commands that build subpaths are moveTo (start a new subpath at a point, drawing nothing), lineTo (append a straight segment from the current point), arc and arcTo (append a circular arc), quadraticCurveTo and bezierCurveTo (append a curved segment — more on those below), rect (append a whole closed rectangular subpath in one call), and closePath (append a straight segment back to the current subpath's starting point). A single current path can hold several of these subpaths at once — nothing about "building a path" limits you to one contiguous shape.

beginPath() is the one call in that list that doesn't append anything. It does the opposite: it throws away whatever was in the current path and starts a new, empty one. Hold onto that — it's the whole bug this lesson is building toward.

Phase 2: painting rasterizes whatever the path currently holds

Nothing above touched a pixel. The pixels only get painted when you call fill() or stroke(), and here is the detail that matters: those calls don't take a shape as an argument. They rasterize whatever is currently sitting in the context's current path, at the moment they're called.

ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(120, 20);
ctx.lineTo(70, 100);
ctx.closePath();

ctx.fillStyle = "var(--sage)";
ctx.fill();          // rasterizes the triangle above — fills its interior

ctx.strokeStyle = "var(--terracotta)";
ctx.lineWidth = 4;
ctx.stroke();         // rasterizes the SAME current path again — outlines it

Notice fill() and stroke() ran back to back against the same current path, and neither call cleared it — painting doesn't consume the path. This is immediate mode doing exactly what the flagship lesson described: a command reads context state and paints pixels right now, then those pixels forget everything about how they got there. The current path is the input; the pixels are the output; nothing links them afterward.

The bug: forgetting beginPath accumulates subpaths

Since the current path is state that survives a paint call, and beginPath() is the only thing that clears it, skipping beginPath() before starting a new shape doesn't give you a fresh shape — it gives you your new subpaths joined onto whatever was already in the path. The next fill() or stroke() doesn't paint just the new shape; it repaints everything that has ever accumulated in the current path since the last beginPath().

// BUGGY: no beginPath between shapes
ctx.fillStyle = "var(--sage)";
ctx.moveTo(20, 20);
ctx.lineTo(80, 20);
ctx.lineTo(50, 80);
ctx.closePath();
ctx.fill();               // fills the triangle — looks fine so far

ctx.fillStyle = "var(--terracotta)";
ctx.rect(120, 20, 60, 60);
ctx.fill();                // intending: just the new square in terracotta...
// ...but the current path still contains the FIRST triangle's subpath too.
// fill() rasterizes BOTH subpaths, and fillStyle only has one value at
// paint time — so the triangle gets repainted in terracotta as well.
// FIXED: beginPath clears the current path before the new shape
ctx.fillStyle = "var(--sage)";
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(80, 20);
ctx.lineTo(50, 80);
ctx.closePath();
ctx.fill();                 // triangle only, sage

ctx.fillStyle = "var(--terracotta)";
ctx.beginPath();             // clear before building the next shape
ctx.rect(120, 20, 60, 60);
ctx.fill();                  // square only, terracotta

Fill rules: how overlapping subpaths decide inside from outside

A current path can hold multiple subpaths, or a single subpath that crosses itself, and fill() has to decide which regions count as "inside" the shape. It does that with a fill rule, and the canvas gives you two: "nonzero" (the default) and "evenodd", passed as an optional argument — ctx.fill("evenodd") — or set on a Path2D fill.

  • Nonzero winding rule. Draw a ray from a test point out to infinity. For every subpath edge that ray crosses, add 1 if the edge runs one direction (say, left to right) and subtract 1 if it runs the other way. If the total is anything other than zero, the point is inside. This is why direction matters under nonzero: a subpath drawn clockwise and a subpath drawn counter-clockwise can cancel each other out where they overlap, punching a hole, while two subpaths wound the same direction just merge into one solid region.
  • Even-odd rule. Draw the same ray and just count how many edges it crosses, ignoring direction entirely. Odd number of crossings means inside; even means outside. Every overlap flips inside to outside and back, regardless of which way either subpath was drawn.

The practical consequence: if you build a square subpath and, inside it, a second square subpath wound in the same direction, nonzero fills the whole thing solid — but wind the inner square the opposite direction and nonzero punches a hole where they overlap, while evenodd punches that hole either way, since it never looks at direction, only at crossing count.

Stroke mechanics: centered lines, joins, and caps

stroke() paints a band of color that straddles the path rather than sitting to one side of it: for a line width of w, the stroke extends w / 2 to each side of the mathematical path. A 10-pixel-wide stroke on a line therefore covers 5 pixels of pixels on either side of where you'd expect the "line" itself to be — a common surprise when a stroked rectangle's outer edge lands outside the box you thought you were drawing.

Two more context-state properties shape how strokes render at the places where the simple "band around a line" picture breaks down:

  • lineJoin controls what happens at a corner where two segments of a subpath meet — "miter" (the default, a sharp point), "round" (a filleted curve), or "bevel" (a flat cut corner).
  • lineCap controls what happens at the two open ends of a subpath that isn't closed — "butt" (the default, the stroke stops exactly at the endpoint), "round" (a semicircle extends past it), or "square" (a flat extension of length w / 2 past it).

Both are ordinary sticky context state, set before the stroke() call that should use them, the same as fillStyle or lineWidth.

Curves are the same math, now defining geometry

quadraticCurveTo(cpx, cpy, x, y) and bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) append a curved segment using one or two control points that pull the curve toward them without the curve ever touching them. If that phrase sounds familiar, it should — it's the identical Bezier math behind the easing curves that shape time in an animation tween. There, a Bezier curve's y-axis was "how far through the animation," plotted against x as "how far through the duration." Here, the same parametric curve is plotted directly in x and y and becomes actual on-screen geometry — a control point doesn't bend a rate of change, it bends a line.

The canvas doesn't rasterize a true continuous curve; there's no such thing on a pixel grid. Internally, the rasterizer approximates the Bezier curve as a sequence of very short straight segments, fine enough that the seams are invisible at normal zoom. That's the same "curve as an approximation" idea you'll see again if you ever zoom far enough into an SVG path or a vector illustration — smoothness is a rendering-resolution illusion sitting on top of straight-line math underneath.

Path2D: a retained path, as an escape hatch

Everything above lives and dies on the context's single current path — build it, paint it, and the next beginPath() throws it away. Path2D is the canvas API's answer to "what if I want to build a path once and reuse it," and it works by moving the path out of the context entirely into its own object:

const star = new Path2D();
star.moveTo(50, 0);
star.lineTo(61, 35);
star.lineTo(98, 35);
star.lineTo(68, 57);
star.lineTo(79, 91);
star.lineTo(50, 70);
star.lineTo(21, 91);
star.lineTo(32, 57);
star.lineTo(2, 35);
star.lineTo(39, 35);
star.closePath();

// Build once. Paint as many times as you like, with the context's
// CURRENT style state each time — Path2D carries geometry, not style.
ctx.fillStyle = "var(--sage)";
ctx.fill(star);

ctx.translate(150, 0);
ctx.strokeStyle = "var(--terracotta)";
ctx.stroke(star);

Path2D even accepts an SVG path-data string in its constructor, so a shape authored as SVG can be replayed on a canvas with no manual moveTo/lineTo translation. Structurally, this is a small retained-mode object living on top of an otherwise immediate-mode API — exactly the trade the flagship lesson described libraries like Fabric or Konva making at a much larger scale. Path2D doesn't give you hit-testing or a scene graph; it gives you exactly one thing retained mode is good at — not rebuilding geometry you already built — and nothing else.

isPointInPath: borrowing back a little hit-testing

The flagship lesson's second consequence was that the canvas can't tell you what you clicked, because it doesn't keep shapes. ctx.isPointInPath(x, y) (and the Path2D variant, ctx.isPointInPath(path, x, y)) is the API's partial answer: it takes the current path — or a given Path2D — and a point, and runs the same fill-rule test fill() would use internally, returning whether that point would have been painted. It doesn't solve hit-testing for you; you still own the list of logical shapes and still have to call this once per shape per click. What it removes is having to write the point-in-polygon or point-in-curve math yourself — the geometry engine backing fill() does that work for you, you're just asking it a question instead of asking it to paint.

Where this goes next

Everything in this lesson happened in the coordinates you passed directly to moveTo and lineTo. But those coordinates don't necessarily land where the numbers suggest — the context can be quietly remapping every coordinate you supply before a single pixel gets touched. The transform matrix is next: the mechanism that moves the coordinate system itself, so the same arc(0, 0, 20, 0, Math.PI * 2) call can paint a circle anywhere, at any angle, at any size, without a single number in the path-building code changing.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Name the two phases this lesson describes, and say precisely which canvas calls belong to each.
  2. After ctx.lineTo(100, 100) runs, has any pixel changed? What did actually happen?
  3. Walk through why forgetting beginPath() between two shapes causes the first shape to get repainted in the second shape's fillStyle.
  4. Explain the nonzero winding rule using the ray-casting test, and give a case where nonzero and evenodd disagree on whether a point is inside.
  5. A stroke with lineWidth 10 is applied to a path. How many pixels does the stroke extend to each side of the mathematical path, and why does that surprise people about stroked rectangles?
  6. What does Path2D let you do that the plain current-path model doesn't, and in what sense is it a retained-mode object living inside an immediate-mode API?
  7. What does ctx.isPointInPath() actually check, and why is it only a partial answer to the canvas's hit-testing problem rather than a full one?