← all guides

Canvas and WebGL fingerprints in headless browsers

If you run a scraping fleet long enough, you hit a specific kind of failure. The proxies are clean, the TLS handshake matches a real Chrome, the headers are in the right order, and the session still gets a challenge page after the third request. When I trace those cases, the cause is often not the network layer at all. It is a canvas hash or a WebGL renderer string that says “this browser has no GPU, no real fonts, and runs on a cloud VM”.

This article is for people who already run Playwright, Puppeteer or Selenium at scale and have seen the basic advice. I am not going to explain what a fingerprint is. I want to cover what actually differs between a headless Chromium on a Linux box and a desktop Chrome on someone’s laptop, where those differences show up in the canvas and WebGL APIs, and which fixes survive contact with a real detection stack.

The stakes are practical. A bad canvas or WebGL profile does not always block you outright. More often it quietly lowers your trust score, so you see more captchas, slower responses, or soft blocks like empty result sets. That is expensive to debug because nothing errors. I will cover how to measure it yourself, so you are not guessing. This is a technical walkthrough of how browsers expose rendering differences, and it is not a guide to anything illegal. Respect the terms and laws that apply to the sites you work with.

background and prior art

Canvas fingerprinting has been studied in public since at least 2012, when Mowery and Shacham published “Pixel Perfect: Fingerprinting Canvas in HTML5”. The idea is simple. Draw the same text and shapes on a canvas, read the pixels back, and hash them. Differences in GPU, driver, operating system, font rasterizer and anti-aliasing produce slightly different pixels, and the hash becomes a stable identifier with a decent amount of entropy. The 2014 KU Leuven paper “The Web Never Forgets” measured canvas fingerprinting in the wild across the top 100,000 sites, which is where a lot of the industry awareness came from.

WebGL adds more surface. Besides rendering a scene and hashing it, a script can read parameters directly: MAX_TEXTURE_SIZE, MAX_VERTEX_UNIFORM_VECTORS, the list of supported extensions, shader precision formats, and, through the WEBGL_debug_renderer_info extension, the unmasked vendor and renderer strings. The W3C published guidance on mitigating fingerprinting in web specifications, and browsers responded in different ways. Firefox has privacy.resistFingerprinting. Brave randomises canvas and WebGL readbacks per session and per site, which it calls farbling. Safari reduced what it exposes. Chrome has kept the APIs mostly intact, which is why Chromium-based automation is the main battleground.

For headless specifically, two things matter. First, historically Chrome’s headless mode was a separate implementation from the headful browser. Chrome 112 introduced “new headless”, which runs the real browser code without a visible window, and Chrome 132 moved the old implementation into a separate chrome-headless-shell binary. The Chrome team documents this in their new headless announcement. Second, regardless of mode, a server without a physical GPU falls back to software rendering, and that fallback is distinctive.

the core mechanism

There are four layers that produce a canvas or WebGL fingerprint. Each one can leak independently, and a detection system usually cross-checks them against each other. The cross-check matters more than any single value.

layer 1: the WebGL renderer and vendor strings

By default, gl.getParameter(gl.RENDERER) returns a generic masked string. To get the real one, a script requests the WEBGL_debug_renderer_info extension and reads UNMASKED_VENDOR_WEBGL (0x9245) and UNMASKED_RENDERER_WEBGL (0x9246). MDN documents the extension in its WEBGL_debug_renderer_info reference, and the Khronos registry has the formal definition.

On a typical Linux VPS running headless Chromium with no GPU, you will see something like this:

vendor:   Google Inc. (Google)
renderer: ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)

The exact string varies by Chrome version and ANGLE backend, but the word SwiftShader is the tell. SwiftShader is Google’s CPU-based Vulkan implementation. Almost no real consumer on a desktop has it as their primary renderer. A real Windows laptop reports something like ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11) or ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11). A Mac reports ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version). Any detector with a table of known-good renderer strings can flag SwiftShader in one lookup.

