Under the Hood
Canvas

Text, images, and direct pixel access

Beyond filled and stroked paths, the canvas draws text through a baseline-anchored positioning model, blits whole images with a scaling three-way overload, and — through getImageData — lets you reach past all of that abstraction to the raw RGBA bytes at the cost of a real performance stall.

Text, images, and direct pixel access

Everything drawn so far has been built from paths: points, moved to, connected, filled or stroked. Text and images don't work that way. Text is handed to the browser's font engine and comes back as rasterized glyphs; images are copied — scaled, cropped, or both — as whole blocks of pixels in one call. Both still end up as the same thing paths do: colored pixels in the buffer, immediately forgotten the moment the call returns. And beneath even that abstraction sits one more mechanism — getImageData — which lets you step around drawing calls entirely and touch the bytes yourself, at a cost worth understanding before you reach for it inside a loop.

Text: fillText, strokeText, and the font state

ctx.fillText(text, x, y) and ctx.strokeText(text, x, y) paint a string using the current fill or stroke style, at the position (x, y). Like every other draw call, they read their appearance from sticky context state rather than taking it as an argument — in this case ctx.font, which takes the same shorthand string CSS uses: ctx.font = 'bold 24px "Georgia", serif'. Set it once and every subsequent text call uses it, exactly like fillStyle or lineWidth.

ctx.font = '32px system-ui, sans-serif';
ctx.fillStyle = '#222';
ctx.fillText('Hello, canvas', 40, 80);

What (x, y) actually anchors to is the part that trips people up, because "the position of text" is ambiguous until you fix two more pieces of state.

The positioning model: textAlign and textBaseline

textAlign controls the horizontal anchor: left (default), right, or center — whether x marks the left edge of the text, the right edge, or its horizontal center.

textBaseline controls the vertical anchor, and it's the one that actually requires a mental model, because text doesn't sit flush inside its own bounding box the way you'd assume. Typography has a concept called the baseline — an invisible line that letters without descenders (like "x" or "H") sit directly on top of, while descenders (the tails of "g," "y," "p") hang below it. The canvas's default, alphabetic, anchors y to that baseline — not to the top of the text, not to its center. If you set y = 80 with the default baseline, the bottoms of most letters land at 80, but a "g" dips below it.

The other values move the anchor to somewhere more predictable when that's what you actually want: top anchors to the top of the em box, bottom to the bottom (below any descenders), middle to the vertical center of the em box, and hanging/ideographic exist for scripts where the default Latin baseline convention doesn't apply. If text is landing "too low" or "too high" relative to where you placed y, the fix is almost never moving y — it's picking the textBaseline that matches what you actually meant by "here."

ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '20px sans-serif';
ctx.fillText('centered both ways', 150, 100);
// (150, 100) is now the true visual center of the text, not its
// bottom-left-ish alphabetic anchor

measureText: you do your own layout

