Evomi

Blog / Scraping Techniques

Screen, Viewport and devicePixelRatio: The Geometry That Does Not Add Up

The ScraperThe Scraper7 min read
port view size checking

You have a Chromium fleet in containers: a current Chrome 152 User-Agent, the TLS fingerprint right, the timezone matching the exit region. Then you dump the geometry from one of your sessions:

YAML
screen.width        1920      window.outerWidth   1920
screen.height       1080      window.outerHeight  1080
screen.availWidth   1920      window.innerWidth   1920
screen.availHeight  1080      window.innerHeight  1080
window.screenX      0         devicePixelRatio    1
window.screenY      0         maxTouchPoints      0


Eight numbers, every one round and equal to another: the screen is exactly the window, the window is exactly the page, sitting at the origin of a display with no taskbar and no scaling.

No laptop looks like that, not because 1920x1080 is rare (it is the most common resolution there is) but because the gaps between these numbers are missing, and on a real machine they are always there.

What Each Number Actually Measures

All of these are in CSS pixels, not device pixels.

  • screen.width / screen.height are the whole display, and on a HiDPI machine the logical size: a panel 3024 physical pixels across, at a 2x scale factor, reports 1512.
  • screen.availWidth / screen.availHeight are what CSSOM-View calls "the available area of the rendering surface of the output device". The spec says nothing about what eats the difference; in practice a Windows taskbar, the macOS menu bar and pinned dock, or a Linux panel.
  • window.outerWidth / outerHeight are the whole browser window, chrome included: title bar, tab strip, omnibox, bookmarks bar, borders.
  • window.innerWidth / innerHeight are the viewport, and the spec is explicit that innerWidth returns it "including the size of a rendered scroll bar (if any)" — which clientWidth excludes.
  • window.screenX / screenY (also screenLeft / screenTop) are where the window's top-left corner sits relative to the screen area's origin; on multi-monitor setups they can be negative.
  • devicePixelRatio is device pixels per CSS pixel, moving with the display's scale factor and with page zoom — which is why it is so often a fraction.
  • window.visualViewport describes the visible part of the layout viewport — widthheightscaleoffsetLeftoffsetToppageLeftpageTop. Note the asymmetry: its width excludes a rendered classic scrollbar while innerWidth includes it, so the two differ by the scrollbar on Windows and Linux, not at all on macOS.

The Arithmetic That Has to Hold

These properties are not independent: a session violating one of the following is not reporting an unusual machine but an impossible one.

  1. availWidth <= width and availHeight <= height. The available area is a subset of the display.
  2. availHeight < height on essentially every real desktop. Something is always reserved; both axes exactly equal is the signature of a display with no window-manager UI.
  3. innerWidth <= outerWidth and innerHeight <= outerHeight. The page cannot exceed the window containing it.
  4. outerHeight - innerHeight > 0 for a windowed browser. That difference is the chrome; exactly zero means kiosk, fullscreen, or no chrome at all.
  5. innerWidth <= screen.availWidth normally. A wider viewport means the window is off-screen, or the numbers came from different sources.
  6. innerWidth - document.documentElement.clientWidth is the scrollbar width, and it lands in a small set: 0 where overlay scrollbars are the default (macOS, Android, ChromeOS), and 15 CSS pixels on Windows and Linux, where Chromium's Fluent scrollbars are non-overlay by default. The 17 often quoted is the Windows native UI metric, not the web-content scrollbar.
  7. screen.width * devicePixelRatio should be a whole number, ideally a panel resolution that exists. 1512 x 2 = 3024. 1536 x 1.25 = 1920.
  8. screenX === 0 && screenY === 0 with outerHeight !== availHeight. A window at the exact origin that is not maximised: nobody puts windows there by hand, and a maximised macOS window starts below the menu bar.
table of contents!


Where a Default Launch Collapses the Gaps

A headless browser has no window manager: no taskbar to subtract, no title bar to draw. Chromium's headless default window is 800x600, and in headless that size is the screen size. Pass --window-size=1920,1080 and you get a render surface with no chrome, so outerHeight and innerHeight are both 1080 (invariant 4 breaks), availHeight has nothing to subtract (invariant 2), and nothing positioned the window (invariant 8). That is the trap in --window-size arithmetic: on a real 1920x1080 laptop the viewport is never 1080 tall — the taskbar takes some, the chrome takes more, leaving something in the 900s.

