How to fix Playwright mobile emulation issues when desktop layout still appears?
PlaywrightMobile emulation in Playwright fails to trigger the mobile layout when viewport and device settings are applied after context creation, when multiple config sources conflict, or when the application's responsive logic doesn't respond to viewport size alone. The most common symptom is a test that sets mobile device settings but still receives the desktop layout — usually because a per-test test.use() override conflicts with a project-level device, or because the application checks window.navigator.userAgent in addition to viewport width.
Common mistake
test('shows mobile navigation', async ({ page }) => {
// Too late — viewport is set after context creation
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('https://app.example.com');
// App saw original viewport on first load — layout is already desktop
await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
});
Also broken when projects have conflicting settings:
// playwright.config.ts defines desktop viewport...
use: { viewport: { width: 1280, height: 720 } },
// ...but individual test tries to override
test.use({ viewport: { width: 375, height: 812 } }); // May not take effect
The fix
Configure mobile emulation at the project level using Playwright's device descriptors, which set viewport, user agent, deviceScaleFactor, and touch events together:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 13'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
});
For a test that must override device settings inline, do it before navigation:
import { test, expect, devices } from '@playwright/test';
test.use({ ...devices['iPhone 13'] });
test('mobile nav is visible', async ({ page }) => {
// Navigate after device settings are applied via test.use
await page.goto('https://app.example.com');
await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
await expect(page.getByRole('navigation')).toBeHidden(); // Full nav hidden on mobile
});
If the app uses user-agent sniffing in addition to viewport:
test.use({
...devices['iPhone 13'],
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
});
Why it works
Playwright's devices descriptors bundle viewport dimensions, device pixel ratio, user agent string, and isMobile/hasTouch flags into a single profile. When applied at context creation, all these values are set before the first navigation — the browser's viewport is already mobile-sized when the initial page load fires, so CSS media queries using max-width resolve correctly. page.setViewportSize() called after page.goto() only resizes the viewport; it doesn't re-trigger the initial HTML parse or set the user agent, which is why the layout change may not apply.
Tips
- Use await page.evaluate(() => window.innerWidth) to verify the actual viewport width seen by the page's JavaScript — useful when debugging why responsive conditions aren't triggering.
- If the application has separate mobile and desktop build bundles served via user agent detection, you need to set userAgent to match the mobile bundle's detection string, not just the viewport.
- Some React and Vue apps use server-side rendering with a viewport hint from request headers — Playwright's emulation only affects the browser side; SSR viewport hints require custom request headers.
- For CI visual regression tests on mobile layouts, fix the deviceScaleFactor in the device config to ensure pixel-perfect screenshots match between environments.