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
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
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
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
- Google Search Central, Mobile-first indexing best practices
- Google Search Central, Dynamic serving
- Google Search Central, Separate URLs
- Google Search Central, Google’s common crawlers and their user agent strings
- Google Search Console Help, URL Inspection Tool
- Chrome for Developers, User-Agent reduction
- Chrome for Developers, User-Agent Client Hints
- Chrome DevTools, Simulate mobile devices with Device Mode
- Chrome DevTools, Override the user agent string
- Chrome DevTools, Remote debug Android devices
- Chromium source, emulated device presets
- WordPress developer reference, wp_is_mobile()