Skip to content

perf: cache Intl formatters instead of constructing per render#364

Open
mattcosta7 wants to merge 4 commits into
mainfrom
perf/cache-intl-formatters
Open

perf: cache Intl formatters instead of constructing per render#364
mattcosta7 wants to merge 4 commits into
mainfrom
perf/cache-intl-formatters

Conversation

@mattcosta7

@mattcosta7 mattcosta7 commented Jul 15, 2026

Copy link
Copy Markdown
Member

Summary

Constructing an Intl.*Format is expensive — frequently more costly than the subsequent .format() call. Yet update() builds fresh formatters on every tick, and a page can render hundreds of <relative-time> elements, so this dominates per-tick cost.

Per render today, an element could construct:

  • Intl.DateTimeFormat for the title, and again for datetime / #isToday / #isCurrentYear / absolute-time paths
  • Intl.RelativeTimeFormat for relative/micro/absolute formatting
  • Intl.Locale on every #lang access (and #lang is read once per formatter)
  • inside the duration ponyfill: a NumberFormat per unit (up to 8) plus a ListFormat per format()
  • a fresh DurationFormat per toLocaleString

Changes

  • New shared module src/intl-cache.ts memoizing DateTimeFormat, RelativeTimeFormat, NumberFormat, and Locale by locale + JSON.stringify(options). Instances are shared across all elements. Formatters are immutable/stateless, so sharing is safe; the set of distinct (locale, options) combos on a page is small and bounded, so a plain Map needs no eviction.
  • Routed every formatter path through the cache: all DateTimeFormat/RelativeTimeFormat sites + Intl.Locale in relative-time-element.ts, NumberFormat/ListFormat in duration-format-ponyfill.ts, and DurationFormat in duration.ts.
  • Memoized the environment-constant 12h-cycle probe (isBrowser12hCycle).
  • Resolve #lang/timeZone/hourCycle once per update() (each is a closest() DOM walk read by several formatters per tick) and thread them into the formatting helpers.

Tests

Added test/intl-cache.ts asserting instance reuse for identical locale+options, distinct instances for differing options/locales, and that undefined option values key the same as absent ones. Full suite passes in Chromium.

Notes

Draft: opening for early feedback on approach.

@mattcosta7 mattcosta7 self-assigned this Jul 15, 2026
@mattcosta7 mattcosta7 requested a review from jonrohan July 15, 2026 00:43
@mattcosta7 mattcosta7 force-pushed the perf/cache-intl-formatters branch from 4a0a9b0 to a39a74d Compare July 15, 2026 00:59
@mattcosta7 mattcosta7 changed the base branch from main to perf/skip-noop-render-writes July 15, 2026 00:59
@mattcosta7 mattcosta7 marked this pull request as ready for review July 15, 2026 01:00
@mattcosta7 mattcosta7 requested a review from a team as a code owner July 15, 2026 01:00
Copilot AI review requested due to automatic review settings July 15, 2026 01:00

This comment was marked as outdated.

Constructing Intl.*Format is expensive (often more than format() itself), yet update() built fresh DateTimeFormat/RelativeTimeFormat/Locale instances on every tick, and the DurationFormat ponyfill built a NumberFormat per unit plus a ListFormat per format. On pages with many <relative-time> elements this dominates tick cost.

Add a shared src/intl-cache.ts memoizing DateTimeFormat, RelativeTimeFormat, NumberFormat, and Locale by locale+options, and route every formatter path through it: all formatters in relative-time-element.ts, NumberFormat/ListFormat in duration-format-ponyfill.ts, and DurationFormat instances in duration.ts. The 12h-cycle probe is memoized once. Formatters are immutable/stateless so sharing across elements is safe.
The #lang, timeZone, and hourCycle getters each walk ancestors via closest(), and were read by multiple formatters per tick. Resolve them a single time at the top of update() and thread them into the formatting helpers, avoiding repeated DOM traversal per render.
- Return 'default' locale without constructing Intl.Locale('') each tick
- Key Duration formatter cache by recognized options (no JSON.stringify/toJSON)
- Bound all Intl caches with an LRU and evict least-recently-used entries
- Invalidate cached formatters and memoized hour-cycle on languagechange
@mattcosta7 mattcosta7 force-pushed the perf/cache-intl-formatters branch from 838c4d4 to f66d5a2 Compare July 15, 2026 14:19
@mattcosta7 mattcosta7 changed the base branch from perf/skip-noop-render-writes to main July 15, 2026 14:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants