~ / insights / analysis

Analysis · September 2026

Your DevTools Mobile View Is Lying to You.

A demo page that serves three different bodies to a desktop browser, a real phone and Chrome emulation shows why a mobile audit done in DevTools is an audit of a page nobody receives.

Amit TiwariAnalysis13 min read

Most of the mobile audits I get sent, and most of the ones I used to produce myself, were done by pressing Ctrl+Shift+M in Chrome, picking a Pixel from the dropdown and reading whatever loaded. To show a batch of students why that is not enough I built a page on a demo site I keep for my videos, a plain WordPress install, with a single article about cat breeds. The page serves three different bodies. A desktop browser gets twelve breeds with a comparison table. A real Android phone gets a shorter list, no table and smaller images. Chrome DevTools in device emulation, the thing every audit uses, gets a third version that looks mobile but carries a different heading, no structured data and two fewer internal links. Nobody who matters ever receives the third version, because Googlebot does not send emulation’s headers and no customer owns the device it claims to be.

I copied the idea from a large Hindi news publisher whose m-dot setup I went through for a pitch. Their redirect to the m-dot host fires on some mobile signals and not others, so a DevTools session, a real phone and Googlebot smartphone did not all land on the same body, and whoever had been auditing that site in emulation had been signing off pages in a form no reader and no crawler had seen. [VERIFY: re-check the publisher’s current redirect behaviour before this goes live.] My demo is a deliberately clean version of the same fault.

Three requests for one URL

How the demo page serves three different bodies desktop real phone emulated Request arrives Device detection (UA, hints, touch) Desktop body Mobile body Emulation body Most sites do not do this on purpose. Device-detection libraries and CDN rules do it by accident.
Figure 1. The demo’s serving logic, which mirrors what a large publisher’s m-dot setup does in production.

The URL is the same in all three cases. Everything that differs is in the request headers and in what the page can discover about the browser once it is running.

A desktop Chrome on Windows sends a User-Agent of Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/NNN.0.0.0 Safari/537.36, where NNN is the major version, along with three low-entropy Client Hints on every request. Sec-CH-UA lists the browser brands, Sec-CH-UA-Mobile is ?0 and Sec-CH-UA-Platform is "Windows". The viewport is whatever the window is, the device pixel ratio is 1 or 1.25 or 1.5 depending on display scaling, and navigator.maxTouchPoints is 0 on a laptop without a touchscreen.

A real Android phone running Chrome sends a reduced User-Agent. Since Chrome shipped User-Agent reduction in 2023 the string is Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/NNN.0.0.0 Mobile Safari/537.36 on every Android phone, whatever the model and whatever the Android version. [VERIFY: confirm the frozen platform token against the Chrome documentation.] The low-entropy hints say Sec-CH-UA-Mobile: ?1 and Sec-CH-UA-Platform: "Android". If the server answers with an Accept-CH header asking for more, the next request from that phone carries Sec-CH-UA-Model with the real model name and Sec-CH-UA-Platform-Version with the real Android version. The pixel ratio is a hardware number such as 2.625 on a recent Pixel, and navigator.maxTouchPoints is 5 or more.

Chrome DevTools in device mode is a desktop Chrome pretending. It has two ways of pretending, and neither matches the second request above. In Responsive mode with the device type set to Mobile, Chrome substitutes a generic Android string that names a Nexus 5 on Android 6.0 [VERIFY: check the current DevTools source for the exact string]. When you pick a named preset such as a Pixel 7, Chrome uses the string stored in its preset list, which reads Android 13; Pixel 7, a form no real Chrome has sent since reduction shipped. The Client Hints come from the same preset rather than from hardware, so the model hint and the model in the User-Agent string agree with each other in a way that a real phone’s never do. Touch events are synthesised from the mouse. The pixel ratio is whatever the preset or the DPR box says. And the whole request leaves from your laptop’s IP address over your office broadband, which matters for any CDN that picks an edge or a rule by network.

How the server tells them apart

What each requester tells the server Desktop Chrome Real Android Chrome DevTools emulation User-Agent string Desktop Mobile Preset, matches no device Sec-CH-UA-Mobile header ?0 ?1 ?1 emulated Sec-CH-UA-Platform Windows or macOS Android Android claimed Touch support Device pixel ratio 1 to 2 2 to 4 Applied to render only Vendor headers A server or script branching on any row sends emulation a page neither a real phone nor a crawler receives.
Figure 2. The inputs a device-detection branch actually receives. The emulation column is a costume.

Interactive: pick a requester and see the signals it sends. Needs JavaScript.

The demo runs as a must-use plugin of about thirty lines. It asks Chrome for the model hint, then reads the User-Agent for the one contradiction that reduction created. A reduced Chrome always reports its version as NNN.0.0.0 and always reports the platform as Android 10; K. Emulation inherits the desktop browser’s reduced version number but keeps a preset platform token, so a request that has the reduced version pattern and does not have the frozen platform token is either emulation or a very old build. Googlebot smartphone, which reports a full Chrome version, falls through to the ordinary mobile branch, which is exactly where I want it.