The canvas has no text wrapping, no line breaking, no flow layout — nothing like a <div> reflowing its contents. If you want a paragraph to wrap at a certain width, you measure and break the lines yourself. ctx.measureText(text) returns a TextMetrics object, whose most useful fields are width (how wide the string would render at the current font) and actualBoundingBoxAscent / actualBoundingBoxDescent (how far the glyphs actually extend above and below the baseline, which is tighter than the font's nominal line height and accounts for exactly what glyphs are in the string).

function wrapText(ctx, text, maxWidth) {
  const words = text.split(' ');
  const lines = [];
  let line = '';
  for (const word of words) {
    const test = line ? `${line} ${word}` : word;
    if (ctx.measureText(test).width > maxWidth && line) {
      lines.push(line);
      line = word;
    } else {
      line = test;
    }
  }
  lines.push(line);
  return lines; // caller fillText()s each line at an incremented y
}

Once fillText runs, none of this metric information persists on the canvas — the glyphs went through the browser's font pipeline (shaping the string into glyph positions, hinting them to the pixel grid, antialiasing their edges) and came out the other side as ordinary pixels. There is no "text object" left behind any more than there was a "rectangle object" in lesson 1 — measure before you draw, because after you draw, you only have a bitmap.

Images: drawImage and its three shapes

drawImage blits pixels from a source image into the canvas, and it comes in three overloads that differ only in how much control you want over position and scaling:

ctx.drawImage(img, dx, dy);
// draw at natural size, top-left at (dx, dy)

ctx.drawImage(img, dx, dy, dWidth, dHeight);
// draw scaled to fit a dWidth x dHeight box at (dx, dy)

ctx.drawImage(img, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
// crop a sWidth x sHeight rectangle out of the source starting at
// (sx, sy), then draw THAT into a dWidth x dHeight box at (dx, dy)

The third form is what makes sprite sheets and texture atlases possible: one source image holds many frames or icons, and each drawImage call cuts out just the rectangle it needs. The img argument isn't limited to <img> elements either — it can be another <canvas> (letting you composite one canvas's contents into another), an ImageBitmap (a decoded, GPU-friendly image handle), or a video element's current frame. All of them get treated the same way: a rectangle of source pixels, copied and optionally scaled into the destination.

Scaling raises one more decision: when the destination box doesn't match the source's size 1:1, the canvas has to invent or discard pixels, and imageSmoothingEnabled (on by default) plus imageSmoothingQuality (low/medium/high) control how. Smoothing on interpolates neighboring source pixels (bilinear or better) for a soft scale; smoothing off falls back to nearest-neighbor, which repeats or drops pixels outright. For photographs you almost always want smoothing on; for pixel art, turn it off, or every scaled-up sprite comes out blurred instead of crisp.

ctx.imageSmoothingEnabled = false; // preserve hard pixel edges
ctx.drawImage(spriteSheet, 32, 0, 16, 16, 100, 100, 64, 64);

Direct pixel access: getImageData and the readback stall

Every mechanism so far — paths, text, images — is a way of telling the canvas what to paint. getImageData(x, y, width, height) goes the other direction: it reads back the actual bytes currently sitting in the buffer, returned as an ImageData object whose .data property is a Uint8ClampedArray — four bytes per pixel (red, green, blue, alpha, each 0–255), laid out row-major. Pixel (x, y) within that array starts at index (y * width + x) * 4.

const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;

for (let y = 0; y < canvas.height; y++) {
  for (let x = 0; x < canvas.width; x++) {
    const i = (y * canvas.width + x) * 4;
    data[i]     = 255 - data[i];     // invert red
    data[i + 1] = 255 - data[i + 1]; // invert green
    data[i + 2] = 255 - data[i + 2]; // invert blue
    // data[i + 3] is alpha — left alone
  }
}

ctx.putImageData(imageData, 0, 0); // write the modified bytes back

That loop is how image filters, an eyedropper/color-pick tool, or any pixel-level algorithm gets built on a canvas — getImageData in, mutate the typed array in plain JavaScript, putImageData back out.

Where this goes next

Text rasterization, image blitting, and pixel readback are the last three mechanisms the canvas offers for getting pixels into (or out of) the buffer. What's left is turning all of it — paths, state, transforms, compositing, text, images — into something that runs inside a frame budget every single frame. Canvas performance and the render loop is the capstone: how the immediate-mode redraw this whole module has been describing actually gets kept fast.

Go deeper

Check yourself

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

  1. What does the typographic baseline mean, and why do letters with descenders extend below it even at textBaseline: 'alphabetic'?
  2. You set y = 80 and see text sitting oddly. What state besides x/y determines where it actually lands, and what value would center it exactly on y?
  3. Why is there no built-in text wrapping on the canvas, and what method do you call yourself to break a string into lines that fit a width?
  4. Name the three drawImage overloads and what each one gives you control over.
  5. What is imageSmoothingEnabled doing when true vs false, and which setting do you want for a scaled-up pixel-art sprite?
  6. Given width w, how do you compute the byte index of pixel (x, y) inside an ImageData's .data array, and how many bytes does each pixel occupy?
  7. Why is getImageData described as a pipeline stall, and what does { willReadFrequently: true } change about that?