Engineering Journal
Table Formatter
Table Formatter

Never store the rendered form of anything you can regenerate

2026-08-21

TLDR: If a value has a source form and a rendered form, persist the source and regenerate the render. Persisting the render is how a document survives one save and dies on the second, and it is almost always invisible until someone reports that their content "turned into garbage."

The problem class

You are building anything that displays a computed view of a value: LaTeX to typeset maths, Markdown to HTML, a template to a document, a query builder to SQL. Two representations exist for the same thing, and you have to decide which one your storage layer treats as real.

The rendered form is tempting. It is what the user sees, what the DOM already contains, and what your export code can emit with no extra work. It also loses information, and the loss compounds every time the value passes through.

Here is the concrete instance. A document extractor reconstructs display maths from a PDF into LaTeX, then typesets it for display. The markup that reaches the DOM looks like this:

<p class="math-block" data-latex="Q = \frac{2\pi f_0 W}{P_R}">
  <span class="katex"><span class="mord mathnormal">Q</span>…600 more spans…</span>
</p>

The importer that read this document back had no branch for it, so it fell through to the paragraph case, which does what every paragraph case does: takes textContent. For that element textContent is the visible glyph run, roughly Q=2πf0WPR. That string is not an equation. It is not even wrong in a recoverable way, because the structure that made it an equation lived in the nesting, and textContent flattens nesting by definition.

So the document rendered correctly, exported correctly, and could not be imported. One round trip through the IR destroyed every equation in it.

The naive approach

The naive fix is to stop flattening. Keep the KaTeX markup, store the whole span tree, put it back on the way out.

That fails for three reasons and it is worth being precise about which.

It is not stable. Rendered output is a function of the renderer's version. Store a span tree from KaTeX 0.16 and re-emit it under 0.18 and your document is now a museum of three renderer versions with different class names, sitting in the same file.

It is not editable. Nobody edits a span tree. The moment a user wants to change P_R to P_{R} there is no surface to do it on, so you end up storing the source anyway, alongside the render, and now you have two sources of truth that can disagree.

It is not comparable. A diff between two renders is a diff between two DOM trees, which is a useless thing to show a person. A diff between two TeX strings is one line and immediately reviewable.

The better model

State the rule as an invariant and enforce it at every boundary:

The source is the content. The render is a view, is regenerated on demand, and is never read back.

In practice that is three commitments.

The IR carries the source. The block type stores the TeX and nothing else:

case 'equation': {
    // The TeX IS the content. The typeset markup is a view of this attribute
    // and is re-derivable; storing it instead is how a round trip loses the
    // equation entirely.
    const tex = esc(block.latex || block.text || '');
    return &lt;p class="math-block" data-latex="${tex}"&gt;${tex}&lt;/p&gt;;
}

Note the fallback text node is the TeX too, not an empty element. If nothing ever typesets it, the reader still gets the content, which is the honest degradation.

The importer reads the attribute, never the text. One branch, placed before the generic paragraph case:

if (el.hasAttribute('data-latex') || cls.includes('math-block')) {
    const latex = el.getAttribute('data-latex') || '';
    const text = blockText(el);
    if (latex || text) {
        addBlock(page, { type: 'equation', latex, text: latex || text, colIdx, ry });
        return;
    }
}

Every mutation writes the source first and regenerates the view. This is the one people skip, and it produces the nastiest bug in the family: a document that shows one thing and exports another.

mathEl.setAttribute('data-latex', tex);   // the content
mathEl.innerHTML = rendered;              // the view, regenerated

Write only the view and the attribute goes stale. The page looks corrected; the next export emits the old equation; nobody can reproduce it because the screen and the file disagree and only one of them is ever checked.

The validity rule that falls out of it

Once the source is canonical, an interesting invariant becomes available: refuse to store a source that cannot render.

let rendered;
try {
    rendered = katex.renderToString(tex, { throwOnError: true, displayMode: true });
} catch (_) {
    return false;      // refuse; the region keeps what it had
}

This is not defensive coding, it is the storage layer's contract. A stored source that will not render is worse than a wrong value: it is a value that displays fine right now, because the old render is still in the DOM, and vanishes on the next re-render. Rejecting it at the write means the document can never enter that state.

The same rule shows up in a lot of places once you look. A template that will not compile should not be saved. A query that will not parse should not be persisted as a saved view.

Tradeoffs

You pay render cost on every display. For maths that is roughly a millisecond per expression, so this is not the constraint people assume. If it were, the answer would be a cache keyed on the source string, which is different from making the render canonical, because a cache can be thrown away.

The renderer becomes a hard dependency of display. If it fails to load, you show source instead of output. That is a real downgrade and it argues for bundling the renderer rather than fetching it from a CDN, since a network failure would otherwise take out the reading experience for the exact content that needs it most.

Two fields feel redundant. Storing latex and a text fallback looks like duplication until an importer meets a document produced before the attribute existed, and text is the only thing it can recover.

The lesson

For every value in your schema, ask: could I regenerate this from something else I have? If yes, that something else is the content, and this field is a cache. Caches do not belong in documents. They belong next to the code that produces them, where they can be thrown away and rebuilt without anyone losing work.

Read this post in the full Engineering Journal →