<?php
// wp-content/mu-plugins/three-bodies.php
add_action('send_headers', function () {
    header('Accept-CH: Sec-CH-UA-Model, Sec-CH-UA-Platform-Version');
    header('Vary: User-Agent, Sec-CH-UA-Mobile, Sec-CH-UA-Model');
});

function tb_requester() {
    $ua     = $_SERVER['HTTP_USER_AGENT'] ?? '';
    $mobile = ($_SERVER['HTTP_SEC_CH_UA_MOBILE'] ?? '') === '?1'
              || strpos($ua, 'Mobile') !== false;
    $reduced = (bool) preg_match('#Chrome/\d+\.0\.0\.0 Mobile#', $ua);
    $frozen  = strpos($ua, 'Android 10; K') !== false;
    if ($mobile && $reduced && !$frozen) {
        return 'emulated';
    }
    return $mobile ? 'mobile' : 'desktop';
}

add_filter('the_content', function ($content) {
    if (!is_single() || get_post_field('post_name') !== 'cat-breeds') {
        return $content;
    }
    $alt = get_post_meta(get_the_ID(), 'body_' . tb_requester(), true);
    return $alt !== '' ? $alt : $content;
});

The Accept-CH line only works over HTTPS, and the model hint arrives on the second request rather than the first, so the demo also logs both. The Vary line is what Google’s dynamic serving documentation asks for, and it is also what tells a cache in front of the site to keep the three bodies apart rather than serving whichever one was fetched first.

The signals a production site might read fall into five groups, and every one of them separates emulation from a real phone if you look closely enough.

The User-Agent string is the oldest signal and still the most common. Emulation changes it, but to a stored string rather than to what the phone in your pocket sends, and since reduction the two have drifted apart in a way that any regular expression written against model names will notice.

Client Hints are the replacement Google built for the User-Agent. The low-entropy ones travel on every request from Chrome, and the high-entropy ones arrive after the server asks. In emulation with a preset they come from the preset. If you type a custom User-Agent into the Network conditions panel and leave the Client Hints form empty, the hints either stay the laptop’s or stop being sent, and in both cases the server sees a mobile User-Agent that contradicts its own hints. [VERIFY: paste the echo output from the demo for the custom-UA case.] WordPress core itself reads Sec-CH-UA-Mobile inside wp_is_mobile() before falling back to substring matching on the User-Agent [VERIFY: confirm the version that added the hint check], so every plugin and theme that calls that function is already branching on hints without its author knowing.

Viewport width and device pixel ratio are client-side signals rather than headers, but a script can post them back and a server can set a cookie on them, and image CDNs read Sec-CH-DPR and Sec-CH-Viewport-Width when the site opts in. Emulation lets you type any number into the DPR box, and a preset carries a rounded value that may not equal what the real device reports.

Touch support is the fourth. navigator.maxTouchPoints, 'ontouchstart' in window and the pointer: coarse media query are the checks most device libraries and most front-end frameworks use. Emulation sets them, but to values chosen by the emulator [VERIFY: record maxTouchPoints in emulation versus on the phone], and a site that keys its layout on the exact number will draw a different page.

The absence of headers is the fifth and the one nobody audits. Requests from inside an Android app’s WebView carry X-Requested-With with the app’s package name, which is how a lot of Indian traffic from Instagram, Facebook and news apps announces itself. Some carrier networks add headers of their own at the proxy. A request from your laptop has none of these, and a site that treats their absence as a desktop signal will hand emulation the desktop body while the phone gets the mobile one.

Where m-dot and dynamic serving branch

Dynamic serving keeps one URL and changes the body. In WordPress that usually means a theme or plugin calling wp_is_mobile(), and at the edge it means a Cloudflare rule, a worker, or an nginx map block matching the User-Agent. Google’s guidance for this setup is one line long, send Vary: User-Agent, and most sites I look at do not send it, so their CDN happily caches the first body it sees and serves it to everyone until the cache expires.

An m-dot setup moves the branch earlier. A redirect at the edge tests the request against a device library, and if the library says mobile the visitor is sent to m.example.com, which has its own templates, its own scripts and its own internal link graph. The two hosts are supposed to be tied together with rel="alternate" on the desktop page and rel="canonical" on the mobile one, and every page on both hosts is supposed to keep the pair in step. That is the setup the Hindi publisher runs, and its weakness is the library. A library from the WURFL era classifies by model name, and a reduced User-Agent has no model name, so it can send a real phone to the desktop host while an emulated Pixel 7, which still announces a model, is redirected to m-dot. The auditor sees the mobile site working. The reader on a real phone never reaches it. [VERIFY: confirm which direction the publisher’s library fails in.]

The three outputs side by side

The demo page uses the same theme for all three bodies so the difference is in content rather than layout. Below is what each body contains, with the exact text to be pasted from the live demo.

