One status code hid every new tag: making filed-report tagging fast
European listed companies file their annual reports as ESEF: an XHTML document with inline XBRL tags on every number in the financial statements. Our editor lets a preparer tag that report directly on the issuer's own layout.
Briskflow engineering

European listed companies file their annual reports as ESEF: an XHTML document with inline XBRL tags on every number in the financial statements. Our editor lets a preparer tag that report directly on the issuer's own layout. They click a value such as 17.096, choose a concept such as ifrs-full:Revenue, a period, a unit and a precision, and save. A typical report needs 200 to 700 tags.
In our first end-to-end proof on real filings, one tag took about 20 seconds from click to accepted. The new tag's highlight never appeared until the page was reloaded, and the Tag button stayed disabled until then. Two saves failed with HTTP 503.
This post describes how we measured the problem, the five root causes we found, and the design of each fix. The headline result: save to accepted went from 6.7 s to 0.56 s (p95), the highlight appears in about 0.1 s, and 20 of 20 saves succeeded.
How a tag moves through the system
The editor never edits the filed text. A tag is a database record that points at a span of the immutable report. A background worker then regenerates an annotated copy of the report with the new tag in place. The browser shows a pending mark at once and swaps it for the confirmed mark when the worker publishes the new copy.

The design separates two questions. "Is the tag accepted?" is answered by the transaction. "Is the display up to date?" is answered by the worker. Most of the defects came from code that mixed the two.
Measure before changing anything
We did not guess. We rebuilt the scenario on the real services (API, worker, pipeline, Postgres, Redis, object storage and a real browser) with a 112-page real filing whose tags we had removed, and we timed each phase of one tag.
| Phase (one tag, before) | Time | Finding |
|---|---|---|
| New-concept preparation | 4.1–4.8 s | full taxonomy presentation built again for every new concept |
| Taxonomy lookup | 1.2–1.4 s | done before the save could return |
| Anchor validation | 450 ms | parsed the whole document again |
| Semantic acceptance | 431 ms | one existence check and one write per presentation arc |
| Confirmed mark | never | the browser stopped polling after one probe |
The last row is not a performance problem. It is a correctness bug that looked like slowness.
Root cause 1: the browser gave up on "not ready yet"
While the worker rebuilt the copy, the browser asked HEAD /fixed-layout/source and got 409 with no body (a HEAD response never has a body). Our retry classifier looked for an error code in the body. It found none, classified the response as a terminal failure, and stopped. The confirmed mark could never arrive.
Fix: a 409 on this route that carries the scoped "projection pending" signal is retryable inside the existing bounded retry. Every other 409, every access refusal and every integrity error stays terminal. The frontend test uses a real bodyless Response(409), because a mock with a JSON body would have hidden the bug.
Lesson: a status code is part of a protocol. When one path cannot carry a body, its classification cannot depend on the body.
Root cause 2: a pending projection looked like a missing source
The next tag validated its anchor against the annotated copy. While that copy was one generation behind, the check reported "Document source is unavailable" and the API returned 503. The stack trace:
readFixedLayoutSource: annotated source is not current (409)
→ anchorStructureGuard: classified as download failure
→ POST /facts: 503 "Document source is unavailable; retry the edit"Fix: two separate proofs.
- Body-current: the immutable report body, its source, artifact and parse identities are unchanged. Placing a tag needs only this.
- Display-current: additionally, the annotated projection matches the current semantic generation. Drawing marks needs this.
A pending projection no longer blocks the next tag. A replaced or re-parsed body is still refused, and a real storage failure still fails closed.
Root cause 3: the taxonomy presentation was rebuilt per concept
When a preparer used a concept for the first time, the API asked the pipeline for the full presentation of the pinned ESEF taxonomy so it could place the concept in a statement. That took 4–5 seconds, inside the save request, for every new concept.
Fix: build the complete pinned catalog once, before the filed report opens, and reuse it. The cache key is the organization and the pinned taxonomy revision, and entries are immutable. The effective profile is still read again on every command. The outline write that used one existence check per arc became one duplicate-safe createMany for the concept's ancestor chain; the database's unique key already included arc order and preferred label (XBRL 2.1 §5.2.4.2.1), so idempotency came for free.
This moves a cost rather than removing it: the first open of a document now pays about 4.3 s once.
Root cause 4: the same document was processed many times
A HEAD probe generated the whole document body just to compute headers. The paginated fact read and the complete fact read each sanitized and parsed the same artifact.
Fixes:
HEADreturns the authorized identity headers with no body I/O. RFC 9110 §9.3.2 allows aHEADresponse to omit headers that need the content; conditional requests still return304on a match.- Snapshot readers share one sanitize-and-parse per artifact. The cache key is tenant + document + content digest + length + sanitizer policy version, it holds at most 8 entries or 128 MiB, failed builds are evicted, and every read checks the live source proof before and after using the cached result. A test proves that 2 or 32 concurrent readers cause exactly one artifact read, one sanitization and one parse.
Root cause 5: duplicate jobs redid finished work
The outbox could deliver more than one regeneration job for the same semantic generation. Each rebuilt and republished the copy, which invalidated the browser's snapshot again.
Fix: before rebuilding, the worker reuses the existing freshness proof (body, source, parse, artifact, semantic identity and a content fingerprint). A duplicate job for unchanged content does nothing; changed content still regenerates.
Results
Twenty tags on the 112-page filed report, on the real services, with no fixed sleeps in the probe.
| Phase | Before | After p50 | After p95 |
|---|---|---|---|
| Save → pending mark | — | 87 ms | 112 ms |
| Save → accepted and visible | 711 ms (proof: 6.5–6.7 s) | 276 ms | 559 ms |
| Save → confirmed mark | never | 2,582 ms | 2,989 ms |
| Pick period | 1,255 ms | 158 ms | 942 ms |
| Unit and precision | 1,014 ms | 422 ms | 454 ms |
| Whole tag, click → confirmed | ~20 s | 4,296 ms | 5,862 ms |
On the server, one tag went from 1,114 ms to 140 ms: preparation 548 → 66 ms, anchor validation 450 → 53 ms, transaction 431 → 62 ms.
What we did not fix
- The whole tag still takes 4–6 s. Most of it is the preparer's own choices, mainly the concept search (0.9–1.7 s including a 300 ms debounce).
- The Tag button waits for the confirmed mark, so the fastest repeat rate is about one tag every 4–6 s.
- These are local measurements; production latency is not yet measured.
Testing performance without flaky tests
Every new performance test asserts work counts or state, never wall-clock time: one bulk write for a 16-arc and a 1,024-arc presentation, one parse for 32 concurrent readers, no body download for HEAD. Earlier the same day, tests with absolute time budgets had failed on a loaded build machine and blocked two pushes. Counting work proves the property we care about (no repeated work) on any machine.
How we ran the work
One agent implemented the fix and a second agent reviewed it read-only, with three review rounds at most. Two rules made the difference:
- Measure first, root cause only. The brief required a phase-by-phase timing before any change, and each fix had to name the standard it follows (XBRL 2.1 presentation arcs, RFC 9110
HEADsemantics) and correct the code where it departs from that standard. - Review rounds after the first only verify. A later round checks that earlier findings are fixed and nothing regressed. New minor findings go to a backlog. This stopped the open-ended review loops that had cost us hours earlier in the week.
The numbers in this post come from the real API, worker, database and a real browser on a real public filing, not from mocks.