Skip to main content

· 3 min read

State machines for an autonomous performance agent: a beginner's guide to XState

How a small XState state machine gives an autonomous agent the structure it needs to measure, fix and re-check Lighthouse scores without going off the rails.

  • Performance
  • SEO
  • Automation

An autonomous agent that improves a website's Lighthouse scores has to do a lot of things in order: measure the page, decide what is worst, change something, then measure again. Left to a pile of if statements, that logic rots fast. A state machine keeps it honest. This is a beginner's look at using XState to give such an agent a spine.

Why a state machine

An agent is really a loop with rules about what may happen next. You should not run a fix before you have a measurement. You should not declare victory before you re-check. A state machine models exactly this: a set of named states, and transitions that are the only legal ways to move between them.

The payoff is that illegal moves become impossible by construction, not by discipline. The agent cannot "audit while fixing" because there is no transition that allows it. You also get a diagram for free — the machine is the flowchart — which matters when you need to explain to a client why the agent did what it did.

The core states

For a Lighthouse agent, five states cover most of the work:

  • idle — waiting for a trigger (a deploy, a schedule, a manual run).
  • auditing — running Lighthouse and collecting the scores.
  • diagnosing — picking the single highest-impact problem.
  • fixing — applying one change, such as compressing an image or deferring a script.
  • verifying — re-running the audit to confirm the change helped.

The agent moves idle → auditing → diagnosing → fixing → verifying, then either loops back to diagnosing for the next problem or returns to idle when the score is good enough.

A minimal machine

import { createMachine, assign } from 'xstate';

const perfAgent = createMachine({
  id: 'perfAgent',
  initial: 'idle',
  context: { score: 0, target: 90, attempts: 0 },
  states: {
    idle: {
      on: { RUN: 'auditing' },
    },
    auditing: {
      invoke: {
        src: 'runLighthouse',
        onDone: {
          target: 'diagnosing',
          actions: assign({ score: ({ event }) => event.output.score }),
        },
      },
    },
    diagnosing: {
      always: [
        { target: 'idle', guard: ({ context }) => context.score >= context.target },
        { target: 'fixing' },
      ],
    },
    fixing: {
      invoke: { src: 'applyBestFix', onDone: 'verifying' },
    },
    verifying: {
      invoke: { src: 'runLighthouse', onDone: 'diagnosing' },
    },
  },
});

Two ideas do the heavy lifting. Context is the machine's memory — the current score, the target, how many attempts you have made. Invoke runs an async job (an actual Lighthouse run, an actual code change) and waits for it to finish before transitioning. The always transition in diagnosing is a guard: if the score already clears the target, go home; otherwise, keep fixing.

Guards keep it safe

The guard is what makes the agent autonomous rather than reckless. Add a second guard on attempts so the machine gives up after, say, five rounds instead of chasing a target it cannot reach. This is how you stop a well-meaning agent from rewriting a page forever over one stubborn metric.

Where to go next

Start small: wire the five states, stub the Lighthouse and fix jobs with fake data, and watch the machine step through the loop in the Stately inspector. Only once the shape is right should you connect the real audit and the real changes. The state machine is the part worth getting correct first — the fixes are easier to trust once the control flow cannot cheat.

Written by base32.