Element Desktop body Real phone body Emulation body
H1 [VERIFY: paste from the demo] [VERIFY: paste from the demo] [VERIFY: paste from the demo]
Breeds listed 12 8 8
Comparison table Present Removed Removed
JSON-LD Article block Present Present Missing
Internal links in body [VERIFY: count] [VERIFY: count] Two fewer than the phone body
Meta robots index, follow index, follow noindex
Image alt text Full Full Empty on two images
Vary header Sent Sent Sent

The desktop and phone bodies differ in ways I chose to be defensible, since a shorter list and smaller images on a phone is ordinary responsive practice. The emulation body differs in ways that would fail a real audit, and an auditor working in DevTools would file those failures against a page that Googlebot and readers never receive, while missing anything wrong with the page they do.

What Google flags under mobile-first indexing

Google finished moving every site to mobile-first indexing in July 2024, and since then it crawls and indexes with Googlebot smartphone alone [VERIFY: date against Google’s documentation]. The mobile-first best practices page lists what it wants the mobile body to keep in step with the desktop one, and each item on that list is something my emulation body breaks on purpose.

Content parity is the first. Text that appears on desktop and not on mobile is not indexed, because the desktop body is no longer fetched. Structured data is the second, and the documentation asks for the same structured data on both, with the same URLs inside it. A JSON-LD block that appears only on desktop is a rich result that no longer exists. Meta tags are the third, and the same title, description and robots directives are expected on both, so a noindex that only fires on one branch is a page that quietly leaves the index for everyone. Internal links and image alt text round it out, and an image without alt on the mobile body has no alt as far as Google Images is concerned.
The failure is symmetrical, which is the part that costs money. If your mobile body is missing structured data and your emulation body has it, the audit passes a page that is failing. If your emulation body is missing it and your mobile body has it, a developer spends a day fixing a bug that does not exist. Either way the audit was of a page nobody receives.

The correct way to test

The correct way to test 1 Real Android device, Chrome, USB remote debugging: the human ground truth 2 Search Console URL Inspection, smartphone crawler: Google's ground truth 3 curl with the Googlebot smartphone user agent: what the server sends a crawler before JavaScript 4 Compare all three. Emulation is for layout work, never for the audit.
Figure 3. Three views that are real, and one that is not.

Three checks catch the demo, and the same three catch the accidental version of it on a real site.

The first is a real phone on a cable. Enable USB debugging on an Android device, open chrome://inspect/#devices on the laptop, load the page on the phone and press Inspect. The DevTools window that opens is attached to the phone’s Chrome, so document.documentElement.outerHTML in that console is the DOM the phone built from the body the phone was sent, with the phone’s User-Agent, the phone’s hints and the phone’s network. Run navigator.userAgent, navigator.userAgentData.mobile, navigator.maxTouchPoints and window.devicePixelRatio in the same console and write the answers down, because they are the ground truth that emulation is supposed to be imitating.

The second is URL Inspection in Search Console with a live test. The tool fetches the page as Googlebot smartphone, from Google’s own IP ranges, and shows the HTML and a screenshot under View tested page. This is the only one of the three that also catches a site which checks Googlebot’s IP range and treats everything else as a spoof.

The third is a fetch with the Googlebot smartphone User-Agent string. Google’s crawler documentation prints it as Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html), where W.X.Y.Z stands for whatever Chromium version Googlebot is running that week. [VERIFY: the exact string against the crawler documentation page before publishing.] A curl with that string and the page saved to disk gives you a file you can diff against the phone’s DOM and the Search Console HTML.

When the three agree with each other and disagree with emulation, ignore emulation. When the three disagree with one another, you have found dynamic serving that is branching on something you did not know about, and the fix is on the server rather than in the audit. I wrote the step-by-step version of these checks, with the commands, as a separate guide.

Where I could be wrong

The demo is deliberately adversarial. I wrote a plugin whose only purpose is to detect emulation, and no site I have ever audited had code written with that intent. If the argument were “sites deliberately serve a special page to DevTools”, it would be wrong for almost every site in India.

The accidental version is what I am actually claiming, and it rests on device-detection libraries, edge rules and WordPress functions branching on signals that emulation gets wrong. That is well documented for the User-Agent string and for wp_is_mobile(), and less documented for Client Hints, where the behaviour of the DevTools custom User-Agent case is something I have observed on one Chrome version and should be re-tested on the current one before anyone relies on it.

The Chrome details drift. The frozen Android 10; K token, the preset strings in the emulated device list, and the value of maxTouchPoints under emulation are all things Chrome can change in a release, and the detection logic in the demo plugin would then classify differently. The three-way test at the end does not depend on any of those details, which is why it is the part of this piece I would keep if everything else turned out to be stale.

Sources

How to cite this analysis

Amit Tiwari (2026). Your DevTools Mobile View Is Lying to You. Analysis, September 2026. amittiwari.net. https://amittiwari.net/analysis/your-devtools-mobile-view-is-lying-to-you

Discuss in the community ↗