Skip to main content
All Engineering

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

Stacked financial report pages: dashed blue boxes mark pending figures, a coral marker   shows the hand-off, and a solid blue box marks the confirmed figures.

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.

Tagging sequence: Browser saves a fact through the API and Postgres, draws a pending mark, then polls until the Worker publishes generation N and displays the confirmed mark.

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)TimeFinding
New-concept preparation4.1–4.8 sfull taxonomy presentation built again for every new concept
Taxonomy lookup1.2–1.4 sdone before the save could return
Anchor validation450 msparsed the whole document again
Semantic acceptance431 msone existence check and one write per presentation arc
Confirmed markneverthe 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:

  • HEAD returns the authorized identity headers with no body I/O. RFC 9110 §9.3.2 allows a HEAD response to omit headers that need the content; conditional requests still return 304 on 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.

PhaseBeforeAfter p50After p95
Save → pending mark—87 ms112 ms
Save → accepted and visible711 ms (proof: 6.5–6.7 s)276 ms559 ms
Save → confirmed marknever2,582 ms2,989 ms
Pick period1,255 ms158 ms942 ms
Unit and precision1,014 ms422 ms454 ms
Whole tag, click → confirmed~20 s4,296 ms5,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:

  1. 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 HEAD semantics) and correct the code where it departs from that standard.
  2. 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.