The performance problem under the parallax effect
Parallax sites share a common visual language: as the user scrolls, background elements scale, rotate, or shift at different rates than the page content. The effect can be striking, but it is also a well-known performance trap. Browsers are optimized for scrolling situations where little changes visually—new content sliding into view at the viewport edges, for example. Parallax inverts that assumption, forcing large visual elements across the page to update continuously, which typically results in expensive repaints.
You can think of a typical parallax page as having two distinct parts: background elements that transform in response to scroll position, and standard page content that scrolls normally. The challenge is that these two layers are usually managed together in ways that defeat the browser's rendering optimizations. The good news is that there are several architectural choices available, each with different trade-offs. The right pick depends on whether you value DOM simplicity, cross-browser compatibility, or raw frame rate.
Option 1: Absolutely positioned DOM elements
The default approach for most developers is to use absolutely positioned div elements and update their positions on every scroll event. Firing up DevTools Timeline in frame mode during a scroll quickly shows the problem: full-screen paint operations happening repeatedly, and often multiple scroll events firing within a single frame. Each of those events can trigger its own layout pass, and at 60Hz you only have about 16ms per frame to finish all of your work.
The first improvement is to stop doing visual updates directly inside the scroll event callback. Scroll events fire far more often than the browser's rendering schedule, so you can easily miss frames or do redundant work. Instead, capture the latest scroll value in the event callback and defer the actual visual updates to a requestAnimationFrame callback. This syncs your work with the browser's paint cycle and ensures you only do one layout pass per frame, no matter how many scroll events arrived. This alone may not eliminate jank—the real bottleneck remains the repaint cost of moving elements across a single, page-sized layer—but it is a necessary foundation.
Option 2: 3D transforms to promote layers
Rather than updating left and top, applying a 3D transform such as translateZ(0) or translate3d(...) to an element changes how the browser handles it. In WebKit-based browsers, this promotes each transformed element to its own layer and hands compositing to the GPU. Because moving a layer doesn't require repainting it, subsequent transformations are cheap—you are just asking the compositor to re-position existing bitmaps.
The catch is that layer promotion is not something you should apply liberally. The -webkit-transform: translateZ(0) hack works today, but it is not cross-browser, it forces a new layer for every effect element, and some WebKit ports have already disabled this behavior. Too many layers can itself create compositing bottlenecks. Furthermore, this doesn't solve the underlying problem of repainting: the hack only avoids that cost if you stick purely to transformations. Avoid animating background positions or other paint-triggering properties. If your design calls for a moving background inside a contained area, wrap it in a parent with overflow: hidden and translate the inner element instead.
Option 3: A single canvas (or WebGL) behind everything
The third route flips the rendering strategy entirely: instead of manipulating several DOM elements, you draw all transformed background imagery into a single fixed-position <canvas>. While the idea of drawing every frame might sound slower, it has real advantages. Your entire effect becomes one hardware-accelerated bitmap, composited from one layer instead of many. The Canvas2D API is a straightforward fit for moving and rotating images, and it tends to behave consistently across browsers where transform layer-promotion policies differ.
/**
* Updates and draws in the underlying visual elements to the canvas.
*/
function updateElements () {
var relativeY = lastScrollY / h;
// Fill the canvas up
context.fillStyle = "#1e2124";
context.fillRect(0, 0, canvas.width, canvas.height);
// Draw the background
context.drawImage(bg, 0, pos(0, -3600, relativeY, 0));
// Draw each of the blobs in turn
context.drawImage(blob1, 484, pos(254, -4400, relativeY, 0));
context.drawImage(blob2, 84, pos(954, -5400, relativeY, 0));
context.drawImage(blob3, 584, pos(1054, -3900, relativeY, 0));
context.drawImage(blob4, 44, pos(1400, -6900, relativeY, 0));
context.drawImage(blob5, -40, pos(1730, -5900, relativeY, 0));
context.drawImage(blob6, 325, pos(2860, -7900, relativeY, 0));
context.drawImage(blob7, 725, pos(2550, -4900, relativeY, 0));
context.drawImage(blob8, 570, pos(2300, -3700, relativeY, 0));
context.drawImage(blob9, 640, pos(3700, -9000, relativeY, 0));
// Allow another rAF call to be scheduled
ticking = false;
}
/**
* Calculates a relative disposition given the page's scroll
* range normalized from 0 to 1
* @param {number} base The starting value.
* @param {number} range The amount of pixels it can move.
* @param {number} relY The normalized scroll value.
* @param {number} offset A base normalized value from which to start the scroll behavior.
* @returns {number} The updated position value.
*/
function pos(base, range, relY, offset) {
return base + limit(0, 1, relY - offset) * range;
}
/**
* Clamps a number to a range.
* @param {number} min The minimum value.
* @param {number} max The maximum value.
* @param {number} value The value to limit.
* @returns {number} The clamped value.
*/
function limit(min, max, value) {
return Math.max(min, Math.min(max, value));
}
Canvas really shines when your parallax elements are large images or other content that draws naturally into a drawing surface. Text is a different story: fillText exists, but rasterizing text into a canvas kills accessibility and forces you to handle line wrapping yourself. If you need legible, accessible text in the page, a transforms-based DOM approach is likely the better choice.
You can push this idea further by using WebGL instead of Canvas2D. WebGL has the most direct path to the graphics card of any browser API, giving the best chance of sustaining 60fps for complex effects. Support remains uneven, but a library like Three.js lets you write rendering code once and switch between a WebGL and a canvas renderer at runtime. A quick feature check is all that's needed:
// check for WebGL support, otherwise switch to canvas
if (Modernizr.webgl) {
renderer = new THREE.WebGLRenderer();
} else if (Modernizr.canvas) {
renderer = new THREE.CanvasRenderer();
}
If you don't want an extra element in the DOM, some browsers also support using a canvas as a background image, but that isn't ubiquitous—treat it as an enhancement.
Choosing a strategy
Absolute positioning remains popular largely because browser support is universal. That support doesn't buy you much, though: in older browsers, this method is just a guarantee of poor rendering performance, and modern browsers handle it poorly too unless you're clever about layer promotion and avoiding paints.
3D transforms offer better performance while keeping you in the DOM, where text remains accessible and layout is natural. Just remember that the layer-promotion behavior you see in WebKit may not match other engines, so commit only after you have verified the rendering profile on your target browsers.
For designs that lend themselves to bitmap imagery and where you can live without DOM-based text, the canvas and WebGL options are the most robust for hitting a consistent 60fps. Ultimately, this is not a one-size-fits-all decision. What you pick depends on your design priorities—don't guess it, test it.



