Engineering Publication · Syed Omar Ibn Feroj← Back to portfolio
▦
Engineering Notes · Open Knowledge Repository
HTML5 Canvas — Engineering
Notes for Pixels
A working reference for the Canvas 2D API — the immediate-mode model, paths and transforms, pixel manipulation, a correct animation loop, hit-testing, and the high-DPI and performance details that decide whether it's smooth.
28
Chapters
2D API
Baseline
Living
Document
Free
License
Ch. 01
Setup & Context
A canvas is a bitmap; the context is the brush.
1.1 — Get the 2D context
JavaScript
const canvas = document.querySelector("canvas"); const ctx = canvas.getContext("2d"); ctx.fillRect(10, 10, 100, 50);
Canvas is immediate-mode: once you draw, there are no objects — only pixels. There is nothing to "move" or "select" later. Every frame you clear and redraw the whole scene from your own data model.
Ch. 02
Coordinate System
Origin top-left, y grows down, and the CSS-size trap.
2.1 — Drawing buffer ≠ display size
HTML
<canvas width="800" height="600"></canvas> <!-- CSS width:400px → bitmap is 800px, SCALED to 400 -->
The
width/height attributes set the bitmap resolution; CSS width/height set the display size. Setting only CSS stretches an 300×150 default bitmap → everything blurry. Set the attributes (in JS) to match the displayed size × devicePixelRatio (Ch. 03).Ch. 03
High-DPI
Crisp on retina — the boilerplate every canvas needs.
3.1 — Scale the buffer by devicePixelRatio
JavaScript
const dpr = window.devicePixelRatio || 1; const { width: w, height: h } = canvas.getBoundingClientRect(); canvas.width = w * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); // now draw in CSS pixels
Assigning to
canvas.width/height resets the entire context (transforms, styles, the bitmap). Do the DPR setup once on init/resize, then re-apply context state — never per frame.Ch. 04
Rectangles
The only shape with dedicated methods.
4.1 — fill, stroke, clear
JavaScript
ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); ctx.clearRect(0, 0, canvas.width, canvas.height); // erase
clearRect over the full buffer is the standard "wipe the frame" call. fillRect with a translucent colour instead creates a motion-trail effect — useful for visualisers.Ch. 05
Paths
Everything that isn't a rectangle is a path.
5.1 — begin, build, fill/stroke
JavaScript
ctx.beginPath(); ctx.moveTo(20, 20); ctx.lineTo(120, 20); ctx.lineTo(70, 90); ctx.closePath(); ctx.fill(); ctx.stroke();
Forgetting
beginPath() is the #1 canvas bug: every new path is appended to the previous one, so the next stroke() re-strokes everything ever drawn — progressively slower and visually wrong. Call beginPath() before every shape.Ch. 06
Curves & Arcs
Circles, rounded corners, smooth lines.
6.1 — arc, quadratic, bezier
JavaScript
ctx.arc(cx, cy, r, 0, Math.PI * 2); // full circle ctx.quadraticCurveTo(cpx, cpy, x, y); ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x, y); ctx.roundRect(x, y, w, h, 8); // modern: rounded rect
arc angles are in radians, measured clockwise from the positive x-axis (3 o'clock). Passing degrees draws nothing recognisable — convert with deg * Math.PI / 180.Ch. 07
Fill & Stroke
State is sticky — set before you draw.
7.1 — Styles persist until changed
JavaScript
ctx.fillStyle = "#22d3ee"; ctx.strokeStyle = "rgba(255,255,255,.5)"; ctx.lineWidth = 2; ctx.fill(); // uses whatever fillStyle is right now
Context properties are global state, not per-shape arguments. They keep their value across draws and frames. Group by style to minimise state changes — switching
fillStyle is comparatively expensive.Ch. 08
Gradients
Linear, radial, conic — created once, reused.
8.1 — Build, add stops, assign
JavaScript
const g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0, "#0ea5e9"); g.addColorStop(1, "#1e293b"); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
Gradient coordinates are in canvas space, not relative to the shape — a gradient defined 0→100 won't span a rectangle drawn at x=500. Recreate it relative to where you draw, or translate first.
Ch. 09
Patterns
Tile an image or another canvas as a fill.
9.1 — createPattern
JavaScript
const p = ctx.createPattern(img, "repeat"); ctx.fillStyle = p; ctx.fillRect(0, 0, w, h);
The source image must be fully loaded before
createPattern (and drawImage) — call them from the image's onload, or you silently paint nothing.Ch. 10
Shadows
Soft drop shadows — and their performance cost.
10.1 — Four shadow properties
JavaScript
ctx.shadowColor = "rgba(0,0,0,.4)"; ctx.shadowBlur = 12; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 4; ctx.fillRect(20, 20, 100, 60); ctx.shadowColor = "transparent"; // reset or it bleeds onto everything
Shadow blur is expensive and applies to every subsequent draw until reset. In an animation loop, blurred shadows tank the frame rate — pre-render the shadowed element to an offscreen canvas once and blit it.
Ch. 11
Line Styles
Caps, joins, dashes — the details that read as polish.
11.1 — cap, join, dash
JavaScript
ctx.lineCap = "round"; // butt | round | square ctx.lineJoin = "round"; // miter | round | bevel ctx.setLineDash([6, 4]); ctx.lineDashOffset = 0; // animate this for "marching ants"
Ch. 12
Text
Drawing strings — and the baseline that catches everyone.
12.1 — font, align, baseline
JavaScript
ctx.font = "600 16px Inter, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("Hello", x, y); const { width } = ctx.measureText("Hello"); // for layout
Default
textBaseline is "alphabetic", so fillText("x", 0, 0) draws mostly above y=0 and is clipped. For predictable positioning set textBaseline = "top" or "middle".Ch. 13
Images
drawImage and its three signatures.
13.1 — Place, scale, crop
JavaScript
ctx.drawImage(img, x, y); // natural size ctx.drawImage(img, x, y, w, h); // scaled ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h); // crop+scale
A canvas tainted by a cross-origin image (drawn without proper CORS) throws on
getImageData/toDataURL. Serve images with Access-Control-Allow-Origin and set img.crossOrigin = "anonymous" before src.Ch. 14
Sprites
One image, many frames — the basis of 2D animation.
14.1 — Sub-rectangle blitting
JavaScript
const frame = (tick / 6 | 0) % FRAMES; ctx.drawImage(sheet, frame * FW, 0, FW, FH, x, y, FW, FH);
A single sprite sheet drawn with the 9-arg
drawImage beats N separate images: one decode, one GPU upload, no per-frame network. Keep frames on a power-of-two grid for clean math.Ch. 15
Transformations
Move the coordinate system, not the shape.
15.1 — translate / rotate / scale
JavaScript
ctx.save(); ctx.translate(cx, cy); // move origin to pivot ctx.rotate(angle); ctx.fillRect(-w/2, -h/2, w, h); // draw centred on origin ctx.restore();
Transforms are cumulative and mutate global state. To rotate a shape about its centre you must translate to the pivot, rotate, draw offset by half-size, then restore — rotating without translating spins everything around (0,0).
Ch. 16
save / restore
The state stack — your only undo.
16.1 — Balance every save with a restore
JavaScript
ctx.save(); // push: transform + all style state // … mutate freely … ctx.restore(); // pop: exact prior state back
An unbalanced
save without restore grows the state stack every frame — a slow leak that degrades over minutes. Treat them like brackets: one restore for every save, same scope.Ch. 17
Compositing
globalAlpha and globalCompositeOperation.
17.1 — Blend modes
JavaScript
ctx.globalAlpha = 0.5; ctx.globalCompositeOperation = "lighter"; // additive glow // … draw … ctx.globalCompositeOperation = "source-over"; // reset to default
"lighter" (additive) is the cheap way to make particles and light glow without shaders. Always reset to "source-over" and globalAlpha = 1 or every later draw inherits it.Ch. 18
Clipping
Constrain drawing to a region.
18.1 — Path → clip
JavaScript
ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.clip(); // subsequent draws limited to the circle ctx.drawImage(img, 0, 0); ctx.restore(); // remove the clip
A clip can only be removed by
restore() (to a state saved before the clip). There is no "unclip" — forget the save/restore pair and the rest of your scene vanishes inside the clip region.Ch. 19
Pixel Manipulation
Read and write the raw RGBA buffer.
19.1 — getImageData / putImageData
JavaScript
const img = ctx.getImageData(0, 0, w, h); const d = img.data; // Uint8ClampedArray, RGBA per pixel for (let i = 0; i < d.length; i += 4) { d[i] = 255 - d[i]; // invert red } ctx.putImageData(img, 0, 0);
getImageData is slow (CPU↔GPU readback) and the array is huge (w·h·4 bytes). Never call it per frame on the whole canvas — operate on small regions, or use WebGL/filters for full-frame effects.Ch. 20
The Animation Loop
requestAnimationFrame, delta time, and never setInterval.
20.1 — Time-based, not frame-based
JavaScript
let last = performance.now(); function frame(now) { const dt = (now - last) / 1000; last = now; update(dt); // move by velocity * dt render(); requestAnimationFrame(frame); } requestAnimationFrame(frame);
Move objects by
velocity * dt, never by a fixed amount per frame. Frame-based motion runs at double speed on a 120 Hz display and crawls under load. setInterval for animation is always wrong — it drifts and runs in background tabs.Ch. 21
Redraw Strategy
Clear-and-repaint vs dirty rectangles vs layers.
21.1 — Pick the cheapest correct one
Default: clear the whole canvas and redraw everything from the model each frame — simple and correct. If only a small region changes, dirty rectangles (clear+redraw just that area) cut fill cost. If a background is static, put it on its own canvas layer and only repaint the dynamic layer.
Don't optimise until you measure. Full clear-and-repaint is fine for most scenes at 60 fps; reach for layering/dirty-rects only when the profiler shows fill rate is the bottleneck.
Ch. 22
Hit Testing
There are no objects — you compute hits yourself.
22.1 — Map pointer → model
JavaScript
canvas.addEventListener("pointerdown", (e) => { const r = canvas.getBoundingClientRect(); const x = e.clientX - r.left, y = e.clientY - r.top; const hit = shapes.find(s => contains(s, x, y)); });
Use
getBoundingClientRect() to convert page coordinates to canvas coordinates, and divide by the CSS-vs-buffer scale if you used DPR. ctx.isPointInPath() can hit-test the current path but is slow for many shapes — keep a model and test geometry.Ch. 23
Particle Systems
Hundreds of cheap objects, one buffer.
23.1 — Update, cull, draw
JavaScript
for (const p of particles) { p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; } particles = particles.filter(p => p.life > 0); // cull dead // draw with composite "lighter" for glow (Ch. 17)
Allocating a new particle object every frame thrashes the GC and stutters. Use a fixed pool: reuse dead slots instead of
push/filter when counts get large.Ch. 24
Performance
The levers, in order of impact.
24.1 — What actually moves the needle
- Batch by style — minimise
fillStyle/fontchanges - Pre-render static/expensive elements to an offscreen canvas, then blit
- Integer coordinates avoid sub-pixel anti-aliasing cost
- Avoid shadows and per-frame
getImageData - One
requestAnimationFrameloop, not many - Layer static backgrounds onto a separate canvas
Ch. 25
Exporting
Get pixels back out as an image.
25.1 — toBlob over toDataURL
JavaScript
canvas.toBlob((blob) => { const url = URL.createObjectURL(blob); // download or upload the blob }, "image/png");
Prefer
toBlob (async, memory-efficient) to toDataURL (a giant base64 string that blocks the main thread for large canvases). Both throw if the canvas is cross-origin tainted (Ch. 13).Ch. 26
Canvas vs SVG vs WebGL
Choose the right rasteriser.
26.1 — The decision
| Use | When |
|---|---|
| SVG | few shapes, need DOM/CSS/events per element, must scale crisply |
| Canvas 2D | thousands of pixels/shapes, charts, image editing, simple games |
| WebGL/WebGPU | tens of thousands of objects, 3D, shaders, GPU effects |
Rule of thumb: if you'd attach a click handler to each element, use SVG. If you're pushing pixels by the thousand every frame, use Canvas. If the CPU can't keep up, move to WebGL.
Ch. 27
Common Pitfalls
The recurring canvas bugs.
27.1 — The list
- Sizing via CSS only → blurry (set the buffer + DPR)
- Missing
beginPath()→ cumulative re-stroking arcangles in degrees instead of radians- Unbalanced
save/restore→ state-stack leak - Forgetting to reset
shadowColor/globalAlpha/composite - Drawing an image before it loaded
getImageDataper frame → jank- Frame-based motion instead of
* dt
Ch. 28
Best Practices
The defaults for a smooth canvas app.
28.1 — The short list
- Set buffer size = CSS size × devicePixelRatio on init/resize
- One rAF loop, time-based updates
beginPath()before every shape; balancedsave/restore- Keep a model; the canvas is a view you fully repaint
- Pre-render expensive/static content offscreen
- Reset transient state (alpha, shadow, composite) each frame
- Profile before optimising the redraw strategy
REF
Canvas Cheatsheet
The high-DPI + loop boilerplate, memorised.
Init + loop
JavaScript
function fit() { const dpr = devicePixelRatio || 1; const { width, height } = canvas.getBoundingClientRect(); canvas.width = width * dpr; canvas.height = height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } addEventListener("resize", fit); fit(); let t = performance.now(); (function loop(n) { const dt = (n - t) / 1000; t = n; ctx.clearRect(0, 0, canvas.width, canvas.height); update(dt); render(); requestAnimationFrame(loop); })(t);