Skip to content

perf: cache createLocaleConfigs per fallbackLocale value - #4130

Open
Vincentdevreede wants to merge 1 commit into
nuxt-modules:mainfrom
Vincentdevreede:perf/cache-locale-configs
Open

perf: cache createLocaleConfigs per fallbackLocale value#4130
Vincentdevreede wants to merge 1 commit into
nuxt-modules:mainfrom
Vincentdevreede:perf/cache-locale-configs

Conversation

@Vincentdevreede

@Vincentdevreede Vincentdevreede commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

createLocaleConfigs recomputes every configured locale's fallback chain from scratch on every request that doesn't already have a cached i18n context, initializeI18nContext runs on Nitro's request hook unconditionally, so this happens on every single request. Its only real input, fallbackLocale, is normally the same value every time.

This adds a small in-memory cache keyed by a serialization of fallbackLocale itself, rather than memoizing once and reusing forever. fallbackLocale comes from defineI18nConfig, which can be async, so in theory it could differ per request, keying by value keeps that case correct (a different value just gets its own cache entry) while still skipping the work entirely whenever it's what it almost always is: unchanged.

Benchmark

Measured directly against the real function (forcing a genuine cache miss on every "uncached" call versus repeated calls with the same value):

locales uncached (per call) cached (per call) speedup
5 5.3 µs 0.9 µs 5.6x
20 11.8 µs 0.8 µs 14.9x
100 39.8 µs 0.7 µs 54.9x

The cached path stays flat regardless of locale count, it's just a map lookup. The uncached path scales with locale count, since it walks every configured locale's fallback chain. Small in absolute terms per request, but free to keep, and it scales better the more locales are configured.

Summary by CodeRabbit

  • Performance Improvements
    • Improved locale configuration handling by reusing previously generated configurations, reducing repeated processing.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