Playwright's defaults do part of the job. Its Chromium contexts get a 1280x720 viewport at a scale factor of 1, applied through a device-metrics override rather than by resizing a window. With no screen set, Playwright passes the viewport as the screen size, and Blink's emulator sets the emulated screen's available rect equal to its full rect — so a bare new_context() reports screen.width === screen.availWidth === 1280 and screen.height === screen.availHeight === 720, invariant 2 failing on both axes by construction. Hence the separate screen option, which emulates a consistent window screen size inside the page, and only when a viewport is set.

devicePixelRatio in the Real World

devicePixelRatio of exactly 1 is not impossible: a 1080p monitor at 100% scaling gives it. What is odd is a fleet where it is always 1 next to a screen size that implies a laptop panel.

The values in the wild cluster: 1 on unscaled 1080p and older desktops; 1.25 and 1.5 from Windows display scaling, where Chromium divides the reported DPI by a 96-DPI base (120 DPI is 125% is 1.25, 144 DPI is 150% is 1.5); 2 on macOS Retina and 200% Windows; and 2 to 3-and-change, often not integral, on phones. Page zoom folds in on top — pinch-zoom does not — so a real population has a long tail of odd values.

The same number reaches CSS as the resolution media feature, whose canonical unit dppx is fixed at 1dppx = 96dpi, so matchMedia("(resolution: 2dppx)") and devicePixelRatio === 2 had better agree.

The Media Query Surface Next Door

CSS media features, queryable through matchMedia(), describe the same machine from another angle:

  • pointer describes the primary input mechanism: nonecoarse or fineany-pointer, per the spec, "corresponds to the union of capabilities of all the pointing devices available to the user", and more than one value can match: a touchscreen laptop matches both any-pointer: fine and any-pointer: coarse while pointer: fine stays true for the mouse.
  • hover and any-hover do the same for hover: whether the primary mechanism can hover, versus whether any can.
  • prefers-color-scheme and prefers-reduced-motion are OS-level user settings with genuinely mixed populations.

The contradiction to watch for is a mobile User-Agent over desktop pointer capabilities: a UA claiming an iPhone should bring pointer: coarsehover: noneany-hover: none and a non-zero navigator.maxTouchPoints, smartphones typically report 5, desktops 0. pointer: fine with maxTouchPoints of 0 is a phone with a mouse and no touchscreen.

These are capability signals, and only touch emulation moves them: it flips the available and primary pointer types to coarse, the hover types to none, and maxTouchPoints off zero in one call. A mobile viewport flag touches none of that, so is_mobile=True without has_touch=True gives a phone-shaped viewport still claiming a fine, hoverable pointer. And has_touch=True alone sets maxTouchPoints to 1 — the CDP default when no count is supplied, a value almost no real device reports.

A Geometry Self-Check You Can Run

This dumps the whole set and flags relationships that cannot be true:

Shell
import json

from playwright.sync_api import sync_playwright

