Detecting virtualized and containerized browsers: GPU, screen, and timing artifacts
A real browser runs on a machine that someone bought, plugged in, and sits in front of. It has a graphics card with a brand name, a monitor with an awkward resolution, speakers, a webcam permission it has refused twice, and a clock that drifts the way silicon drifts. A browser running in a cloud container has none of that. It rasterizes WebGL on the CPU because there is no GPU on the box, reports an 800-by-600 screen because nobody attached a display, and answers timing questions with the slightly-too-clean rhythm of a virtualized clock. None of these are bugs. They are the honest output of an honest browser describing the honest environment it lives in. The problem, for anyone trying to look human from inside a datacenter, is that the environment is the tell.
This is the detection surface that does not care which automation framework you use or how well your stealth patches hide navigator.webdriver. You can spoof the user agent, suppress the CDP leaks, and synthesize perfect mouse curves, and the box underneath you still has no GPU. That gap between “what the browser claims” and “what the hardware can actually do” is where environment detection lives. This post walks the three biggest categories: the GPU and WebGL renderer strings, the screen and display geometry, and the timing and device-count artifacts of virtualization. It says where each signal comes from, what a real consumer device returns versus what a server returns, and where the public record runs out and inference begins.
The sections go from the loudest signal to the subtlest. First the WebGL renderer, which is the single most reliable VM tell and the hardest to fake convincingly. Then screen geometry: the default headless window size, device pixel ratio, and the touch-versus-desktop mismatches. Then the cheap navigator counters, deviceMemory and hardwareConcurrency, that a thin container gives away for free. Then timing, which is the deepest and the most fragile of the four. A closing section ties the signals together into the consistency check that detectors actually run, because no single artifact convicts on its own.
The WebGL renderer is the loudest signal
Ask a browser what its GPU is called and it will mostly tell you the truth. WebGL exposes this through the WEBGL_debug_renderer_info extension, whose two constants, UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL, return the graphics vendor and the renderer string straight from the driver. The MDN reference describes the extension as exposing driver information intended for debugging. It became, instead, one of the most stable fingerprinting and bot-detection signals on the web, because the string it returns is specific, hard to fake, and tightly coupled to physical hardware that a datacenter box does not have.
On a real Chrome install, the renderer string is wrapped by ANGLE, Chrome’s graphics abstraction layer, and looks like a long structured label. A consumer NVIDIA card reports something close to ANGLE (NVIDIA, NVIDIA GeForce RTX 3090 (0x00002204) Direct3D11 vs_5_0 ps_5_0, D3D11). An Intel integrated chip reports ANGLE (Intel, Intel(R) UHD Graphics 630 (0x00003E9B) Direct3D11 vs_5_0 ps_5_0, D3D11). An Apple Silicon Mac reports ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version). The pattern is consistent: a vendor name, a model name a marketer chose, a PCI device ID in hex, and the backing graphics API. That hex device ID is the detail that matters. It is a real number assigned to a real silicon part, and a software renderer has nothing to put there.
Because when there is no GPU, the string changes completely. Headless Chrome on a server with no graphics hardware falls back to SwiftShader, Google’s CPU-based rasterizer, and the renderer comes back as some variant of Google SwiftShader, historically embedded in a longer ANGLE string like ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver). The 0x0000C0DE there is not a device ID. It is a hex pun. SwiftShader hard-codes it precisely because there is no real device to identify, and its presence is an immediate, unambiguous declaration that this browser is rendering graphics on the CPU. No consumer laptop ships that string. On Linux VMs and containers the equivalent fallback is Mesa’s llvmpipe, a software rasterizer that JIT-compiles OpenGL shaders to CPU machine code through LLVM, and the renderer string says llvmpipe outright. Both are software. Both are rare-to-nonexistent on the consumer devices the traffic is pretending to come from.
The detection logic writes itself. Maintain a list of software renderer substrings, SwiftShader, llvmpipe, Mesa OffScreen, Microsoft Basic Render Driver, and flag any session whose renderer matches. This is cheap, runs in a few lines of client-side JavaScript, and has an extremely low false-positive rate because almost nobody browses the consumer web on software rendering. The handful of genuine users who do (a corporate VDI deployment, a stripped-down VM, someone whose GPU driver crashed) are rare enough that detectors are comfortable treating the signal as high-weight. Vendors that publish about WebGL fingerprinting describe it the same way: a software renderer is a strong standalone indicator that the environment is virtualized or headless, and inconsistency between the renderer and the rest of the fingerprint is a second-order tell on top.
That second-order check is the more interesting one. A scraper that knows about the SwiftShader problem will often patch the renderer string to claim a real GPU, hooking the getParameter call so it returns ANGLE (NVIDIA, ...) instead of the truth. This defeats the substring blocklist and creates a worse problem. WebGL is not only a string. It also rasterizes actual geometry, and the pixels that come back from a software renderer differ from the pixels a real GPU produces: anti-aliasing edges, floating-point rounding in shaders, supported extension lists, maximum texture sizes, and shader precision ranges all carry the renderer’s signature. A session that claims an RTX 3090 in its renderer string but produces SwiftShader’s exact pixel output and SwiftShader’s extension list has told two contradictory stories, and the contradiction is louder than either signal alone. Tools like CreepJS are built around exactly this idea, cross-checking each claimed attribute against the others and surfacing the mismatches as “lies.” The patch-the-string approach trades a known tell for a more damning one.
There is a deeper reason the renderer is hard to fake from a datacenter, and it is economic rather than technical. The reliable fix is to give the browser a real GPU, either by renting GPU instances or passing a physical card through to the VM. That works, the renderer string becomes genuine, and the pixels match. It is also expensive and does not scale to the thousands of concurrent sessions a scraping operation wants, and the GPUs available in datacenters (datacenter-class Tesla and A100 parts) render with their own distinct signatures that differ from the consumer GeForce and Radeon cards the traffic is impersonating. So even the honest fix leaks. The economics of this gap, where the defensive signal is cheap and the evasion is expensive, is a recurring theme across the anti-bot field, and it is why GPU detection has stayed effective for so long. Whether to patch the string in the engine or hook it at runtime is its own debate, covered in patching Chromium from source versus runtime injection.
One caveat keeps this signal honest. The WEBGL_debug_renderer_info extension is not guaranteed to be available. Firefox disables it under privacy.resistFingerprinting, the Tor Browser returns constant values, and the extension has been on a slow deprecation path in some engines, so a missing renderer string is itself a (weaker) signal rather than a guarantee. Chrome and Chromium-based browsers still return the exact value, which is why the signal remains so useful against the Chromium-based automation stacks that dominate scraping.
Screen geometry: nobody attached a monitor
The second category is cheaper to read and almost as reliable. A browser describes its display through window.screen and window.devicePixelRatio, and a browser with no display has to make something up. Historically, headless Chrome made up an 800-by-600 screen. That default has been a documented headless tell for years: the invisible browser window is 800 by 600 unless you pass --window-size, and even passing the flag did not always propagate to screen.width and screen.height, which stayed pinned at the default. An anti-bot script reading screen.width === 800 && screen.height === 600 catches a meaningful slice of naive automation outright, because that exact resolution on a desktop user agent in 2026 is vanishingly rare. Real users sit in front of 1920-by-1080, 2560-by-1440, 1366-by-768 laptop panels, and the various Retina and 4K resolutions. They do not sit in front of 800 by 600.
This has shifted recently, and the shift matters for anyone reading older detection writeups. Chrome’s headless mode now supports a fully virtual, configurable screen. As of Chrome 142, shipped with the headless screen configuration feature documented in January 2026, a --screen-info switch lets the operator define the origin, size, scale factor, orientation, and work area of one or more virtual displays, and those values flow through to the DOM. The 800-by-600 default is no longer mandatory. A scraper can now present a believable 1920-by-1080 desktop screen without the values leaking back to the default. So the bare-resolution check has decayed from a strong signal to a weak one, and a detector that still relies on it alone is reading a bot population that has mostly moved on.
What replaces it is consistency between the screen properties, not any single value. A real display has internal relationships. screen.availWidth and screen.availHeight are the screen minus the taskbar or dock, so on Windows they are usually a little smaller than screen.width and screen.height; a configuration where availWidth === width and availHeight === height exactly suggests no desktop chrome, which is normal for some setups but suspicious in combination with other signals. The window.innerWidth and innerHeight should be smaller than outerWidth and outerHeight by the height of the browser toolbar; values of zero, or inner equal to outer, are a classic automation artifact. And devicePixelRatio ties the whole thing together: a user agent claiming a Retina MacBook should report a DPR of 2, a claimed 4K Windows laptop at 150% scaling should report 1.5, and a desktop Linux container that reports a flat DPR of 1 alongside a high-resolution screen and a Mac user agent has produced a combination that does not occur in the wild.
There is a touch dimension to this as well. A user agent claiming a mobile or tablet device should report a nonzero navigator.maxTouchPoints, a pointer: coarse media query, and an orientation that changes. A container emulating a phone through the DevTools Protocol can set the viewport and DPR but often leaves maxTouchPoints at zero or forgets the orientation object, so the device claims to be a phone and then admits it has a mouse and no touchscreen. These mismatches are the same family of bug as the GPU contradiction: the spoof reaches the obvious surface property and misses one of the dependent ones. The general principle that stealth patches reach the headline property and forget the dependent surfaces shows up across iframe and worker context fingerprinting too, where the patched main context and the unpatched worker disagree.
The cheap counters: device memory and core count
Two navigator properties give away thin virtualization almost for free. navigator.deviceMemory reports approximate RAM, and navigator.hardwareConcurrency reports the logical CPU core count. Both are deliberately coarsened for privacy, and the coarsening is exactly what makes the out-of-range values diagnostic.
navigator.deviceMemory does not return the real RAM figure. The W3C Device Memory specification requires the value to be rounded to the nearest power of two and then clamped to a privacy-preserving range, so in practice Chrome returns one of a small set of buckets: 0.25, 0.5, 1, 2, 4, or 8, with newer Chrome on non-Android platforms able to report 16 and 32 as well. The implementation detail is concrete. Chrome’s source caps and rounds the figure, historically with a comment in the device-memory code noting that the value is max-limited to reduce the fingerprintability of high-spec machines. Two things fall out of this. A value below the typical floor, 0.25 or 0.5 gigabytes, is normal for a cheap phone but suspicious on a desktop user agent, and it is exactly what a memory-constrained container reports. And any value that is not a clean power of two (a 3, a 6, a non-bucket float) cannot come from a spec-compliant Chrome at all; it can only come from a spoof that did not read the rounding rule. Castle’s writeup on the property describes catching exactly this in production traffic, including a meaningful volume of Chrome sessions reporting values above the 8 GB cap, which a genuine Chrome of the relevant era could not produce, correlated with automation and fake-account activity.
navigator.hardwareConcurrency is the companion signal. It returns the logical core count, and virtualized instances are frequently provisioned with 1 or 2 vCPUs to keep costs down. A desktop user agent reporting hardwareConcurrency === 1 is unusual; real desktops and laptops have had four or more logical cores as the common case for years. The value can legitimately be reduced by the browser, so a low number is not proof on its own, but a low core count alongside a low memory bucket, a software renderer, and a datacenter ASN starts to draw a consistent picture of a small cloud instance. None of these counters convicts alone. They are weights, and the scoring systems that consume them stack the weights. How a vendor turns a pile of weak signals into a single verdict is the subject of the DataDome scoring pipeline and the broader JavaScript runtime fingerprinting writeups.
There is a subtle trap in spoofing these. Because the legal values form a small discrete set, a spoof has to land on a value inside the set and consistent with everything else. Set deviceMemory to 16 and hardwareConcurrency to 32 to look like a beefy workstation, and now the renderer had better be a real high-end GPU and the screen had better be a large high-DPR display, or the workstation claim collapses under its own contradictions. The counters are easy to read and easy to change, which means the detector’s value is not in the raw number but in whether the raw number agrees with the rest of the story.
Timing under virtualization
The deepest category is also the most fragile, and it deserves a careful and honest treatment because a lot of what circulates about it is overstated. The idea is that virtualization perturbs timing, and that a high-resolution clock in the browser can measure the perturbation. The physics is real. The browser’s access to it is heavily constrained, and that constraint is the first thing to understand.
After Spectre, browsers deliberately blunted their timers. Chrome clamps performance.now() to a resolution of 100 microseconds by default, a change that landed in Chrome 91 and applies across platforms, as the cross-origin isolated timers post lays out. Firefox clamps to a coarser 1 millisecond. The fine 5-microsecond resolution is available again only to pages that opt into cross-origin isolation by sending the Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers, which a detector serving its own challenge page can do but which dramatically narrows the field of pages that get the high-resolution clock at all. The motive was speculative-execution side channels: a high-resolution timer is what lets Spectre-style attacks measure cache timing differences, and coarsening the clock raised the cost of those attacks. The side effect is that a detector trying to time microarchitectural behavior from JavaScript is working with a 100-microsecond ruler unless it controls the page’s isolation headers.
Within that 100-microsecond limit, you can still measure things that take long enough to register. Two flavors of timing detection survive the coarsening. The first is a relative measurement that does not need absolute precision: spawn parallel web workers, give each a fixed compute task, and time how the aggregate scales as you add workers. On a machine with two real cores, adding a third and fourth worker does not speed anything up because there is nothing to run them on, and the scaling curve flattens. This is a practical way to estimate the true core count even when hardwareConcurrency has been spoofed, because it measures what the silicon can actually do rather than what the navigator claims, and the researcher who documented browser-based VM detection in 2019 reported this worker-timing approach as the most accurate single technique in that toolkit. It is also the same family of measurement that pure behavioral timing detection uses, covered in detecting automation via timing.
The second flavor is the one to be skeptical about, and I want to be precise about the boundary. There is a well-established native technique where code executes rdtsc; cpuid; rdtsc and measures the cycle gap; the cpuid instruction forces a VM exit under many hypervisor configurations, and the trip out to the hypervisor and back costs on the order of 1,200 to 2,000-plus CPU cycles, far more than the handful of cycles the instruction takes on bare metal. That technique is real, it is used by anti-cheat systems, and it cleanly separates virtualized from native execution. But it runs in native code with access to rdtsc and the ability to issue cpuid. JavaScript in a sandboxed browser has neither. It cannot issue cpuid, it cannot read the timestamp counter directly, and its clock is coarsened. So the clean native VM-exit timing attack does not port to the browser as-is. What ports is the weaker statistical cousin: certain hypervisors expose a synthetic clock running at a fixed, characteristic frequency, and analyzing the distribution and quantization of performance.now() deltas can sometimes reveal that the underlying clock ticks at one of those telltale rates rather than at the messy frequency of real hardware. The exact frequencies and the reliability of this in a modern coarsened-clock browser are not cleanly documented in public, and what circulates is more often described than measured. Treat browser-side hypervisor-clock detection as plausible and occasionally real, not as a reliable production signal, and weight it accordingly.
There is also a recovery technique worth naming because it shows the cat-and-mouse around the clamp. Before cross-origin isolation gated it, a SharedArrayBuffer shared between threads could act as a homemade high-resolution clock: a worker increments a counter in a tight loop, the main thread reads it, and the counter’s value becomes a fine-grained timestamp that sidesteps the performance.now() clamp entirely. The same researcher who profiled high-precision timers showed that clock-interpolation methods can claw resolution back down toward tens of microseconds, and noted that the interpolation behavior itself varies by OS and CPU enough to leak something about the environment. Browsers responded by gating SharedArrayBuffer behind the same cross-origin isolation that gates the fine clock, which is why both moved together. The upshot for environment detection is that precise timing is a controlled resource now, available mostly to a page that serves its own isolation headers, which a first-party anti-bot challenge can do and a third-party tracker mostly cannot.
Devices that are not there
A few more environment signals come from hardware that a container simply does not have, and they are worth a short tour because they share the same logic as the GPU: the absence of a real peripheral leaks.
Audio is the clearest. The Web Audio API renders a short signal through an oscillator and compressor, and the floating-point output is a stable per-device fingerprint that depends on the audio stack. A headless container often has no audio device at all, which can leave the AudioContext unavailable, stuck at a constant default sampleRate, or producing output identical across thousands of sessions because they all share the same virtual audio pipeline. Detectors watch for the API being missing and for many sessions collapsing onto a tiny set of identical audio hashes, both of which point at a stripped or virtualized runtime rather than a population of distinct real machines. The same collapse-onto-a-handful-of-values pattern appears in canvas and font enumeration when the container ships a minimal font set: a real desktop has hundreds of installed fonts in an idiosyncratic mix, and a bare Linux container has the dozen that came with the base image, so the font list is both short and suspiciously uniform across the fleet.
Media codecs are another. A stripped Chromium build lacks the proprietary codecs that ship in real Chrome, so canPlayType for H.264 or AAC can come back empty where a genuine Chrome answers probably. That is more of a Chromium-versus-Chrome tell than a VM tell, but it travels with virtualized scraping because the cheap path is to run open-source Chromium in a container rather than licensed Chrome on real hardware. The catalog of these per-build differences belongs more to headless Chrome detection, but they reinforce the environment story: the container is built from the parts that are free to ship, and the missing parts are the ones that cost money or need hardware.
Putting the artifacts together
No single one of these signals is meant to stand alone, and a detector that fired on any one of them in isolation would generate false positives from the long tail of unusual-but-real users: the person on corporate VDI with software rendering, the genuinely low-spec phone, the privacy browser that randomizes its renderer. The power of environment detection is in the join. A session that presents a Windows 11 desktop user agent, then reports a SwiftShader renderer, an 800-by-600 screen, 0.5 gigabytes of device memory, two CPU cores, no audio device, a dozen fonts, and a datacenter ASN has not failed one check. It has failed seven, and every one of them points the same direction. The consumer Windows desktop it claims to be does not exist, and the cost of fixing all seven at once is most of the cost of just running a real browser on a real machine, which is the outcome the detector wants to force.
The arms race has a clear shape from here. The loud, binary signals are decaying as automation tooling matures: the 800-by-600 default is configurable now, the SwiftShader string can be patched, and the new headless mode closed several of the old giveaways. What does not decay is the consistency requirement, because fixing it is not a patch, it is an infrastructure bill. You can spoof any individual value, but making fifteen spoofed values mutually consistent, and keeping them consistent with the WebGL pixel output and the timing behavior and the ASN, is the hard part, and the detectors have moved their weight from the individual values onto the joins between them. CreepJS treats the problem as a search for “lies” rather than a list of “signals,” and that captures the shift exactly: the question is no longer whether the renderer is SwiftShader, it is whether the renderer agrees with the screen, which agrees with the memory, which agrees with the clock.
The honest bottom line is that a browser describing its environment will tell the truth about that environment unless every layer is patched in lockstep, and the environment in a datacenter is structurally different from the environment in a living room. A real GPU costs money and does not virtualize cheaply. A real monitor has a resolution nobody would choose on purpose. A real machine has speakers and a webcam and three hundred fonts and a clock that drifts. The container has none of it, and the more of the absences you paper over, the closer your bill gets to just buying the hardware you were trying to avoid. That is the trap, and it is a durable one: the cheapest way to look like a real device is, increasingly, to be one.
Sources & further reading
- MDN Web Docs (2025), WEBGL_debug_renderer_info extension — reference for the UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL constants and their privacy caveats.
- Castle (2025), The role of WebGL renderer in browser fingerprinting — how renderer strings and SwiftShader/ANGLE output feed bot detection and consistency checks.
- deviceandbrowserinfo.com, Possible values of the WebGL renderer attribute — catalog of real ANGLE renderer strings across Intel, NVIDIA, AMD, Apple, and software fallbacks.
- bannedit (2019), Virtual Machine Detection In The Browser — early write-up combining WebGL strings, deviceMemory, and parallel-worker timing into a VM confidence score.
- Castle (2025), Deep dive: how navigator.deviceMemory can be used for fingerprinting and bot detection — the power-of-two buckets, the 8 GB cap, and out-of-range values seen in production traffic.
- W3C (2025), Device Memory specification — the normative rounding-to-power-of-two and clamping rules behind navigator.deviceMemory.
- Chrome for Developers (2026), Screen configuration for automation and testing with Chrome Headless — the configurable virtual screen and —screen-info switch in Chrome 142.
- Chrome for Developers (2021), Aligning timers with cross-origin isolation restrictions — the 100-microsecond default clamp, the 5-microsecond isolated resolution, and the Spectre rationale.
- Nikolai Tschacher / incolumitas.com (2021), On High-Precision JavaScript Timers — measured clamp values, SharedArrayBuffer counters, and clock-interpolation that leaks OS/CPU.
- Antoine Vastel (2023), New headless Chrome has a near-perfect browser fingerprint — what —headless=new changed about the GPU and WebGL signals versus the old mode.
- abrahamjuliot, CreepJS — open-source fingerprinter built around cross-checking signals for “lies,” including renderer-versus-platform mismatches.
- Mesa documentation, LLVMpipe — the software OpenGL rasterizer behind the
llvmpiperenderer string on Linux VMs and containers.
Further reading
How anti-bot systems fingerprint the JavaScript runtime
A reference on the JS-runtime fingerprinting surface: error stack formats, Function.prototype.toString, feature and timing probes, property enumeration order, and the engine quirks that betray a patched or automated browser.
·21 min readHeadless Chrome detection: every tell from navigator.webdriver to missing codecs
A reference catalog of the signals that give headless Chrome away: the webdriver flag, empty plugin lists, the permissions contradiction, missing proprietary codecs, software WebGL renderers, and what --headless=new actually fixed.
·21 min readThe HeadlessChrome user-agent token and the long tail of headless signals
Traces the HeadlessChrome user-agent token from its 2017 origin through the 2023 --headless=new rewrite, and the second-order tells that survive a clean UA: permissions inconsistencies, software WebGL, missing codecs, and CDP side effects.
·20 min read