Skip to content
Lexicamp

Engineering

Teaching an agent to audit our data layer

Last updated

Only one thing decides whether Lexicamp feels fast, and it isn’t animation or navigation or image loading. Those all sit comfortably inside budget. It’s how many words you’ve saved.

A learner three weeks in with 40 words will never notice anything. A learner two years in with several thousand words runs into every cost in the app at once: the read path, the cache, the persistence layer, the derivations, and the render tree. That learner is also our most committed user, which is the uncomfortable part. They earned the slowness by using the product exactly as intended.

So when the app started feeling heavy on a large library, the question wasn’t which thing to optimise. It was how to be sure we optimised the thing that was actually slow rather than the thing that looked slow.

Why a skill and not a checklist

We wrote the audit as a skill, a reusable set of instructions an agent loads whenever it works on this part of the codebase. That choice came out of a failure mode we’d already watched happen.

Our data layer is heavily commented. Almost every non-obvious line carries a note explaining why it’s correct, usually citing the bug that put it there. That’s good for maintenance and quietly dangerous for performance work, because a comment explaining why code is correct says nothing about what it costs. It’s very easy to read a thoughtful paragraph about ordering guarantees and come away feeling the function has been vetted.

A checklist in a doc gets read once. A skill gets loaded every time anyone, human or agent, touches fetching, caching, or invalidation, and it can carry things a checklist can’t. Ours ships a scanner: one script that prints the current invalidation graph, the query-key surface, how many cache writes are surgical rather than blunt, and what the persist configuration actually says. It makes no judgments, which is the point, because every audit then starts from today’s code instead of from a doc that quietly went stale.

It also sets the measurement protocol. Take every number twice, once at around 40 saved words and once at several thousand, because the ratio between them is the finding. A step that goes from 12 ms to 15 ms is fine however ugly the code is. One that goes from 12 ms to 2,400 ms is the headline. And every audit has to record the plausible fixes it rejected along with the reason, or the next one re-litigates the same dead ends.

The baseline it found

Here’s what the audit turned up against a 4,300-word library. Numbers are Node/V8 against our mock fixtures, median of eleven runs; the ratios transfer to the phone, the absolute milliseconds don’t.

Moment 38 words 4,300 words Ratio
Cold-launch deserialize 0.74 ms 99.4 ms 134×
Backgrounding (serialize) 0.08 ms 12.0 ms 148×
On-disk cache 47 KB 5.66 MB 123×

Three structural findings sat behind those numbers, and the first was the one nobody expected. We persist the query cache as JSON and pass a function to JSON.parse that turns known date fields back into real Date objects. The function is about as cheap as it can be, a set lookup on the field name. The trouble is that JSON.parse calls a reviver once per node in the document rather than once per date, so at 4,300 words that’s 236,569 invocations to produce 30,100 dates, a hit rate of 13%. The other 87% is pure overhead paid on the JS thread while the user watches a splash screen.

The second finding was that most writes asked the server for something they already knew. The scanner counted 37 blanket cache invalidations against 6 surgical updates, and all six of the surgical ones happened to target the cheapest query in the app. Archiving a single word invalidated the entire word library and re-fetched every row, which is what produces the visible pause between tapping Archive and the row restyling: the UI waiting on a network round trip for a boolean it set itself.

The third finding was a list nobody had virtualized. The main word list uses a virtualized FlatList, which is correct and had been reviewed. The Add to deck picker rendered the same data through a plain scroll view, mounting a row component for every saved word at once. It escaped attention because the list everyone thought about was already fine.

What changed

Before After
Cold-launch deserialize 99.4 ms 19.9 ms 5.0×
On-disk cache 5.66 MB 3.70 MB −35%
Backgrounding 12.0 ms 7.9 ms 1.5×
Small library (38 words) 0.83 ms 0.18 ms no regression

Five changes got us there, and the biggest was the smallest to write. We dropped the JSON.parse reviver and walk the parsed result instead, which is the identical rule in one cheap pass, with a test asserting the two produce byte-identical output. That edit alone took deserialization from 99.4 ms to 28.1 ms.

The rest was mostly deciding to store less and ask for less. We stopped persisting three things: per-search results that grow without bound, a redundant second copy of the library, and a queue the server recomputes on every load anyway. Saved words and home statistics still persist, so opening the app on a plane still shows your words. The query behind the home and progress screens was pulling example sentences and alternate-translation blobs for every card in order to compute counts that never use them, so we narrowed it. Mutations that already know what changed now patch the cached rows directly instead of invalidating the world, which covers archiving a word, editing a translation and deleting a word. And the deck picker got virtualized.

What we left alone

The most-cited candidate was a statistics function that runs over every card on every render without memoization. It’s textbook: an unmemoized O(n) derivation in a hot path. Measured, it costs 0.67 ms at 4,300 cards. It looks exactly like the bug, but it isn’t one, so we left it alone.

Two mutations still invalidate the world on purpose. Saving a word and committing a quiz session both produce state the client genuinely cannot derive, and in the quiz case the next review date comes out of the scheduling algorithm itself. Faking that in the cache to save a round trip would fork the one piece of math the entire product rests on. The audit says this out loud so that nobody has to make the call under pressure later.

Where the skill was wrong

Four times, and the fourth one mattered.

An early draft told readers that the date reviver was already about as cheap as it could get, and that the real lever was persisting less. That’s backwards. It’s true of the function’s body and false of its invocation count, and it steered attention away from what turned out to be the single cheapest win available. We only caught it by running the skill against agents, comparing their output to agents working without it, and watching one of them measure the thing the doc had told it not to bother measuring.

The runs without the skill were also good, which is worth saying. Working from a well-commented codebase, they found the invalidation cost and the duplicate fetch on their own. What the skill reliably added was different in kind: measurements where there would otherwise have been assertions, an explicit record of the risky changes not to make, and a scanner that pointed at code nobody had thought to suspect, which is how the deck picker turned up at all.

That’s the case for writing this sort of thing down. An agent can reason about your code without it. But a codebase accumulates specific, hard-won knowledge about where its costs actually live, and that knowledge should sit somewhere it gets loaded automatically instead of remembered occasionally.

These numbers come from a synthetic 4,300-word library on a laptop, not a phone in someone’s hand. On-device is the next measurement, and the skill says so.