layer 2: WebGL parameters and extensions

Even if you spoof the renderer string, the numeric parameters underneath have to match what that GPU would report. A script can read maybe 30 to 60 parameters and the full extension list, then compare them with a lookup keyed on renderer. SwiftShader reports its own limits and its own extension set. If your renderer string says RTX 3060 but MAX_TEXTURE_SIZE and the extension list say SwiftShader, you have created a contradiction that is more suspicious than the honest software renderer. This is the most common mistake I see with naive stealth patches, and I will come back to it in the failure modes section.

layer 3: canvas 2D rendering

Canvas 2D fingerprinting draws text, often with an emoji and a pangram in a few fonts, overlaid with gradients and arcs, then calls toDataURL() or getImageData(). Things that change the output:

  • font availability: a stock Debian or Ubuntu container often has only DejaVu and maybe Liberation fonts. Requests for Arial, Helvetica or Segoe UI fall back to something else, and the glyph shapes differ.
  • emoji: without Noto Color Emoji installed, emoji render as empty boxes or monochrome glyphs. A real desktop renders a colour emoji. This one difference alone splits most headless Linux fleets from real users.
  • text anti-aliasing: subpixel (LCD) text rendering is common on Windows and absent in most Linux headless setups. Greyscale anti-aliasing produces different edge pixels.
  • GPU rasterization: when canvas is GPU-accelerated, arcs and gradients get GPU-specific rounding. Software canvas gives you a different result.
  • Skia version: the fingerprint changes across Chrome versions, so a hash is only meaningful relative to the reported user agent.

A scripted check for this is short, and you should run it in your own stack:

// run inside page.evaluate()
function canvasProbe() {
  const c = document.createElement('canvas');
  c.width = 280; c.height = 60;
  const ctx = c.getContext('2d');
  ctx.textBaseline = 'top';
  ctx.font = '16px Arial';
  ctx.fillStyle = '#f60';
  ctx.fillRect(100, 1, 62, 20);
  ctx.fillStyle = '#069';
  ctx.fillText('Cwm fjordbank glyphs vext quiz, \u{1F600}', 2, 15);
  ctx.strokeStyle = 'rgba(102,204,0,0.7)';
  ctx.arc(50, 30, 20, 0, Math.PI * 2);
  ctx.stroke();
  return c.toDataURL();
}

Hash the return value with SHA-256 and compare it across your own machines. If every container in your fleet produces the same hash, and that hash does not appear on a real laptop, you have a fleet-wide marker.

layer 4: consistency across contexts and with the rest of the profile

This layer is the one people underestimate. A modern detection script does not just read the main-thread canvas. It may also:

  • render in an OffscreenCanvas inside a dedicated worker and compare with the main thread
  • create a canvas inside a cross-origin or sandboxed iframe, where your patches may not have been applied
  • call toDataURL() twice and check the two results are identical (naive per-call noise fails this)
  • compare the WebGL renderer with navigator.platform, navigator.userAgent, navigator.userAgentData.platform, screen size, device pixel ratio and the font list
  • compare against WebGPU adapter info where available

A fingerprint is a claim about a machine, and the claim has to be coherent. A Windows user agent with a Mesa or SwiftShader renderer and no Windows fonts is incoherent no matter how well each individual value is spoofed.

why headless matters but is not the whole story

New headless in modern Chrome closes many of the historical gaps, because it is the same code as the headful browser. Things like missing plugins, odd window.chrome objects and broken permission APIs are mostly gone. What it does not change is the machine underneath. A new-headless Chrome on a GPU-less VPS still renders with SwiftShader and still lacks desktop fonts. So the right mental model is that headless mode determines your JavaScript-visible API surface, and the host environment determines your rendering output.

worked examples

All numbers below come from my own test runs on a small fleet. They are rough and reflect specific Chrome versions at the time, so treat them as a method to copy and not as universal constants. Run your own measurement before you trust any of it.

