August 21, 20262 min

Computed Once, Wrong Forever

Shared i18n label constants were built once at module load, before the translation library had finished initializing — permanently baking in blank labels that never recomputed once the library actually became ready.

JavaScript · TypeScript · Correctness · System Design

TL;DR

  • A module built a shared object of translated labels once, at import time, by calling the translation library immediately.
  • The library initialized asynchronously and wasn't ready yet — so every label resolved to blank at the moment the constant was computed.
  • Because the object was computed exactly once and reused everywhere as a frozen constant, it stayed blank for the module's entire lifetime, long after the library became ready.

Computing a value once at module load is normally the responsible choice. It avoids redundant work for something that looks like it can't change:

ts
1// Runs at import time. Exactly once.
2export const STATUS_LABELS = {
3  pending:  t("status.pending"),
4  approved: t("status.approved"),
5  rejected: t("status.rejected"),
6};
7

The assumption baked in there is that t() is ready to answer by the time this module is imported. It wasn't.

Why the failure was permanent, not transient

The constant is computed during the window where the library returns blanks, then never recomputed.

If the labels had been recomputed on each use, this would have been a brief, self-correcting glitch: blanks for a moment during startup, correct text once the library caught up.

Recomputed per callFrozen at module load
During init windowblankblank
After init completescorrectstill blank
Self-correctsyesnever
Depends on import ordernoyes

The bug wasn't the race. Races that resolve on their own are common and usually harmless. It was combining a race with a cache that never expires.

Why it wasn't isolated to one file

The same pattern had been copied into several unrelated files, because it reads as the obvious, idiomatic way to define a set of constant labels. Each copy carried the same latent bug, and each surfaced or didn't depending on module load order — which nobody reasons about when writing a labels file.

The fix

Expose the labels as functions, evaluated per call:

ts
1// Asked fresh each time. Load order stops mattering.
2export const statusLabels = () => ({
3  pending:  t("status.pending"),
4  approved: t("status.approved"),
5  rejected: t("status.rejected"),
6});
7
Deferring evaluation to call time removes the dependency on when the module happened to load.

That trades a small amount of repeated work for correctness that doesn't depend on load order.

The transferable part

"Compute once at module load" is only safe for values actually available and correct at load time — and asynchronous initialization anywhere in the dependency chain breaks that quietly, without raising anything.

If a value depends on something that initializes asynchronously, treat it as a function to call rather than a constant to freeze. The cost of recomputing is almost always smaller than the cost of caching a value from before its dependency was ready — especially when the cached value is an empty string, which looks like a styling bug rather than a lifecycle one.

Share this note

Comments

responses

0/2000

Loading comments…