an AI complete a web-performance optimization project on its own?
It can do much of the work, but the effective workflow is not to tell Codex “optimize this site” and blindly accept every edit it proposes. A better division of labor is to let the developer own the goal, constraints, and final decisions, while Codex handles the repetitive, context-heavy work: running Lighthouse, inspecting DevTools, tracing data through the source code, testing hypotheses, and running the test suite. Codex brings the evidence back; the developer decides what it means.
This article follows that process through a real Next.js fruit puzzle game. The first mobile Lighthouse run in Chrome DevTools scored 76. A saved rerun under the same general setup scored 79. After one round of diagnosis and targeted changes with Codex, the local production build reached 98. Total Blocking Time (TBT) fell from 828.5 ms to 49 ms.
The interesting part is not simply which lines changed. The case study answers a more useful set of questions:
- How should a performance problem be described to Codex?
- How do you make it investigate before it starts editing?
- How can a developer tell whether an AI-found cause is actually the root cause?
- When is it safe to let Codex implement the plan?
- How do you improve a Lighthouse score without damaging the experience of real users?
1. Start by dividing responsibility
Codex is particularly useful for work such as:
- running Lighthouse repeatedly and saving machine-readable JSON reports;
- finding long tasks in a Performance trace;
- checking the Console, Network panel, DOM, and response headers;
- tracing the origin of a value across the repository;
- writing a small benchmark for a suspicious function;
- implementing a narrowly defined code change; and
- running builds, unit tests, and end-to-end tests and summarizing the results.
The developer still owns three decisions:
- Define the product behavior that must not change. In this example, the board must still contain 170 cells, analytics events must not disappear, and mobile interactions must remain usable.
- Decide whether the evidence supports the conclusion. A Lighthouse recommendation is not automatically a root cause, and correlation is not causation.
- Set the optimization boundary. Should third-party analytics be delayed? Is a different caching strategy acceptable? Is another two points worth adding runtime or operational complexity?
Without this separation, an agent can produce a technically impressive score improvement that is not a valid product change.
2. First prompt: establish a baseline, and do not edit code
The first request should explicitly forbid code changes. Without a saved baseline, there is no reliable way to show that later work helped. Changing images, JavaScript, caching, and components all at once also makes the result impossible to attribute.
Here is the shape of the initial request used in this investigation:
Analyze this website's performance with Chrome DevTools and Lighthouse in mobile mode.
The current performance score is about 76. Do not modify the code yet. First:
1. Save a reproducible Lighthouse JSON baseline.
2. Record FCP, LCP, Speed Index, TBT, TTI, CLS, long tasks, and transfer size.
3. Check the Console for hydration warnings and runtime errors.
4. Inspect the Network panel for large resources, third-party scripts, and oversized images.
5. Connect browser observations to source code, but present conclusions as hypotheses for now.
6. For every conclusion, include the evidence, likely impact, and a way to verify it.
Do not treat a generic Lighthouse recommendation as the root cause.
Do not remove product functionality to improve the score.
The first saved rerun looked like this:
| Metric | Baseline |
|---|---|
| Performance | 79 |
| FCP | 1.66 s |
| LCP | 1.66 s |
| Speed Index | 2.82 s |
| TBT | 828.5 ms |
| TTI | 5.91 s |
| Max Potential FID | 421 ms |
| Server response | 663 ms |
| Total byte weight | 474 KiB |
| CLS | 0 |
The original 76 and the saved 79 do not contradict each other. A single Lighthouse run is sensitive to CPU scheduling, network emulation, and whether an edge worker or cache is cold or warm. At this stage, the shape of the metrics matters more than a three-point difference.
The strongest signal was the combination of a reasonably quick LCP (1.66 s) with very high TBT (828.5 ms) and a TTI close to six seconds. The screen appeared, but the main thread kept doing expensive JavaScript work. Compressing an image might reduce transfer size, but it could not explain more than 800 ms of main-thread blocking.
3. Second prompt: make Codex build an evidence chain
Once the TBT anomaly is visible, the next request should not be “optimize the JavaScript.” Ask where the blocking time came from, and keep the investigation read-only.
The baseline has LCP 1.66 s, TBT 828.5 ms, and TTI 5.91 s.
Continue investigating the main-thread blocking, but do not edit code yet.
Requirements:
1. List every long task over 50 ms in the Performance trace.
2. Connect each long task to a script, component initialization, or business function.
3. Check for Console errors in the same time window.
4. Compare the key DOM data in the server HTML with the data after hydration.
5. If a business function looks suspicious, benchmark it separately.
6. Summarize the result as observation -> evidence -> hypothesis -> verification.
If the evidence is incomplete, label the result as a hypothesis rather than a conclusion.
The trace revealed two consecutive React long tasks, approximately 421 ms and 403 ms. Together they accounted for most of the TBT. At the same time, the Console showed the shortened React error #418, which indicates that the server-rendered markup did not match the client's first render: a hydration mismatch.
That error alone is not proof that hydration caused all 800 ms of blocking. Three further checks were needed:
- Which content actually differed between the server and client?
- Was the difference large enough for React to abandon the existing DOM?
- Was the cost of rebuilding that UI comparable to the long tasks in the trace?
Codex read the board generated in the server HTML and the board present after hydration, then calculated a small checksum for each:
- server board:
63227 - client board:
69345
Both boards were valid 10×17 boards—170 cells—but they were not the same board. React could not reuse the server nodes and rebuilt the board subtree.
Codex then benchmarked the board generator in isolation. On an unrestricted local CPU, the median was about 82 ms, with the slowest sample around 121 ms. Lighthouse emulates a slower mobile CPU, so a cost in the 300–400 ms range is plausible. That was consistent with the two long tasks in the trace.
The evidence now formed a closed loop:
| Stage | Observation | What it tells us |
|---|---|---|
| Lighthouse | TBT 828.5 ms | The main thread is heavily blocked. |
| Performance trace | 421 ms and 403 ms long tasks | The blocking is concentrated in React's initial work. |
| Console | React #418 | The server and first client render disagree. |
| DOM data |
63227 ≠ 69345
|
The two sides really rendered different boards. |
| Source tracing | Server uses a cached previous-day seed; client uses today's seed | The data split has been located. |
| Function benchmark | 82–121 ms locally | The generator is large enough to become a mobile long task. |
This is a much more actionable explanation than “there are too many React components” or “the bundle is too large.” It also leads to an experiment that can disprove it: make the first board identical on both sides and see whether the mismatch and long tasks disappear.
4. The root cause was data ownership, not randomness
The game was intended to generate one stable daily board. The simplified implementation looked deterministic:
// server
const board = createBoard(seedFromToday())
return renderPage(board)
// client's first render
const [board] = useState(() => createBoard(seedFromToday()))
The same algorithm appears on both sides, so why would the results differ? The page was using a day-long server cache.
- The server HTML could come from a cache entry created yesterday, with yesterday's seed.
- Hydration ran now, so the client immediately calculated today's seed.
- When the date boundary and cache lifetime did not line up, the two renders produced different boards.
The subtle lesson is that sharing an algorithm does not mean sharing the input to the first render. Deterministic hydration requires the same data, not merely the same function. If the inputs come from the current clock, a random source, a time zone, or cache state, the result can diverge even when the code is identical.
The developer therefore has to decide who owns the initial data. In this case, the server became the sole owner: it generated the initial board and passed it to the client. The client stopped trying to infer the first screen for itself.
5. Third prompt: design the solution before implementing it
After the root cause is established, ask Codex for a layered plan. Make it separate the root-cause fix from the work that merely reduces the remaining cost.
We confirmed that the hydration mismatch comes from a cached server board and a
client board generated from the current date. React discards and rebuilds 170 cells.
Do not edit code yet. Design an optimization plan first.
1. Fix the split in first-screen data ownership before adding memoization.
2. Separate root-cause, rendering, resource, and third-party-script changes.
3. For each change, predict which Lighthouse metrics should move.
4. List product behavior that could change and the regression risks.
5. Give an implementation order and a verification method for each stage.
Constraints: keep the 170-cell board, daily-game behavior, analytics events,
and existing interactions.
The extra planning step is worthwhile because performance work usually crosses rendering, caching, resources, and third-party code. If editing starts immediately, too many areas can change at once, making risk and causality difficult to evaluate.
The resulting plan had four layers:
- Consistent first render: generate one initial board on the server and pass it to the client as props.
- Stable runtime rendering: memoize the 170-cell board and reduce the countdown update frequency.
- Resource delivery: replace the oversized logo and serve AVIF screenshots at an appropriate size.
- Third-party work: queue analytics immediately, but defer the full GA SDK until the page is idle.
A useful prioritization question is: does this remove unnecessary work, or merely make unnecessary work a little faster? Unifying the first-screen data removes work and therefore comes first. memo is valuable, but it is a secondary optimization if the application is still rebuilding the wrong board.
6. Fourth prompt: implement in causal stages
Only after reviewing the plan should the developer authorize code changes. Each stage should be isolated so its effect can be tested.
Implement the approved plan in four stages.
Stage 1: fix first-screen data ownership and the hydration mismatch.
Stage 2: reduce repeated board rendering and timer updates.
Stage 3: optimize the logo and game images.
Stage 4: defer third-party analytics loading.
After every stage:
1. Run the relevant tests and a production build.
2. Check the browser Console for warnings and errors.
3. Explain the change, the expected metric impact, and the observed result.
4. Stop if a regression appears; do not continue to the next stage.
Do not change the game rules. Do not hide content or delete analytics to improve Lighthouse.
6.1 Fix the hydration data flow
The essential shape of the fix was:
// The server owns the first-screen data.
const initialSeed = PREVIEW_SEED
const initialBoard = createBoard(initialSeed)
return <Game initialSeed={initialSeed} initialBoard={initialBoard} />
// The client uses that data during hydration and does not generate it again.
function Game({ initialSeed, initialBoard }) {
const [board, setBoard] = useState(initialBoard)
const cache = useRef(new Map([[initialSeed, initialBoard]]))
// Later resets, daily synchronization, and board changes still follow
// the existing product rules.
}
Once the preview board was fixed, the first screen no longer needed to depend on a date-based ISR result. Static generation also removed the cache-creation date from the hydration equation.
6.2 Stop re-rendering the board for unrelated timer ticks
There is no reason for all 170 cells to render again whenever the countdown changes. The board became a memoized component, and the timer moved from 50 ms updates to 100 ms updates:
const Board = memo(function Board({ cells, selection }) {
return cells.map(renderCell)
})
setInterval(updateTimer, 100)
One hundred milliseconds is still smooth enough for a countdown display while cutting the number of timer updates in half.
6.3 Deliver images at their displayed size
The initial report found a 512×512 PNG logo rendered at roughly 56×56 pixels. About 26.3 KB was being transferred for pixels that could never be seen. A game screenshot weighed about 67 KB and could be reduced by another 14 KB.
The final version used a sub-1 KB vector logo and responsive image delivery:
<picture>
<source media="(max-width: 760px)" srcset="screenshot-384.avif" />
<img src="screenshot-640.avif" width="640" height="474" alt="Game screen" />
</picture>
The image-delivery audit no longer reported an avoidable-size warning.
6.4 Move the full analytics SDK off the critical path
The initial GA transfer was about 166.9 KB, and its main-thread execution cost was about 155 ms. The requirement to record the first page view does not necessarily mean that the entire SDK must download and initialize before the first screen is usable.
// Create a queue immediately so the initial event is not lost.
window.dataLayer = window.dataLayer || []
queueInitialPageView()
// Download the full SDK once the page has settled.
setTimeout(() => {
requestIdleCallback(loadAnalytics, { timeout: 5000 })
}, 5000)
This is also a product decision. If ad attribution or experiment assignment must complete before the first interaction, analytics cannot simply be deferred. If events only need to arrive eventually, queuing them first and loading the SDK during idle time is a better trade-off.
7. Fifth prompt: prove that the optimization worked
Codex can run the checks, but the acceptance criteria should be stated up front. A score by itself is not enough.
Validate the optimization. Do not report only the Lighthouse score.
1. Run the production build, unit tests, and E2E tests.
2. Serve the production build and rerun mobile Lighthouse with the same settings.
3. Compare FCP, LCP, Speed Index, TBT, TTI, CLS, server response, and transfer size.
4. Check that no hydration warning or error remains in the Console.
5. Verify the 170-cell board, start/reset flow, countdown, and mobile layout.
6. Report regressions as well as improvements; do not cherry-pick favorable numbers.
7. Clearly separate local results from results measured after deployment.
The measured results were:
| Metric | Before (online rerun) | After (local production) | Change |
|---|---|---|---|
| Performance | 79 | 98 | +19 points |
| FCP | 1.66 s | 0.89 s | -46.3% |
| LCP | 1.66 s | 2.37 s | +42.7% |
| Speed Index | 2.82 s | 0.89 s | -68.4% |
| TBT | 828.5 ms | 49 ms | -94.1% |
| TTI | 5.91 s | 2.37 s | -59.9% |
| Max Potential FID | 421 ms | 99 ms | -76.5% |
| Server response | 663 ms | 108 ms | -83.7% |
| Total byte weight | 474 KiB | 185 KiB | -61.0% |
| CLS | 0 | 0 | unchanged |
The supporting checks passed as follows:
- production build completed successfully;
- all 9 game-engine unit tests passed;
- 25 E2E tests passed, with 3 skipped because of device-specific conditions;
- the page Console contained no warnings or errors; and
- all 170 board cells rendered correctly.
Why the 98 should not be treated as the whole story
This was not a strict A/B test. The before result came from the deployed site, while the after result came from a local production server. The improvements to TBT, transfer size, and hydration errors are well supported, but server response time is affected by Cloudflare Worker cold and warm states. The LCP values also cannot be compared as if every other variable were identical.
The increase in LCP from 1.66 s to 2.37 s should not be hidden to make the story look cleaner. The honest interpretation is:
- 2.37 s is still close to the “good” range;
- the different environments do not yet prove that LCP regressed;
- the deployed URL should be measured repeatedly with the same device and network settings; and
- if the production median remains near or above 2.5 s, the LCP element and resource-discovery path deserve a separate investigation.
8. How to judge whether Codex's performance analysis is trustworthy
Codex can search, run tools, and compare files quickly. Its conclusions still need review. These questions are a useful checklist.
Is this a recommendation or evidence?
“Compress the images” is a general recommendation. “A 512×512 image is displayed at 56×56, wasting roughly 26 KB” is evidence about this page.
Is there a complete link between the metric and the cause?
The investigation did not rewrite components merely because React #418 appeared. It confirmed that the boards differed, that React rebuilt 170 nodes, and that the board generator was expensive enough to match the trace.
Does the change remove work or hide it?
Preventing a second board generation during hydration removes work. Delaying content until after Lighthouse finishes only hides work and usually harms users.
Are the product invariants written down?
Constraints such as “keep 170 cells” and “do not lose analytics events” prevent an agent from achieving a better score by deleting behavior.
Are negative results reported honestly?
A credible report discusses the LCP increase, environment differences, and the risk of not having measured after deployment—not only the improved TBT and total score.
Can the conclusion be falsified?
A good root-cause hypothesis suggests a directional experiment. If forcing the same board on both sides removes the mismatch and long tasks, the hypothesis gains support. If it does not, the investigation should continue instead of rationalizing the result.
Did real feature tests pass?
Lighthouse covers only part of the product. Interaction, visual output, event delivery, Console errors, and several device conditions need their own checks.
9. A reusable Codex workflow for other projects
The same method can be organized as five independent conversations or five explicit stages:
- Baseline prompt: measure and save reports; prohibit edits.
- Diagnosis prompt: connect traces, Console, Network, DOM, and source code into an evidence chain.
- Planning prompt: separate the root-cause fix from secondary optimizations, risks, and verification.
- Implementation prompt: make causal changes one stage at a time and run targeted tests after each.
- Review prompt: measure the production mode again and report every metric, environment difference, and remaining risk.
This is more reliable than compressing the request into “optimize everything to 100.” It takes a few more turns, but each technical decision remains traceable and incorrect changes are easier to catch.
A complete performance task should leave more than a code diff. Keep at least:
- the original and follow-up Lighthouse JSON reports;
- a structured metric summary;
- the root cause and its evidence chain;
- expected versus observed impact for each change;
- automated test results; and
- unverified risks and the conditions for the next optimization.
10. Conclusion: Codex amplifies judgment rather than replacing it
On the surface, this is a story about moving from Lighthouse 76/79 to 98. The more valuable result was finding a data-ownership bug spanning ISR caching, date seeds, and React hydration.
If the team had followed generic Lighthouse advice first, it might have compressed images and trimmed JavaScript while leaving the two 400 ms long tasks untouched. Codex's real strength was moving quickly between the browser, reports, and repository to connect evidence that was scattered across those places. The developer's job was to ask the right questions, demand falsifiable evidence, define product constraints, and decide when further optimization was no longer worth the complexity.
There is no requirement to turn 98 into 100. Main-thread blocking is below 50 ms, the hydration error is gone, image delivery passes its audit, and the game and tests still work. Adding code-splitting and more runtime complexity for the final two points may not improve the user experience.
The sensible next step is to run mobile Lighthouse at least five times after deployment under the same production conditions. Separate Cloudflare cold and warm results, use the median, and then decide whether another round of LCP work is justified.
The point of AI-assisted performance work is not to let Codex make every decision for the developer. It is to make each decision faster to investigate, easier to verify, and easier to undo when the evidence says it was wrong.
Project links
- Open-source repository: fruit-box-game
- Play online: Fruit Box Game



Top comments (0)