example 1: baseline audit of a containerised fleet

I took 40 identical Docker containers based on a standard Playwright image, running Chromium in new headless mode on three Hetzner-style VPS hosts. I ran the canvas probe above and a WebGL probe that reads the unmasked renderer, 40 parameters and the extension list.

  • canvas hash: 1 unique value across all 40 containers, not just per host. Identical hardware class, identical fonts, identical result.
  • WebGL renderer: the same SwiftShader string on all 40.
  • emoji: rendered as boxes in the default image until I installed fonts-noto-color-emoji.

Then I checked the same probes on four real devices: a Windows 11 laptop with Intel graphics, a desktop with an NVIDIA card, a MacBook, and a mid-range Android phone. All four produced distinct canvas hashes and distinct renderer strings. That is the point. Real populations are diverse, and a fleet that collapses to one value is visible as a cluster. If a site sees 5 percent of its traffic from a single canvas hash that no real device family produces, that cluster is easy to score down.

The cheap improvement here was installing a realistic font set, which changed the canvas hash and removed the box emoji. It did not fix the renderer string. It also made every container identical to every other container again, just with a different hash, which leads to example 2.

example 2: a spoof that made things worse

On a second project I tried the common stealth approach: override WebGLRenderingContext.prototype.getParameter so that the unmasked renderer returns an NVIDIA string, and add small random noise to toDataURL. On a handful of target sites, challenge rates went up compared with the unpatched SwiftShader baseline. I measured roughly a 12 to 15 percent increase in challenges on one retail target across about 2,000 sessions per arm. That is a single target and a single week, so do not generalise it, but the direction was consistent across the three sites I tested.

When I dug into why, I found three issues:

  • the extension list and MAX_TEXTURE_SIZE still said SwiftShader while the renderer said NVIDIA
  • Function.prototype.toString.call(WebGLRenderingContext.prototype.getParameter) returned my patched source and not function getParameter() { [native code] }
  • reading a canvas twice returned two different hashes because I had added fresh noise on each call

Any one of those is enough for a decent detector. Fixing them meant patching consistently, making toString return native-looking text, and seeding the noise per session and per origin so reads are stable. That brought the challenge rate back to near the baseline, not below it. The honest summary is that the patch only made me even with the unpatched browser after a lot of work, which brings me to example 3.

example 3: run on real GPU hardware instead

For one high-value target I stopped patching and changed the host. I rented a small number of bare-metal boxes with consumer GPUs, ran Chrome with hardware acceleration enabled, and used a headful browser under Xvfb with the real driver stack. The renderer string, parameters, extension list and canvas output were all genuinely produced by the GPU, so nothing had to be faked and nothing could contradict.

Costs were the trade-off. A GPU host cost me several times what a plain VPS does per concurrent browser, and concurrency per box was limited by VRAM and the driver. For that target the lower challenge rate paid for itself. For bulk jobs on easy targets it did not. If you are weighing this kind of decision, my notes on costing a job per thousand pages show how I put a number on it before committing to hardware.

The residual issue was the diversity problem again. Ten boxes with the same GPU model and driver produce the same canvas hash. Real hardware reduces contradictions, but it does not give you a population. You still need to vary the machine class, or accept the cluster.

edge cases and failure modes

contradiction between renderer and everything else

Pitfall: spoofing the renderer string alone. Detectors cross-check parameters, extensions, shader precision, platform, fonts and screen size.

Counter-strategy: do not spoof a renderer string you cannot back up. Either pick a coherent profile where every layer agrees (including font set and devicePixelRatio), or stay with the honest software renderer and avoid the other tells. Coherence beats a flattering number. If you use a stealth tool, check whether it ships complete profiles or only patches the string. This is also where the antidetectreview.org blog is useful, since commercial anti-detect browsers differ a lot in whether they ship coherent canvas and WebGL profiles or just noise.

