Front-end Engineering Lab
PatternsInternationalization

Lazy Loading Translations

Optimize bundle size by code splitting translation files

Lazy Loading Translations

Loading all translations upfront bloats your bundle. Lazy loading translations by route or feature reduces initial load time and improves performance.

The Problem

// ❌ BAD: Load all translations
import en from './messages/en.json'; // 500KB
import pt from './messages/pt.json'; // 500KB
import es from './messages/es.json'; // 500KB
import ar from './messages/ar.json'; // 500KB
import fr from './messages/fr.json'; // 500KB

// Total: 2.5MB loaded upfront!

Strategies

1. Split by Route

messages/
├── en/
│   ├── common.json        # Always loaded
│   ├── home.json          # Load on homepage
│   ├── dashboard.json     # Load on dashboard
│   ├── products.json      # Load on products page
│   └── checkout.json      # Load on checkout

2. Split by Feature

messages/
├── en/
│   ├── common.json        # Shared terms
│   ├── auth.json          # Login/signup
│   ├── cart.json          # Shopping cart
│   └── profile.json       # User profile

3. Hybrid Approach

messages/
├── en/
│   ├── core/              # Critical, always loaded
│   │   ├── common.json
│   │   ├── navigation.json
│   │   └── errors.json
│   └── features/          # Lazy loaded
│       ├── dashboard.json
│       ├── products.json
│       └── checkout.json

Implementation (next-intl)

Basic Lazy Loading

// i18n.ts
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async ({ locale }) => {
  // Only load common messages initially
  const commonMessages = (await import(`./messages/${locale}/common.json`)).default;
  
  return {
    messages: commonMessages,
  };
});

Route-Based Loading

// app/[locale]/dashboard/page.tsx
import { getTranslations } from 'next-intl/server';

export default async function DashboardPage() {
  // Load dashboard translations on-demand
  const t = await getTranslations('dashboard');
  
  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('welcome')}</p>
    </div>
  );
}

Dynamic Import in Components

'use client';

import { useState, useEffect } from 'react';
import { useLocale } from 'next-intl';

export function FeatureComponent() {
  const [messages, setMessages] = useState<any>(null);
  const locale = useLocale();

  useEffect(() => {
    // Lazy load feature translations
    import(`@/messages/${locale}/feature.json`)
      .then(module => setMessages(module.default))
      .catch(err => console.error('Failed to load translations', err));
  }, [locale]);

  if (!messages) {
    return <Skeleton />;
  }

  return <div>{messages.title}</div>;
}

Webpack/Next.js Magic Comments

// Optimize chunk naming and prefetching
const loadTranslations = async (locale: string) => {
  const messages = await import(
    /* webpackChunkName: "translations-[request]" */
    /* webpackPrefetch: true */
    `./messages/${locale}/dashboard.json`
  );
  
  return messages.default;
};

Preloading Strategy

Prefetch on Hover

'use client';

import { useRouter } from 'next/navigation';
import { useState } from 'react';

export function NavLink({ href, children }: Props) {
  const [prefetched, setPrefetched] = useState(false);
  const locale = useLocale();

  const prefetchTranslations = () => {
    if (prefetched) return;

    // Prefetch translations for this route
    const route = href.split('/').pop();
    import(`@/messages/${locale}/${route}.json`)
      .then(() => setPrefetched(true))
      .catch(() => {});
  };

  return (
    <a
      href={href}
      onMouseEnter={prefetchTranslations}
      onFocus={prefetchTranslations}
    >
      {children}
    </a>
  );
}

Prefetch on Viewport

'use client';

import { useEffect, useRef } from 'react';

export function PrefetchTranslations({ route }: { route: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const locale = useLocale();

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          // Prefetch when element is visible
          import(`@/messages/${locale}/${route}.json`);
          observer.disconnect();
        }
      },
      { rootMargin: '50px' }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => observer.disconnect();
  }, [locale, route]);

  return <div ref={ref} />;
}

Cache Management

// lib/translation-cache.ts
const cache = new Map<string, any>();

export async function getTranslations(locale: string, namespace: string) {
  const key = `${locale}:${namespace}`;
  
  // Return from cache if available
  if (cache.has(key)) {
    return cache.get(key);
  }
  
  // Load and cache
  const messages = await import(`@/messages/${locale}/${namespace}.json`);
  cache.set(key, messages.default);
  
  return messages.default;
}

// Clear cache when locale changes
export function clearTranslationCache() {
  cache.clear();
}

Bundle Analysis

Check Bundle Sizes

# Analyze Next.js bundle
ANALYZE=true npm run build

# Look for translation chunks
# Before: main.js (2.5MB with all translations)
# After: main.js (200KB) + dashboard.json (50KB) + products.json (80KB)

Webpack Bundle Analyzer

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // ... config
});

Progressive Loading

// Load translations in priority order
async function loadTranslations(locale: string) {
  // 1. Critical (block render)
  const common = await import(`./messages/${locale}/common.json`);
  
  // 2. Important (show spinner)
  const navigation = await import(`./messages/${locale}/navigation.json`);
  
  // 3. Nice-to-have (load in background)
  setTimeout(() => {
    import(`./messages/${locale}/footer.json`);
  }, 2000);
  
  return {
    ...common.default,
    ...navigation.default,
  };
}

Service Worker Caching

// sw.js - Cache translations
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  // Cache translation files
  if (url.pathname.includes('/messages/')) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        if (response) {
          return response;
        }
        
        return fetch(event.request).then((response) => {
          return caches.open('translations-v1').then((cache) => {
            cache.put(event.request, response.clone());
            return response;
          });
        });
      })
    );
  }
});

Metrics

Measure Impact

// Before lazy loading
Initial Bundle: 2.5MB
Time to Interactive: 4.2s

// After lazy loading
Initial Bundle: 250KB (90% smaller!)
Time to Interactive: 1.8s (57% faster!)
Dashboard route +50KB
Products route +80KB

Best Practices

  1. Always Load Common: Shared terms, navigation, errors
  2. Split by Route: Load translations when route loads
  3. Prefetch Smart: On hover/viewport for better UX
  4. Cache Aggressively: Translations don't change often
  5. Monitor Bundle Size: Track translation file sizes
  6. Lazy Load Features: Modal, drawer, tooltip translations
  7. Version Translations: Cache bust when updated

Common Pitfalls

Loading all translations: Large initial bundle
Split by route/feature

No prefetching: Delay when navigating
Prefetch on hover/viewport

Not caching: Re-download on every route
Cache in memory/SW

Too granular: 100 tiny files
Reasonable chunks (50-100KB)

Tools

  • Next.js: Automatic code splitting
  • Webpack: Magic comments for chunks
  • Vite: Dynamic imports
  • Rollup: Code splitting plugins

Testing

test('lazy loads translations', async () => {
  const { findByText } = render(<Dashboard />);
  
  // Initial render shows loading
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  // Wait for translations to load
  expect(await findByText('Dashboard Title')).toBeInTheDocument();
});

Lazy loading translations is essential for performance—don't force users to download languages they won't use!

On this page