createLocaleConfigs now uses a module-level cache keyed by the serialized fallbackLocale value. It returns an existing locale configuration when the key is present. It stores newly generated configurations before returning them.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: caching createLocaleConfigs results by fallbackLocale value.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/shared/locales.ts`:
- Around line 16-19: Update createLocaleConfigs and localeConfigsCache to
enforce a bounded lifetime or size-based eviction policy, so request-specific
serialized fallbackLocale configurations cannot accumulate indefinitely during
SSR. Preserve cache reuse for retained entries while evicting older or expired
records according to the chosen limit.
- Around line 14-19: Bound the process-wide localeConfigsCache used by
createLocaleConfigs so distinct fallbackLocale keys cannot grow without limit.
Replace the unbounded Map behavior with a bounded FIFO or LRU policy, evicting
entries when the configured capacity is exceeded while preserving cached
results. Add a test covering insertion beyond capacity and verifying the oldest
or least-recently-used locale configuration is evicted.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 25f9ac3a-3742-4262-bbf1-eb55a263ffc0

📥 Commits

Reviewing files that changed from the base of the PR and between ccee57f and 5c55d32.

📒 Files selected for processing (1)
  • src/runtime/shared/locales.ts

Comment on lines +14 to +19
const localeConfigsCache = new Map<string, Record<string, LocaleConfig>>()

export function createLocaleConfigs(fallbackLocale: FallbackLocale): Record<string, LocaleConfig> {
const cacheKey = JSON.stringify(fallbackLocale)
const cached = localeConfigsCache.get(cacheKey)
if (cached) { return cached }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
fd -i 'locales|locale|i18n|runtime' . | head -200

printf '%s\n' '--- symbols and call sites ---'
rg -n --glob '!node_modules' --glob '!dist' \
  'createLocaleConfigs|localeConfigsCache|LocaleConfig|FallbackLocale' .

printf '%s\n' '--- target file outline ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline src/runtime/shared/locales.ts
fi

printf '%s\n' '--- target file ---'
cat -n src/runtime/shared/locales.ts

Repository: nuxt-modules/i18n

Length of output: 15210


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- server context ---'
cat -n src/runtime/server/context.ts | sed -n '1,120p'

printf '%s\n' '--- client/server plugin ---'
cat -n src/runtime/plugins/i18n.ts | sed -n '1,90p'

printf '%s\n' '--- runtime context and mutation candidates ---'
cat -n src/runtime/context.ts | sed -n '1,55p'
cat -n src/runtime/context.ts | sed -n '145,185p'
cat -n src/runtime/context.ts | sed -n '300,325p'

printf '%s\n' '--- all writes and returned-object consumers ---'
rg -n --glob '!node_modules' \
  'createLocaleConfigs\(|localeConfigsCache|localeConfigs\[|localeConfigs\.|serverLocaleConfigs|useLocaleConfigs\(\)\.value' \
  src test specs

printf '%s\n' '--- relevant diff summary ---'
git diff --stat
git status --short

Repository: nuxt-modules/i18n

Length of output: 17323


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- setupVueI18nOptions and runtime config ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline src/runtime/shared/vue-i18n.ts
fi
rg -n -A45 -B10 \
  'setupVueI18nOptions|defineI18nConfig|fallbackLocale|runtimeConfig' \
  src/runtime/shared/vue-i18n.ts src/runtime src/bridge docs/content/docs \
  | head -500

printf '%s\n' '--- fallback type declarations ---'
rg -n --glob '*.{ts,d.ts}' \
  'type FallbackLocale|FallbackLocale =' node_modules src . 2>/dev/null \
  | head -120

printf '%s\n' '--- tests for shared locale helpers or runtime config requests ---'
rg -n --glob '*.{spec,test}.{ts,js}' \
  'createLocaleConfigs|fallbackLocale|defineI18nConfig|runtime.*config|request' \
  test specs | head -400

Repository: nuxt-modules/i18n

Length of output: 44868


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- loadVueI18nOptions implementation ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline src/runtime/shared/messages.ts
fi
rg -n -A80 -B15 \
  'loadVueI18nOptions|vueI18nConfigs|ConfigLoader|i18nConfigs' \
  src test specs | head -500

printf '%s\n' '--- request-scoped config examples ---'
rg -n -A25 -B12 \
  'defineI18nConfig\(|useRequestEvent\(|useRuntimeConfig\(' \
  specs test playground docs src | head -500

printf '%s\n' '--- cache-related tests and package scripts ---'
rg -n -A25 -B10 \
  'cacheLifetime|cache|test' \
  specs/lazy_load/message_isolation.spec.ts package.json vitest.config.* 2>/dev/null | head -300

Repository: nuxt-modules/i18n

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -eu

node - <<'JS'
'use strict'

// Behavioral probe for the cache policy in src/runtime/shared/locales.ts.
// The locale configuration payload is intentionally small and representative;
// the point is whether distinct JSON keys remain strongly reachable.
const localeConfigsCache = new Map()

function createLocaleConfigs(fallbackLocale) {
  const cacheKey = JSON.stringify(fallbackLocale)
  const cached = localeConfigsCache.get(cacheKey)
  if (cached) return cached

  const localeConfigs = { en: { fallbacks: [fallbackLocale], cacheable: true } }
  localeConfigsCache.set(cacheKey, localeConfigs)
  return localeConfigs
}

const first = createLocaleConfigs({ default: ['en-0'] })
const sameValue = createLocaleConfigs({ default: ['en-0'] })

for (let i = 1; i <= 10000; i++) {
  createLocaleConfigs({ default: [`en-${i}`] })
}

console.log(JSON.stringify({
  equalValuesReuseEntry: first === sameValue,
  entriesAfterDistinctInputs: localeConfigsCache.size,
  firstEntryStillReachable: localeConfigsCache.get(JSON.stringify({ default: ['en-0'] })) === first,
  lastEntryPresent: localeConfigsCache.has(JSON.stringify({ default: ['en-10000'] })),
}))
JS

printf '%s\n' '--- exact request/config call chain ---'
cat -n src/runtime/shared/messages.ts | sed -n '96,108p'
cat -n src/runtime/shared/vue-i18n.ts | sed -n '12,25p'
cat -n src/runtime/server/context.ts | sed -n '24,35p'

Repository: nuxt-modules/i18n

Length of output: 2205


Bound localeConfigsCache before merge.

The server path resolves Vue I18n configuration for each request, and each distinct fallbackLocale key remains strongly reachable in the process-wide Map. Use a bounded FIFO/LRU cache and add an eviction test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/shared/locales.ts` around lines 14 - 19, Bound the process-wide
localeConfigsCache used by createLocaleConfigs so distinct fallbackLocale keys
cannot grow without limit. Replace the unbounded Map behavior with a bounded
FIFO or LRU policy, evicting entries when the configured capacity is exceeded
while preserving cached results. Add a test covering insertion beyond capacity
and verifying the oldest or least-recently-used locale configuration is evicted.

