The EXT_disjoint_timer_query_webgl2 extension offers something JavaScript rarely gets: how long the GPU spent on a slice of WebGL commands, rather than wall-clock time with CPU work mixed in. You open a TIME_ELAPSED_EXT query, issue the draw commands, end the query, and read the result later in nanoseconds. On paper, it is exactly the tool for finding out which postprocessing pass is the expensive one.
On 3 October 2026 I used that extension to weigh a WebGL page I am building. The measuring was done by a rig that runs Brave headless on an M4 Mac, and one of the things it measured was per-pass GPU time through that extension. The page uses three.js 0.186.1 with the postprocessing library at 6.39.5, so every pass in this article means a pass inside that library's composer.
The queries wrapped around each pass returned impossible numbers. This is about why I stopped trusting them, and the measurement I ended up relying on instead.
Numbers that contradict each other
The rig wraps each pass's render method in a TIME_ELAPSED_EXT query, and separately wraps the composer's own render as a whole under the label total. What came back from those two wrappers made no sense from several directions at once.
First, the SMAA pass was recorded at 10.7 ms, almost the same as the heavy effect pass that runs before it. For a post-process anti-aliasing pass, that is already a strange number.
Second, adding up the per-pass numbers gave more than the composer total measured on its own. A pass's time is part of the whole composer's time, so when the parts sum to more than the whole, at least one of those numbers is not measuring what I thought it was.
Third, the composer total held at 13 to 14 ms whether the canvas was rendered at DPR 1 or DPR 1.5, as long as the work stayed under the 16.7 ms budget. That budget is one frame at 60 fps, 1000 ms divided by 60. Yet at the same CSS size, going from DPR 1 to 1.5 means 1.5 × 1.5 = 2.25 times as many canvas pixels, and postprocessing passes do their work per pixel. My notes put it in one line: the total gets stuck around 14 ms even when the work is far lighter.
Fourth, the numbers only became honest once the GPU was overloaded. The example is 43 ms, with per-fragment terrain detail rendered at DPR 1.5. So the query is trustworthy precisely when the frame has already blown its budget, while what I need for weighing a page is how far under budget a scene runs. In that range, the query total reports nearly the same number for different amounts of work.
The wrapper was not sloppy
It is fair to suspect the instrument first, because this extension has a few well-known traps. The rig already avoided the obvious ones.
WebGL2 allows only one active TIME_ELAPSED_EXT query at a time, and the composer's render calls render on the passes inside it. Measuring the composer and its passes in the same frame would nest the queries. The rig avoids that by rotating the measuring mode every frame between three modes: per pass, one scene element, and total. The wrapper also refuses to open a new query when one is already active, or when the current frame belongs to a different mode.
Query results are only kept when GPU_DISJOINT_EXT is not set. That flag signals that something happened on the GPU that makes timing results unreliable, so samples from that window are thrown away. One measurement defaults to 90 frames split across the three modes, roughly 30 frames per mode, and the samples for each label are summarized as a count, a median, and a p90. Roughly like this, simplified and with neutral variable names:
// Simplified from the wrapper in the measuring rig; variable names are neutral.
const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2');
const modes = ['pass', 'element', 'total']; // every frame: currentMode = modes[frame % 3]
let active = null, currentMode = 'pass';
const pending = [], samples = {};
function wrap(obj, label, mode) {
const original = obj.render;
obj.render = function (...args) {
// never open a second query, and only measure on this mode's frames
if (!ext || active || currentMode !== mode) return original.apply(this, args);
const q = gl.createQuery();
gl.beginQuery(ext.TIME_ELAPSED_EXT, q);
active = label;
try {
return original.apply(this, args);
} finally {
gl.endQuery(ext.TIME_ELAPSED_EXT);
active = null;
pending.push({ q, label });
}
};
}
composer.passes.forEach((pass, i) => wrap(pass, `${i}-${pass.name}`, 'pass'));
wrap(composer, 'total', 'total');
// later, when the result of one query { q, label } is read:
const disjoint = gl.getParameter(ext.GPU_DISJOINT_EXT);
if (!disjoint) (samples[label] ||= []).push(gl.getQueryParameter(q, gl.QUERY_RESULT) / 1e6); // ns to msThe numbers in the previous section came from a wrapper like this one. So the misses cannot be explained by nested queries, disjoint samples slipping through, or one unlucky sample.
The suspected cause is still a suspicion
Brave is a Chromium-based browser, and on a Mac, Chromium runs WebGL through ANGLE, a layer that translates WebGL calls into the system's graphics API, in this case Metal. The suspicion I wrote down: ANGLE Metal measures per command buffer, and Apple GPUs are tile-based, so the boundary between one pass and the next cannot be timestamped. If that is right, a per-pass number is not really measuring its own pass, which would fit an SMAA pass costing as much as a heavy one and parts that add up to more than the whole.
It is still a suspicion, and my notes hold no proof of it. I also have no proven explanation for the total stalling around 14 ms. What the rig's header comment records is only the practical conclusion: on Apple GPUs (ANGLE Metal), per-pass time queries cannot be trusted, use total, the whole composer. The second half of that note does not line up with the numbers above, because the total from the same query also stalls at 13 to 14 ms while the work stays under budget. To weigh the page, I needed a method that does not depend on GPU queries at all.
Forty renders and one readPixels
The method I recorded as proven uses no queries. On the page, the composer is rendered 40 times back to back without waiting for requestAnimationFrame, then a single gl.readPixels(0, 0, 1, 1, ...) is called as a sync point, and the wall-clock time is divided by 40. The rig calls this measurement the real load. Before the stopwatch starts, the composer is rendered once and a one-pixel readPixels is called once. Only then come the 40 renders in one loop, one more readPixels, and the performance.now() difference divided by 40. A simplified version:
// Simplified from the "real load" measurement in the measuring rig; variable names are neutral.
const buf = new Uint8Array(4); // one RGBA pixel
composer.render(1 / 60);
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); // sync before the stopwatch
const t0 = performance.now();
for (let i = 0; i < 40; i++) composer.render(1 / 60); // no waiting for rAF
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); // wait until the GPU has finished everything
const msPerFrame = (performance.now() - t0) / 40;The trick is in how readPixels behaves. Reading pixels back into JavaScript memory is synchronous: the browser has to wait for the earlier draw commands to finish before it can copy the result, so a single pixel is enough as a sync point and the copy itself costs next to nothing. The first readPixels makes sure the GPU queue is empty when the stopwatch starts, so leftover work from an earlier frame is not counted. Forty renders without waiting for rAF keep the GPU fed with no vsync gaps, and the cost of the one sync at the end is spread across 40 frames. The result is still wall-clock time, so it also includes the CPU work of building the commands, not pure GPU time. For weighing a page, that is exactly what I want.
The results are sensible and follow the pixel count. At the measuring point that the calibration comment in the code marks as the heaviest frame, the real load is 10.1 ms at DPR 1 and 13.8 ms at DPR 1.5. The ratio, 13.8 divided by 10.1, is about 1.37, while the pixel count goes up 2.25 times. The increase is not proportional, but it moves in the same direction as the pixel count, which the query total did not do. Both numbers are also under the 16.7 ms budget, the range in which the query total stalled at 13 to 14 ms.
For those numbers to mean anything, the rig prepares the page first. The page's idle-saving mode is switched off during measurement, so every frame actually renders. The page's dynamic resolution is switched off by replacing its function with an empty one, since dynamic resolution exists to change the resolution as the load changes, and a measurement taken while the resolution moves is no longer measuring one condition. On desktop, the canvas is measured at DPR 1 and 1.5 by default, and 1.5 is the page's canvas DPR ceiling at its top quality tier. After the DPR changes, the rig waits 700 ms so new programs finish compiling outside the sample, and at each point it holds for 1.8 seconds so temporal accumulation of the scene's effects and exposure damping settle first. Brave itself is launched headless with the --enable-gpu and --ignore-gpu-blocklist flags.
Cost per element: switch it on, switch it off, compare
For the cost of a single element, the rule I follow now is not to trust a per-pass split. Instead, the total load is compared with that element switched on and switched off. The page exposes its main instance as a property on window, and the rig does not change the scene code at all: every switch is attached from outside through that property.
I should be straight about this part. The rule exists, but for this rig there are no per-element on and off numbers measured with the real load yet.
The code does record numbers for one element that ended up removed. A comment in the terrain code notes that the per-fragment terrain detail was removed on 3 October 2026, because at DPR 1.5 it pushed the frame from 14 to 43 ms, and the result looked like a game texture. The comment does not say which measuring method produced those two numbers. My notes use that 43 ms as the example of an honest query reading, since it is far above budget. The 14 ms comes with no such remark, and it falls right inside the stalled range from earlier, so I do not use it as the cost of a frame without the detail. After that day's measurement, the composer was trimmed to a RenderPass, one EffectPass, then SMAA, with no normal pass and no terrain detail.
The numbers I still use
- Per-pass times from
TIME_ELAPSED_EXTunder ANGLE Metal are not something I use to split cost between passes. - The total from the same query only means something once the frame is over budget. Under budget, it holds at 13 to 14 ms even when the work is far lighter.
- The load of one frame is measured with back-to-back renders that do not wait for rAF, a one-pixel
readPixelsas a sync point before and after, and the wall-clock time divided by the number of renders. - Before measuring, switch off anything that lets the page adjust itself, such as dynamic resolution and an idle-saving mode, then leave time for program compilation and temporal accumulation.
- The cost of one element is the difference in total load between that element on and off.
- The evidence for this method is still a sanity check: the numbers follow the pixel count. This article has no comparison against a GPU profiling tool, and the cause I suspect for the misbehaving queries remains a suspicion.