patches that are visible as patches

Pitfall: overriding prototype methods in a way that can be detected. Common leaks are non-native toString output, wrong function length and name, properties appearing as own properties instead of inherited, and stack traces that reveal injected script frames.

Counter-strategy: inject before any page script runs (page.addInitScript in Playwright, Page.addScriptToEvaluateOnNewDocument over CDP), patch at the prototype level with proxies that preserve native-looking behaviour, and test against a detection page that checks these properties. Better still, patch below JavaScript, either with a custom Chromium build or by controlling the GPU flags, since a change in the browser itself cannot be detected by inspecting a JS function.

iframes, workers and offscreen canvas

Pitfall: your init script patches the top frame but not workers or cross-origin iframes. A detector draws in a worker with OffscreenCanvas and compares with the main thread. The honest value shows up in the worker.

Counter-strategy: make sure your patch applies to every realm. With CDP you can use Target.setAutoAttach with waitForDebuggerOnStart to inject into workers before they run. Then test with a script that renders the same scene in the main thread, a worker and an iframe, and verify all three hashes match. If you cannot cover workers, prefer changing the host environment over patching, because host-level changes apply everywhere.

unstable noise

Pitfall: random noise on every read. Two consecutive reads differ, which no real browser does. Some detectors read three times to catch this specifically.

Counter-strategy: derive noise from a per-session seed combined with the origin, apply it deterministically, and keep it small. Brave’s approach is the reference here: results are stable within a session and site but differ across them. Also consider that a “noisy” canvas is itself a signal, because only a minority of real browsers randomise. If you randomise, you are claiming to be one of those.

flag and version drift

Pitfall: GPU-related Chrome flags change between versions. In recent Chrome releases the automatic fallback to SwiftShader requires an explicit opt-in flag (--enable-unsafe-swiftshader), and ANGLE backend flags like --use-angle= behave differently by platform. A flag combination that worked in a pinned image breaks silently after an upgrade, and canvas output changes with Skia updates.

Counter-strategy: pin Chrome by exact version in your images, keep a canary session that runs the canvas and WebGL probes after every upgrade, and alert when the hash or renderer string changes unexpectedly. Check the Playwright browser docs for which Chromium build your Playwright version bundles, because it is not the same as the stable Chrome channel. A bundled Chromium and a branded Chrome can differ in codecs and some rendering details.

what we learned in production

The biggest lesson is that the fingerprint is a population problem and not just a single-session problem. I spent too long making one session look perfect. What mattered more was that a thousand sessions did not all share one canvas hash, one renderer string and one font list. The practical fix was boring: several image variants with different font sets, a few distinct GPU host classes for the targets that justify them, and profile assignment that stays sticky per account or per cookie jar so a given identity does not change hardware between visits. If you run many identities, the same logic applies as in multi-account work, and the multiaccountops.com blog covers the identity-consistency side of it better than I will here.

The second lesson is to measure before and after every change, with enough sessions to see a difference. I now keep a standing probe page that logs the canvas hash, the unmasked renderer, a parameter digest and a worker-versus-main comparison for every browser image I build, and I diff those numbers on each Chrome upgrade. Most of my wasted weeks came from changing five things at once and having no idea which one moved the challenge rate. Start with the cheap, honest fixes (real fonts, emoji, sane screen size, a consistent timezone and locale for the exit IP), see how far they get you, and only then consider patching or buying GPU hardware. For anything else on the network side of this, the blog index has the proxy-specific pieces.

references and further reading

Written by Xavier Fok

disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-10-04.

proxies
Need proxies that survive the block wall?

Singapore Mobile Proxy runs real 4G/5G mobile IPs on rotating SIMs — the carrier-grade addresses most of these targets still trust.

see plans →
read on
More scraping guides

The rest of the field manual: target-site playbooks, library walkthroughs, provider reviews, and anti-bot troubleshooting.

browse all guides →