Comment on lines 16 to +19
export function createLocaleConfigs(fallbackLocale: FallbackLocale): Record<string, LocaleConfig> {
const cacheKey = JSON.stringify(fallbackLocale)
const cached = localeConfigsCache.get(cacheKey)
if (cached) { return cached }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 '\blocaleConfigs(?:\.value)?(?:\[[^]]+\])?\s*(?:=|\?\?=)|\.(?:fallbacks|cacheable)\s*=' src || true

Repository: nuxt-modules/i18n

Length of output: 4016


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- locale cache implementation ---'
cat -n src/runtime/shared/locales.ts | sed -n '1,120p'

printf '%s\n' '--- all localeConfigs references ---'
rg -n -C 4 '\blocaleConfigs\b|useLocaleConfigs' src

printf '%s\n' '--- type and interface declarations ---'
rg -n -C 5 'interface LocaleConfig|type LocaleConfig|FallbackLocale|useLocaleConfigs' src

Repository: nuxt-modules/i18n

Length of output: 23716


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Nuxt context consumers ---'
cat -n src/runtime/context.ts | sed -n '155,245p'

printf '%s\n' '--- server context and message loading ---'
cat -n src/runtime/server/context.ts | sed -n '45,85p'
rg -n -C 8 'function getMergedMessages|const getMergedMessages|getMergedMessages\s*=' src

printf '%s\n' '--- fallback property operations ---'
rg -n -C 4 '\bfallbacks\b|\bgetLocaleConfig\b' src --glob '*.{ts,js,vue}'

printf '%s\n' '--- static mutation verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

roots = [p for p in Path("src").rglob("*") if p.suffix in {".ts", ".js", ".vue"}]
patterns = [
    r'\b(?:localeConfigs|serverLocaleConfigs|ctx\.localeConfigs|localeConfigs\.value)\s*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)?\s*(?:=|\+=|-=|\*=|/=)',
    r'\b(?:fallbacks|fallbackLocaleCodes)\s*\.\s*(?:push|pop|shift|unshift|splice|sort|reverse|copyWithin|fill)\s*\(',
    r'\b(?:fallbacks|fallbackLocaleCodes)\s*\[[^\]]+\]\s*=',
]
for path in roots:
    text = path.read_text(errors="replace").splitlines()
    for number, line in enumerate(text, 1):
        if any(re.search(pattern, line) for pattern in patterns):
            print(f"{path}:{number}:{line}")
PY

Repository: nuxt-modules/i18n

Length of output: 17351


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- repository-wide createLocaleConfigs references ---'
rg -n -C 4 '\bcreateLocaleConfigs\b|\blocaleConfigsCache\b' .

printf '%s\n' '--- complete merged-message implementation ---'
cat -n src/runtime/server/utils/messages.ts | sed -n '35,80p'

printf '%s\n' '--- repository-wide locale config writes and fallback mutators ---'
rg -n -C 3 '\b(?:localeConfigs|serverLocaleConfigs|ctx\.localeConfigs|fallbacks)\b' . \
  --glob '!node_modules' --glob '!dist' --glob '!build' \
  | rg '(^|:).*(:.*)?(=|\?\?=|\.push|\.pop|\.shift|\.unshift|\.splice|\.sort|\.reverse|\.fill|\.copyWithin)' || true

printf '%s\n' '--- tests mentioning locale config caching ---'
rg -n -C 5 'createLocaleConfigs|localeConfigsCache|fallbacks.*cache|cached-locale-configs' test tests . \
  --glob '*.{ts,js,mjs,cjs}' 2>/dev/null || true

Repository: nuxt-modules/i18n

Length of output: 17668


Bound localeConfigsCache growth

localeConfigsCache retains one record for each distinct serialized fallbackLocale and never evicts entries. Different request-specific fallback configurations can therefore accumulate for the lifetime of the SSR process. Add a bounded eviction policy or another lifetime limit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/shared/locales.ts` around lines 16 - 19, Update
createLocaleConfigs and localeConfigsCache to enforce a bounded lifetime or
size-based eviction policy, so request-specific serialized fallbackLocale
configurations cannot accumulate indefinitely during SSR. Preserve cache reuse
for retained entries while evicting older or expired records according to the
chosen limit.

@ikz2018

ikz2018 commented Aug 20, 2026

Copy link
Copy Markdown

We ran into the same "runs on every request" problem you describe here, and I wanted to share profiling data in case
it's useful — in our case the dominant cost inside initializeI18nContext turned out to be a different line.

Context: we upgraded a Nuxt 3.15 monorepo to Nuxt 4.5.2 / @nuxtjs/i18n 10.6.0 and saw response times at our ingress go
from ~50 ms to 250–500 ms, with pods saturating on CPU. We serve static assets from the Node pods themselves
(node-server preset, no CDN in front), so every /_nuxt/*.js chunk request goes through the Nitro request pipeline.

Profiling the production build under load (node --cpu-prof, requests for a single 442-byte JS chunk, 20 concurrent
keep-alive connections) showed this stack:

getEnv
← applyEnv
← useRuntimeConfig
← useRuntimeI18n
← initializeI18nContext
← nitro hook "request"

Sample distribution in that profile:

┌──────────────────┬─────────────────────────────────────┐
│ function │ % of samples │
├──────────────────┼─────────────────────────────────────┤
│ getEnv │ 8.9 % │
├──────────────────┼─────────────────────────────────────┤
│ kebabCase │ 2.1 % │
├──────────────────┼─────────────────────────────────────┤
│ snakeCase │ 1.7 % │
├──────────────────┼─────────────────────────────────────┤
│ splitByCase │ 1.1 % │
├──────────────────┼─────────────────────────────────────┤
│ deepCopy │ 1.1 % │
├──────────────────┼─────────────────────────────────────┤
│ applyEnv + klona │ 1.2 % │
├──────────────────┼─────────────────────────────────────┤
│ total │ 16.5 % (≈ half of all non-idle CPU) │
└──────────────────┴─────────────────────────────────────┘

That's useRuntimeI18n(undefined, event) → useRuntimeConfig(event), which clones the whole runtime config and walks
every key through splitByCase/kebabCase/snakeCase looking for process.env overrides — once per request, including
requests that will never touch i18n.

Measured impact, same build, only difference being a guard on the request hook:

┌───────────────────────────────────────────────────────────────┬───────┐
│ │ rps │
├───────────────────────────────────────────────────────────────┼───────┤
│ 10.6.0 as-is │ ~2065 │
├───────────────────────────────────────────────────────────────┼───────┤
│ 10.6.0 with the request hook skipping buildAssetsDir │ ~8290 │
├───────────────────────────────────────────────────────────────┼───────┤
│ (our previous stack, Nuxt 3.15.4 + i18n 9.5.6, for reference) │ ~9060 │
└───────────────────────────────────────────────────────────────┴───────┘

The guard we tested:

const { buildAssetsDir } = useRuntimeConfig().app // resolved once at plugin setup

nitro.hooks.hook('request', async (event) => {
if (event.path.startsWith(buildAssetsDir)) return
await initializeI18nContext(event)
})

Static asset 404s still return 404 (Nitro throws those before the renderer, so render:before never runs and
useI18nContext is never reached), and SSR requests are untouched.

Worth noting our results don't contradict yours — they're complementary. We run 2–3 locales, and createLocaleConfigs
didn't appear in our profile at all, which lines up with your benchmark showing the win scaling with locale count.

Happy to open a separate issue with the full reproduction and profiles, and a PR for the guard, if that's useful. Not
suggesting any change to this PR.

@BobbieGoede Could you take a look when you get a chance? I don't have much experience contributing, so I posted here since this looked like the same problem — happy to move it to a separate issue if that's a better place.

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