PROBE = """() => {
  const de = document.documentElement;
  const q = (s) => matchMedia(s).matches;
  const g = {
    screen_width: screen.width,
    screen_height: screen.height,
    screen_avail_width: screen.availWidth,
    screen_avail_height: screen.availHeight,
    screen_color_depth: screen.colorDepth,
    screen_pixel_depth: screen.pixelDepth,
    outer_width: window.outerWidth,
    outer_height: window.outerHeight,
    inner_width: window.innerWidth,
    inner_height: window.innerHeight,
    client_width: de.clientWidth,
    client_height: de.clientHeight,
    screen_x: window.screenX,
    screen_y: window.screenY,
    device_pixel_ratio: window.devicePixelRatio,
    max_touch_points: navigator.maxTouchPoints,
    visual_viewport: window.visualViewport ? {
      width: visualViewport.width,
      height: visualViewport.height,
      scale: visualViewport.scale,
      offset_left: visualViewport.offsetLeft,
      offset_top: visualViewport.offsetTop,
      page_left: visualViewport.pageLeft,
      page_top: visualViewport.pageTop,
    } : null,
  };
  const media = {};
  for (const s of ["(prefers-color-scheme: dark)", "(prefers-reduced-motion: reduce)",
                   "(pointer: fine)", "(pointer: coarse)", "(pointer: none)",
                   "(any-pointer: fine)", "(any-pointer: coarse)",
                   "(hover: hover)", "(any-hover: hover)",
                   "(forced-colors: active)"]) media[s] = q(s);

  const scrollbar_width = g.inner_width - g.client_width;
  const chrome_height = g.outer_height - g.inner_height;
  const mobile_ua = /Android|iPhone|iPad|iPod/.test(navigator.userAgent);
  const flags = [];

  if (g.screen_avail_width > g.screen_width || g.screen_avail_height > g.screen_height)
    flags.push("available screen area exceeds the screen");
  if (g.screen_avail_width === g.screen_width && g.screen_avail_height === g.screen_height)
    flags.push("no OS chrome reserved on either axis");
  if (g.inner_width > g.outer_width || g.inner_height > g.outer_height)
    flags.push("viewport larger than the window containing it");
  if (chrome_height <= 0)
    flags.push("browser window has zero chrome height");
  if (g.inner_width > g.screen_width || g.inner_height > g.screen_height)
    flags.push("viewport larger than the screen");
  if (g.screen_x === 0 && g.screen_y === 0 && g.outer_height !== g.screen_avail_height)
    flags.push("window at exact origin but not maximised");
  if (!Number.isInteger(g.screen_width * g.device_pixel_ratio))
    flags.push("screen width x devicePixelRatio is not a whole pixel count");
  if (q("(pointer: coarse)") && g.max_touch_points === 0)
    flags.push("coarse primary pointer with zero touch points");
  if (mobile_ua && q("(pointer: fine)"))
    flags.push("mobile user agent reporting a fine pointer");
  if (mobile_ua && q("(any-hover: hover)"))
    flags.push("mobile user agent reporting hover capability");
  if (window.visualViewport && visualViewport.scale === 1
      && Math.abs(visualViewport.width - g.client_width) > 2)
    flags.push("visual viewport width disagrees with the layout viewport at scale 1");

  return { geometry: g, media, derived: { scrollbar_width, chrome_height }, flags };
}"""

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context()
    page = context.new_page()
    page.goto("https://example.com", wait_until="domcontentloaded")
    print(json.dumps(page.evaluate(PROBE), indent=2))
    browser.close()

Run it against a bare new_context() and you will get several flags; against your own browser, none.

Making the Numbers Consistent

Derive the viewport from the screen instead of picking both independently: choose a plausible display, subtract OS and browser chrome, and set the scale factor that display would have.

Shell
# A Windows laptop: 1920x1080 panel at 125% scaling.
# Logical screen: 1920 / 1.25 = 1536 x 864.
SCREEN = {"width": 1536, "height": 864}
TASKBAR = 40          # bottom taskbar, logical px
BROWSER_CHROME = 88   # tab strip + omnibox, logical px

UA = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
)


def open_desktop_context(browser):
    return browser.new_context(
        screen=SCREEN,
        viewport={
            "width": SCREEN["width"],
            "height": SCREEN["height"] - TASKBAR - BROWSER_CHROME,
        },
        device_scale_factor=1.25,
        color_scheme="light",
        reduced_motion="no-preference",
        is_mobile=False,
        has_touch=False,
        user_agent=UA,
    )



The derivation matters more than the numbers: 736 is what is left of 864 after a taskbar and browser chrome, a shape a real machine produces.

Two honest caveats. The screen option emulates window.screen but does not invent a taskbar, so availWidth/availHeight still track the screen you gave it: invariant 2 is not something a context option fixes. And a device-metrics viewport has no real window chrome, so outerHeight === innerHeight stays true in headless. Those are limits of the tool, not things to paper over with injected getters — which turn one contradiction into two.

Mistakes That Waste Time

  • Randomising the screen size per session. Never-shipped resolutions are more distinctive than common ones, and they break the arithmetic too.
  • Setting viewport and screen to the same value. The 1080-on-1080 case: it removes the two gaps that carry the most information.
  • Patching devicePixelRatio instead of setting the scale factor. The scale factor drives the real render pipeline; a patched getter leaves resolution queries and image selection on the old value.
  • Copying a device descriptor and then changing the UA. Its geometry, touch flags and scale factor were chosen to match that UA.

Plausibility, Not Entropy

You are not trying to be unique or maximally random but ordinary: numbers a real, common machine produces, where every derived value follows from the others. There are only a few dozen genuinely common display configurations, and the arithmetic does the rest.

And if a site has asked you not to crawl it, none of this answers that question. An official API, a licence or identifying yourself does.

Wrapping Up

Two changes cover most of it. Derive the viewport from a screen size you chose deliberately, subtracting OS and browser chrome rather than picking round numbers for both, and set the device scale factor to the value that display would really have, not 1.

Then run the probe: every flag it raises is two numbers your configuration got wrong, each a few lines to fix.