I’m running a Lighthouse user-flow using Puppeteer to measure performance on our web app. When I execute my script with just Puppeteer, the app loads in desktop view exactly as expected.
But whenever I call Lighthouse functions like:
await flow.navigate(...);
await flow.startTimespan({ stepName: 'Open Organization (List) - Timespan' });
…the app suddenly switches to mobile layout, even though:
The browser window size remains 1920×1080
I explicitly configure Lighthouse for desktop emulation
My Setup
Node.js v24.0.0
Puppeteer (latest)
Lighthouse (via import { startFlow } from 'lighthouse')
import puppeteer from 'puppeteer';
import { startFlow } from 'lighthouse';
const DESKTOP_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36';
export default async function runFlow({ baseUrl, creds }) {
const browser = await puppeteer.launch({ headless: false });
const page = await browser.newPage();
await page.setUserAgent(DESKTOP_UA);
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false });
const flow = await startFlow(page, {
name: 'Velaris: Login → Cockpit → Organization',
configContext: {
settingsOverrides: {
formFactor: 'desktop',
screenEmulation: {
mobile: false,
width: 1920,
height: 1080,
deviceScaleFactor: 1,
disabled: false
},
emulatedUserAgent: DESKTOP_UA
}
}
});
await flow.navigate(`${baseUrl}/login`, { stepName: 'Login' });
await flow.startTimespan({ stepName: 'Open Organization' });
// ... rest of flow
}
What I Tried
Explicitly setting formFactor: 'desktop'
Forcing screenEmulation.mobile = false
Setting a fixed desktop user-agent string (DESKTOP_UA)
Setting viewport manually before binding Lighthouse
Still, as soon as Lighthouse flow functions run, the site loads in mobile view
After struggling with chatgpt for hours, I was able to fix the issue. So, I generated a meaningful answer here so anyone can refer it later.
Problem
Even after setting a desktop UA and viewport in Puppeteer, Lighthouse User Flows kept flipping to mobile emulation as soon as a LH step ran. Passing:
configContext: { settingsOverrides: { formFactor: 'desktop', screenEmulation: {...} } }
didn’t help because a mobile preset/config elsewhere (or version differences) overrode those overrides.
A mobile preset/config (e.g., via a JSON profile or configPath
) can override settingsOverrides
.
Different LH versions look at both formFactor
and emulatedFormFactor
.
Per-step config can reapply mobile unless you set your config on every step.
Don’t inherit anything. Give Lighthouse a full config that explicitly forces desktop.
Pass that full config
to startFlow
and every step (navigate
, snapshot
, startTimespan
, endTimespan
).
Align Puppeteer (UA + viewport + window size) to match the config so recordings don’t “flip”.
configs/lh.desktop.json
{
"extends": "lighthouse:default",
"settings": {
"preset": "desktop",
"formFactor": "desktop",
"emulatedFormFactor": "desktop",
"screenEmulation": {
"mobile": false,
"width": 1920,
"height": 1080,
"deviceScaleFactor": 1,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"throttlingMethod": "devtools",
"throttling": {
"rttMs": 40,
"throughputKbps": 10240,
"cpuSlowdownMultiplier": 1,
"requestLatencyMs": 0,
"downloadThroughputKbps": 0,
"uploadThroughputKbps": 0
}
}
}
// flows/my.flow.js
import fs from 'fs';
import path from 'path';
import puppeteer from 'puppeteer';
import { fileURLToPath } from 'url';
import { startFlow } from 'lighthouse';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const selectors = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'selectors', 'velaris.json'), 'utf8')
);
async function waitVisible(page, sel, timeout = 20000) {
await page.waitForSelector(sel, { visible: true, timeout });
}
function loadConfig(configPath) {
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (!cfg?.settings?.screenEmulation) {
throw new Error(`Invalid LH config: missing settings.screenEmulation in ${configPath}`);
}
return cfg;
}
// Usage: runFlow({ baseUrl, creds, configPath: 'configs/lh.desktop.json' })
export default async function runFlow({ baseUrl, creds, configPath }) {
if (!baseUrl) throw new Error('baseUrl is required');
if (!creds?.user || !creds?.pass) throw new Error('creds.user and creds.pass are required');
if (!configPath) throw new Error('configPath is required');
const cfg = loadConfig(configPath);
const se = cfg.settings.screenEmulation;
const ua = cfg.settings.emulatedUserAgent;
const browser = await puppeteer.launch({
headless: false,
args: ['--no-sandbox', '--disable-dev-shm-usage', `--window-size=${se.width},${se.height}`]
});
try {
const page = await browser.newPage();
// Keep the visible browser consistent with the LH config
await page.setUserAgent(ua);
await page.setViewport({
width: se.width,
height: se.height,
deviceScaleFactor: se.deviceScaleFactor ?? 1,
isMobile: !!se.mobile
});
// Bind LH with a FULL config (not settingsOverrides)
const flow = await startFlow(page, {
name: 'Velaris: Login → Cockpit (snapshot) → Organization (timespan)',
config: cfg
});
// 1) LOGIN (Navigation) — pass the same config on EVERY step
await flow.navigate(`${baseUrl}/login`, { stepName: 'Open Login', config: cfg });
await waitVisible(page, selectors.loginEmail ?? "input[name='email']");
await page.type(selectors.loginEmail ?? "input[name='email']", creds.user, { delay: 20 });
await page.type(selectors.loginPassword ?? "input[name='password']", creds.pass, { delay: 20 });
await Promise.all([
page.click(selectors.loginButton ?? "button[type='submit']"),
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 45000 })
]);
// (Optional) workspace selection…
// 2) COCKPIT (navigate + snapshot)
await Promise.all([
page.click(selectors.cockpitNav ?? "[data-testid='nav-cockpit']"),
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 45000 })
]);
await waitVisible(page, selectors.cockpitReady ?? "main.cockpit, [data-testid='cockpit-root']");
await flow.snapshot({ stepName: 'Snapshot: Cockpit', config: cfg });
// 3) ORGANIZATION (timespan)
await flow.startTimespan({ stepName: 'Open Organization (List) - Timespan', config: cfg });
await Promise.all([
page.click(selectors.organizationNav ?? "[data-testid='nav-organization']"),
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 45000 })
]);
await waitVisible(page, selectors.organizationReady ?? "[data-testid='organization-list'], [data-testid='accounts-list'], table");
await new Promise(r => setTimeout(r, 500));
await flow.endTimespan({ config: cfg });
const reportHtml = await flow.generateReport();
const reportJson = await flow.createFlowResult();
// …save reports
return { reportHtml, reportJson };
} finally {
await browser.close();
}
}
Using a full config
prevents external presets/configs from overriding your intent.
Setting both formFactor
and emulatedFormFactor
covers LH version differences.
Passing the same config on every step blocks later re-application of mobile emulation.
Matching Puppeteer’s UA/viewport keeps your screen recording consistent with what LH measures.
Tip: If you want multiple form-factors, create separate JSONs (e.g.,
lh.desktop.json
,lh.laptop.json
,lh.mobile.json
) and pass the desired file via CLI (--config configs/lh.desktop.json
).