Writing user flows

Basic user flows

The three Lighthouse measurement modes and how to combine navigation, timespan and snapshot steps.

A user flow is a small TypeScript module that describes interactions with a page and marks which parts of them Lighthouse should measure. Think of it as a front-end end-to-end test that records performance data while it runs.

Anatomy of a flow file#

user-flows/basic-navigation.uf.mts
import { UserFlowInteractionsFn, UserFlowContext, UserFlowProvider } from '@push-based/user-flow';

const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise<any> => {
  const { page, flow, browser, collectOptions } = ctx;
  const { url } = collectOptions;

  await flow.navigate(url, {
    name: 'Navigate to coffee cart',
  });

  // Select coffee

  // Checkout order

  // Submit order
};

export default {
  flowOptions: { name: 'Order Coffee' },
  interactions,
} satisfies UserFlowProvider;

The default export is a UserFlowProvider:

PropertyDescription
flowOptions.nameThe title of the report.
flowOptions.configAn optional Lighthouse config for this flow. See Lighthouse configuration.
interactionsAn async function that receives the context and drives the page.
launchOptionsOptional Puppeteer launch options, for example headless or args.

The UserFlowContext passed to interactions contains:

PropertyDescription
pageThe Puppeteer Page. Use it to click, type, wait for selectors and so on.
browserThe Puppeteer Browser, in case you need more than one page.
flowThe Lighthouse UserFlow. Every measurement step is started on it.
collectOptionsThe resolved collect options, most importantly url.

The three measurement modes#

Lighthouse offers three kinds of steps. Each covers a different question and supports a different set of audits.

ModeMeasuresPerformanceAccessibilityBest practicesSEOPWA
Navigation NavigationA page load100% / 30100% / 30100% / 30100% / 30✔ / 7
Timespan TimespanA user interaction over time10 / 10❌7 / 7❌❌
Snapshot SnapshotThe current state of the page4 / 416 / 165 / 59 / 9❌

The numbers are the audits each mode supports per category, with the share of the category score they cover.

  • Navigation is the classic Lighthouse run. Use it for cold and warm page loads.
  • Timespan records everything between startTimespan and endTimespan. It is the only mode that captures runtime work such as long tasks, layout shifts and network requests triggered by an interaction.
  • Snapshot audits the DOM as it is right now, without loading or interacting. Use it after an interaction to check accessibility, SEO and best practices of the new state.

The report shows the icon next to each step so you can tell the modes apart at a glance.

A report with navigation, timespan and snapshot steps

Choosing selectors#

Decouple test selectors from the selectors used for styling. A data attribute that exists only for tests, such as [data-test="lcp-img"], survives refactorings of markup and CSS. The following attributes are recognised by the DevTools Recorder as well, which keeps recordings and hand-written flows consistent:

data-testid, data-test, data-qa, data-cy, data-test-id, data-qa-id, data-testing

Walkthrough: order a coffee#

The following steps build a flow for the coffee cart demo that navigates to the page, selects a coffee, checks out and submits the order.

1. Start from the navigation#

Run npx user-flow init and accept the defaults. You get the rc file and the basic-navigation.uf.mts shown above. Run it once with npx user-flow to confirm everything works.

2. Add the interactions#

Fill the placeholder comments with Puppeteer calls:

user-flows/basic-navigation.uf.mts
const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise<any> => {
  const { page, flow, collectOptions } = ctx;

  await flow.navigate(collectOptions.url, {
    name: 'Navigate to coffee cart',
  });

  // Select coffee
  const cappuccinoItem = '[data-test=Cappucino]';
  await page.waitForSelector(cappuccinoItem);
  await page.click(cappuccinoItem);

  // Checkout order
  const checkoutBtn = '[data-test=checkout]';
  await page.waitForSelector(checkoutBtn);
  await page.click(checkoutBtn);

  const nameInputSelector = '#name';
  await page.waitForSelector(nameInputSelector);
  await page.type(nameInputSelector, 'nina');

  const emailInputSelector = '#email';
  await page.waitForSelector(emailInputSelector);
  await page.type(emailInputSelector, 'nina@gmail.com');

  // Submit order
  const submitBtn = '#submit-payment';
  await page.waitForSelector(submitBtn);
  await page.click(submitBtn);
  const successMsg = '.snackbar.success';
  await page.waitForSelector(successMsg);
};

3. Measure the interactions with timespans#

Wrap each interaction in startTimespan and endTimespan. Give every timespan a name; it becomes the step title in the report.

user-flows/basic-navigation.uf.mts
const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise<any> => {
  const { page, flow, collectOptions } = ctx;

  await flow.navigate(collectOptions.url, {
    name: 'Navigate to coffee cart',
  });

  await flow.startTimespan({ name: 'Select coffee' });
  // Select coffee ...
  await flow.endTimespan();

  await flow.startTimespan({ name: 'Checkout order' });
  // Checkout order ...
  await flow.endTimespan();

  await flow.startTimespan({ name: 'Submit order' });
  // Submit order ...
  await flow.endTimespan();
};

4. Audit the resulting states with snapshots#

After each timespan, take a snapshot of the page. Snapshots are cheap and add accessibility and SEO audits for the state the interaction produced.

user-flows/basic-navigation.uf.mts
const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise<any> => {
  // ...

  // Select coffee, wrapped in a timespan ...
  await flow.snapshot({ name: 'Coffee selected' });

  // Checkout order, wrapped in a timespan ...
  await flow.snapshot({ name: 'Order checked out' });

  // Submit order, wrapped in a timespan ...
  await flow.snapshot({ name: 'Order submitted' });
};

5. Run the real measurement#

Drop --dryRun and run the flow. The report now contains one navigation, three timespans and three snapshots:

npx user-flow collect

Keep flows maintainable#

Once a flow grows beyond a handful of selectors, move selectors and interactions into reusable objects. The UFO architecture page describes the pattern. If you would rather not write interaction code at all, replay a DevTools recording.