Fabian QuijosacaThis is a follow-up to Mandelbrot Set in JS - Zoom In. In that article we built a Mandelbrot...
This is a follow-up to Mandelbrot Set in JS - Zoom In.
In that article we built a Mandelbrot renderer using Canvas and Web Workers, with click-to-zoom.
This post covers what broke after ~16 zooms, why it broke (floating-point precision),
and how we replaced click zoom with a smooth scroll-based zoom that also lets you zoom back out.
If you played with the previous demo long enough, you noticed something strange: after zooming in about 16 times, the fractal starts looking pixelated, blocky, and eventually the entire canvas turns solid black.
This isn't a bug in the Mandelbrot math. The set is infinitely detailed, there's always more structure to see. The problem is in how computers store decimal numbers.
JavaScript (like most languages) stores all numbers as 64-bit IEEE 754 doubles. This is just the standard format computers use for decimal numbers, and it gives you about 15 to 17 significant digits of precision. That sounds like a lot, but zoom burns through those digits very fast.
Each click zoomed to a window of 2 × ZOOM_FACTOR × canvas_width pixels centered on the click point. With ZOOM_FACTOR = 0.1, each zoom reduced the visible range to 20% of the previous range:
const zfw = WIDTH * ZOOM_FACTOR; // 800 * 0.1 = 80px on each side
REAL_SET = {
start: getRelativePoint(e.pageX - canvas.offsetLeft - zfw, WIDTH, REAL_SET),
end: getRelativePoint(e.pageX - canvas.offsetLeft + zfw, WIDTH, REAL_SET),
};
The coordinate range after N clicks shrinks like this:
range_after_N = initial_range × 0.2^N
| Clicks | Real axis range |
|---|---|
| 0 | 3.0 (from -2 to 1) |
| 5 | ~0.00077 |
| 10 | ~2.4 × 10⁻⁷ |
| 15 | ~7.5 × 10⁻¹² |
| 16 | ~1.5 × 10⁻¹² |
At click 15, the range is 7.5e-12. If your center is around -0.7, the coordinates look like:
start: -0.700000000003750
end: -0.700000000003751
Those two numbers share 15 digits. With only 15 to 17 digits of total precision, the difference between adjacent pixels becomes too small to represent. Every pixel ends up mapping to the same value. Result: a grid of identical colors, pixelation, black.
This is called catastrophic cancellation: when you subtract two numbers that are almost the same, you lose all the useful digits.
The first change is switching from click to wheel (scroll). This gives us:
Here is the complete new listener:
const ZOOM_FACTOR = 0.8; // each scroll step = 80% of current range (zoom in)
const MIN_RANGE = 1e-12; // safety limit, stop before precision breaks down
const startListeners = () => {
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomIn = e.deltaY < 0;
const factor = zoomIn ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
const realRange = REAL_SET.end - REAL_SET.start;
const imagRange = IMAGINARY_SET.end - IMAGINARY_SET.start;
const newRealRange = realRange * factor;
const newImagRange = imagRange * factor;
// Stop zooming in before precision collapses
if (newRealRange < MIN_RANGE || newImagRange < MIN_RANGE) return;
// Map cursor pixel to a point in the complex plane
const mouseX = e.pageX - canvas.offsetLeft;
const mouseY = e.pageY - canvas.offsetTop;
const centerReal = getRelativePoint(mouseX, WIDTH, REAL_SET);
const centerImag = getRelativePoint(mouseY, HEIGHT, IMAGINARY_SET);
REAL_SET = {
start: centerReal - newRealRange / 2,
end: centerReal + newRealRange / 2,
};
IMAGINARY_SET = {
start: centerImag - newImagRange / 2,
end: centerImag + newImagRange / 2,
};
Mandelbrot();
}, { passive: false }); // passive: false is required to call e.preventDefault()
};
Let's go through each decision:
e.preventDefault() + { passive: false }
By default, browsers treat wheel events as passive for performance, assuming you won't stop the default scroll behavior. We need to prevent the page from scrolling while the user zooms the fractal, so we have to opt out. Without { passive: false }, calling preventDefault() does nothing and the page scrolls anyway.
factor = zoomIn ? ZOOM_FACTOR : 1 / ZOOM_FACTOR
Zooming in multiplies the range by 0.8 (makes it smaller). Zooming out divides by 0.8 (makes it bigger). This keeps zoom in and out symmetric, so ten zooms in followed by ten zooms out brings you back to exactly where you started.
The new approach maps the cursor pixel to a point in the complex plane, then builds the new window symmetrically around it:
const centerReal = getRelativePoint(mouseX, WIDTH, REAL_SET);
const centerImag = getRelativePoint(mouseY, HEIGHT, IMAGINARY_SET);
getRelativePoint converts a pixel position to a coordinate using a simple formula:
const getRelativePoint = (pixel, length, set) =>
set.start + (pixel / length) * (set.end - set.start);
ZOOM_FACTOR = 0.8 instead of 0.1
With 0.1, each click zoomed to 20% of the range, which was very aggressive. The precision limit was hit in 16 steps. With 0.8, each scroll step reduces the range by only 20%, so you can zoom about 130 times before hitting the same limit. It also feels much smoother to use.
if (newRealRange < MIN_RANGE || newImagRange < MIN_RANGE) return;
With MIN_RANGE = 1e-12 we stop zooming in when the coordinate window gets too small. At that scale, the numbers don't have enough precision left to render a meaningful image. Instead of turning black, the fractal just stays frozen at the last good zoom level. The scroll event is silently ignored.
For context, here is how each column of pixels is computed in the Web Worker. This part is the same as in the previous article:
// worker.ts, runs in a separate thread via Vite's ?worker import
const MAX_ITERATION = 1000;
function mandelbrot(c: { x: number; y: number }): [number, boolean] {
let z = { x: 0, y: 0 };
let n = 0;
let d = 0;
do {
const p = {
x: Math.pow(z.x, 2) - Math.pow(z.y, 2),
y: 2 * z.x * z.y,
};
z = { x: p.x + c.x, y: p.y + c.y };
d = 0.5 * (Math.pow(z.x, 2) + Math.pow(z.y, 2));
n += 1;
} while (d <= 2 && n < MAX_ITERATION);
return [n, d <= 2];
}
This is the core iteration: z → z² + c. A point c is in the Mandelbrot set if |z| never escapes 2 after MAX_ITERATION steps. Points that do escape get colored by how fast they did it (the value of n).
The main thread sends one message per column and the worker replies with the results:
// columns are dispatched in random order for a cool reveal effect
const launchTasks = () => {
while (TASKS.length > 0) {
const [col] = TASKS.splice(Math.floor(Math.random() * TASKS.length), 1);
worker.postMessage({ col });
}
};
Here is an honest list of what this implementation still can't do:
| Limitation | Why it happens |
|---|---|
| ~130 scroll steps max zoom | JavaScript number precision (15-17 digits). You need a different approach to go deeper. |
| Re-renders the full canvas on every scroll event | The worker is restarted on each zoom. Fast scrolling queues many full renders. |
| No mobile support |
wheel events don't fire on touch screens. You'd need to handle pinch gestures separately. |
| Single worker for all columns | One worker handles all 800 columns. Multiple workers could be faster. |
Fixed MAX_ITERATION = 1000 |
Deep zoom areas need more iterations to look good, but raising this constant slows everything down. |
decimal.js
To zoom beyond ~130 steps you need more than the standard 64-bit number format. The decimal.js library lets you set how many digits of precision you want:
import Decimal from 'decimal.js';
Decimal.set({ precision: 50 }); // 50 significant digits
const newRange = new Decimal(realRange).mul(factor);
const center = new Decimal(realSet.start)
.plus(new Decimal(mouseX).div(WIDTH).mul(new Decimal(realSet.end).minus(realSet.start)));
The downside is that this kind of math is 10 to 100 times slower than normal numbers, so you would need to lower the canvas resolution or the number of iterations to keep things running at a good speed.
This is the technique used by professional deep-zoom renderers like Kalles Fraktaler. The idea is to compute one very precise reference point and then calculate all other pixels as small adjustments relative to that point, using regular numbers. This can reach zoom depths of 10^1000 and beyond, with good performance, but it requires a solid math background to implement.
MAX_ITERATION
Instead of a fixed limit, scale the number of iterations based on how deep the zoom is, so shallow views are fast and deep views show more detail:
const maxIter = Math.floor(100 + zoomLevel * 50);
The scroll event fires much faster than the renderer can keep up. Using requestAnimationFrame would skip frames that come in too quickly and only render when the browser is ready:
let rafId: number;
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
updateCoordinates(e);
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => Mandelbrot());
}, { passive: false });
Handle touchstart and touchmove with two fingers to calculate a scale factor and apply the same zoom logic.
| What changed | Before | After |
|---|---|---|
| Interaction |
click, zoom in only |
wheel, zoom in and out |
| Zoom center | Approximate click pixel | Exact cursor coordinate |
| Zoom step | 20% of range per click | 20% of range per scroll tick |
| Precision guard | None, canvas turns black | Stops at 1e-12 range |
| Max useful zooms | ~16 | ~130 |
| Page scroll behavior | Not a concern | Blocked with passive: false
|
You can see the demo running on quijosakaf.com and find the full source on GitHub.
Repository:
github
If you want to experiment, try changing ZOOM_FACTOR between 0.5 (aggressive) and 0.95 (very smooth). The math works the same either way, it's just a personal preference.
If you made it this far, thank you so much. This kind of topic can get complicated fast, and I appreciate you sticking with it.
I want to be honest: this post was written with the help of AI (Claude). Concepts like IEEE 754, catastrophic cancellation, arbitrary precision arithmetic, and perturbation theory were things I did not know about before I started digging into why the zoom was breaking. The AI helped me understand why each thing was happening and gave me the right words to describe it, which made it much easier to explain here.
The demo will keep improving. The improvements listed above (RAF throttling, adaptive iterations, arbitrary precision, pinch-to-zoom) are real next steps I plan to work on. If you have ideas, found a bug, or just want to talk about fractals, drop a comment below.