-
Notifications
You must be signed in to change notification settings - Fork 136
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
…hmark-ssrssg add bundle size benchmark
- Loading branch information
Showing
31 changed files
with
1,547 additions
and
311 deletions.
There are no files selected for viewing
30 changes: 30 additions & 0 deletions
30
inlang/packages/paraglide/paraglide-js/benchmark/.gitignore
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
# Logs | ||
logs | ||
*.log | ||
npm-debug.log* | ||
yarn-debug.log* | ||
yarn-error.log* | ||
pnpm-debug.log* | ||
lerna-debug.log* | ||
|
||
node_modules | ||
dist | ||
dist-ssr | ||
*.local | ||
|
||
# Editor directories and files | ||
.vscode/* | ||
!.vscode/extensions.json | ||
.idea | ||
.DS_Store | ||
*.suo | ||
*.ntvs* | ||
*.njsproj | ||
*.sln | ||
*.sw? | ||
|
||
# dynamically generated | ||
src/pages | ||
src/i18n/generated.ts | ||
messages | ||
project.inlang |
109 changes: 109 additions & 0 deletions
109
inlang/packages/paraglide/paraglide-js/benchmark/README.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
# Benchmark | ||
|
||
## Introduction | ||
|
||
The internationalization (i18n) library you choose can significantly impact your application's bundle size and overall performance. | ||
|
||
This benchmark provides data-driven insights comparing Paraglide-JS with i18next, one of the most popular i18n solutions. | ||
|
||
### What is Being Tested | ||
|
||
This benchmark evaluates the bundle size implications of both libraries across various scenarios: | ||
|
||
- **Number of Locales**: How does an i18n library scale with the number of locales? | ||
- **Number of Message per page**: How does an i18n library scale with the number of messages that are used on a given page? | ||
- **Library Implementation Variants**: Testing different implementation approaches: | ||
- **Paraglide**: | ||
- `default`: Standard implementation | ||
- `experimental-`: Experimental implementation with per-locale splitting | ||
- **i18next**: | ||
- `http-backend`: Using HTTP backend for loading translations | ||
- **Namespace Size**: Testing how the total available messages in a namespace affects bundle size | ||
|
||
### Key Considerations | ||
|
||
#### The Paraglide Approach | ||
|
||
Paraglide takes a different approach to i18n by compiling messages into tree-shakable functions. Bundlers are able to tree-shake and include only the used messages for any given page. This has important implications. | ||
|
||
#### Work in Progress | ||
|
||
We are actively working on per-locale splitting to further optimize bundle size for applications with many languages and messages. Find more information in issue [#88](https://github.com/opral/inlang-paraglide-js/issues/88). | ||
|
||
## Setup | ||
|
||
The benchmark creates a static website for each configuration (library variant, number of locales, messages per page, and namespace size). Each website is loaded in a headless browser, and the total transfer size is measured. | ||
|
||
### Library modes | ||
|
||
Each library is tested in different modes: | ||
|
||
- **Paraglide**: | ||
- **default**: Out of the box Paraglide JS with no additional compiler options. | ||
- **<compiler-option>**: Mode with a [compiler option](https://inlang.com/m/gerre34r/library-inlang-paraglideJs/compiler-options) that is being tested. | ||
- **i18next**: | ||
- **default**: The default i18next implementation [source](https://www.i18next.com/overview/getting-started#basic-sample). | ||
- **http-backend**: i18next implementation using HTTP backend for loading translations on dmeand [source](https://github.com/i18next/i18next-http-backend). | ||
|
||
### Metrics | ||
|
||
- **Total Transfer Size**: The total transfer size of the website. | ||
- **Messages**: The number of messages per page. | ||
- **Namespace Size**: The total number of messages in the namespace. | ||
- **Locales**: The number of locales. | ||
|
||
### Limitations | ||
|
||
**Choosing the number of messages and namespace varies between projects** | ||
|
||
Some teams use per component namespacing while other teams have one namespace for their entire project. In cal.com's case, every component that uses i18n [loads at least 3000 messages per locale](https://github.com/calcom/cal.com/blob/b5e08ea80ffecff04363a18789491065dd6ccc0b/apps/web/public/static/locales/en/common.json). | ||
|
||
To the point of the problem: Avoiding manual chunking of messages into namespaces is the benefit of Paraglide JS. The bundler tree-shakes all unused messages, making namespaces redundant. | ||
|
||
|
||
## Results | ||
|
||
|
||
`Locales: 5` | ||
`Messages: 200` | ||
`Namespace Size: 500` | ||
|
||
| Library | Total Transfer Size | | ||
|---------------------------------------------------|---------------------| | ||
| paraglide (experimental-middleware-optimizations) | 31.5 KB | | ||
| paraglide (default) | 90.1 KB | | ||
| i18next (default) | 694.3 KB | | ||
| i18next (http-backend) | 191.0 KB | | ||
|
||
|
||
`Locales: 10` | ||
`Messages: 200` | ||
`Namespace Size: 500` | ||
|
||
| Library | Total Transfer Size | | ||
|---------------------------------------------------|---------------------| | ||
| paraglide (experimental-middleware-optimizations) | 31.6 KB | | ||
| paraglide (default) | 148.3 KB | | ||
| i18next (default) | 694.3 KB | | ||
| i18next (http-backend) | 191.0 KB | | ||
|
||
|
||
`Locales: 20` | ||
`Messages: 200` | ||
`Namespace Size: 500` | ||
|
||
| Library | Total Transfer Size | | ||
|---------------------------------------------------|---------------------| | ||
| paraglide (experimental-middleware-optimizations) | 31.7 KB | | ||
| paraglide (default) | 266.1 KB | | ||
| i18next (default) | 694.3 KB | | ||
| i18next (http-backend) | 191.1 KB | | ||
|
||
|
||
## Contributing | ||
|
||
Contributions to improve the benchmark are welcome. | ||
|
||
1. adjust the build matrix in `build.config.ts` | ||
2. run `pnpm run bench` to build the benchmark | ||
3. run `pnpm run preview` to preview the results |
247 changes: 247 additions & 0 deletions
247
inlang/packages/paraglide/paraglide-js/benchmark/bench.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,247 @@ | ||
import { chromium } from "playwright"; | ||
import { builds, buildConfigToString } from "./build.config.ts"; | ||
import { startServer } from "./server.ts"; | ||
import fs from "node:fs"; | ||
import { runBuilds } from "./build.ts"; | ||
import csvToMarkdown from "csv-to-markdown-table"; | ||
|
||
// Function to benchmark network transfer | ||
async function benchmarkBuild(url: string): Promise<number> { | ||
const browser = await chromium.launch(); | ||
const page = await browser.newPage(); | ||
|
||
// Track responses and their sizes | ||
const responsePromises: Promise<number>[] = []; | ||
page.on("response", (response) => { | ||
// Only add successful responses | ||
if (response.status() >= 200 && response.status() < 400) { | ||
const promise = response | ||
.body() | ||
.then((body) => body.length) | ||
.catch(() => 0); | ||
responsePromises.push(promise); | ||
} | ||
}); | ||
|
||
console.log(`Benchmarking ${url}`); | ||
|
||
// Format URL properly with server address | ||
await page.goto(url, { waitUntil: "networkidle" }); | ||
|
||
// Wait for all response promises to resolve before closing the browser | ||
const sizes = await Promise.all(responsePromises); | ||
const totalBytes = sizes.reduce((sum, size) => sum + size, 0); | ||
|
||
await browser.close(); | ||
|
||
return totalBytes; // Return bytes | ||
} | ||
|
||
// Format bytes to human-readable format (B, KB) | ||
function formatBytes(bytes: number): string { | ||
if (bytes < 1024) { | ||
return `${bytes} B`; | ||
} else { | ||
return `${(bytes / 1024).toFixed(1)} KB`; | ||
} | ||
} | ||
|
||
async function runBenchmarks() { | ||
await runBuilds(); | ||
|
||
// Get unique libraries, library modes, locales, messages | ||
const libraries = [...new Set(builds.map((build) => build.library))].sort( | ||
(a, b) => | ||
a === "paraglide" ? -1 : b === "paraglide" ? 1 : a.localeCompare(b) | ||
); | ||
|
||
// Get library modes grouped by library | ||
const libraryModes: Record<string, string[]> = {}; | ||
for (const build of builds) { | ||
if (!libraryModes[build.library]) { | ||
libraryModes[build.library] = []; | ||
} | ||
if (!libraryModes[build.library].includes(build.libraryMode)) { | ||
libraryModes[build.library].push(build.libraryMode); | ||
} | ||
} | ||
|
||
const locales = [...new Set(builds.map((build) => build.locales))].sort( | ||
(a, b) => a - b | ||
); | ||
const messages = [...new Set(builds.map((build) => build.messages))].sort( | ||
(a, b) => a - b | ||
); | ||
const namespaceSizes = [ | ||
...new Set(builds.map((build) => build.namespaceSize)), | ||
] | ||
.filter((size): size is number => size !== undefined && size !== null) | ||
.sort((a, b) => a - b); | ||
|
||
const port = 8110; | ||
|
||
const server = startServer(port); // Start server | ||
|
||
// Create a map of unique library+mode combinations | ||
const libraryModeMap = new Map<string, string>(); | ||
for (const build of builds) { | ||
const key = `${build.library}-${build.libraryMode}`; | ||
const displayName = `${build.library} (${build.libraryMode})`; | ||
libraryModeMap.set(key, displayName); | ||
} | ||
|
||
// Create results object to store benchmark data | ||
// Structure: locale -> message -> namespaceSize (as string) -> libraryModeKey -> size | ||
const results: Record< | ||
number, | ||
Record<number, | ||
Record<string, | ||
Record<string, number> | ||
> | ||
> | ||
> = {}; | ||
|
||
// Initialize results structure | ||
for (const locale of locales) { | ||
results[locale] = {}; | ||
for (const message of messages) { | ||
results[locale][message] = {}; | ||
// Use 'default' as the key when namespaceSize is undefined | ||
for (const namespaceSize of [...namespaceSizes, undefined]) { | ||
const nsKey = namespaceSize?.toString() || "default"; | ||
results[locale][message][nsKey] = {}; | ||
for (const [libraryModeKey] of libraryModeMap) { | ||
results[locale][message][nsKey][libraryModeKey] = 0; | ||
} | ||
} | ||
} | ||
} | ||
|
||
// Run benchmarks and collect results | ||
const benchmarkPromises: Array<Promise<void>> = []; | ||
for (const build of builds) { | ||
const name = buildConfigToString(build); | ||
const promise = benchmarkBuild(`http://localhost:${port}/${name}`).then( | ||
(size) => { | ||
const nsKey = build.namespaceSize?.toString() || "default"; | ||
results[build.locales][build.messages][nsKey][ | ||
`${build.library}-${build.libraryMode}` | ||
] = size; | ||
} | ||
); | ||
benchmarkPromises.push(promise); | ||
} | ||
|
||
// Wait for all benchmarks to complete | ||
await Promise.all(benchmarkPromises); | ||
|
||
server.close(); | ||
|
||
// Generate markdown with tables | ||
let markdownOutput = "# Benchmark Results\n\n"; | ||
|
||
// Create a unique set of configurations | ||
type ConfigKey = string; | ||
type LibraryResults = Record<string, number>; // library-mode key -> size | ||
|
||
// Group results by configuration | ||
const configResults = new Map<ConfigKey, { | ||
locale: number, | ||
message: number, | ||
namespaceSize: number | undefined, | ||
results: LibraryResults | ||
}>(); | ||
|
||
// Collect all configurations and their results | ||
for (const locale of locales) { | ||
for (const message of messages) { | ||
for (const namespaceSize of [...namespaceSizes, undefined]) { | ||
const nsKey = namespaceSize?.toString() || "default"; | ||
const nsValue = namespaceSize !== undefined ? namespaceSize : message; | ||
|
||
// Create a unique key for this configuration | ||
const configKey = `l${locale}-m${message}-ns${nsValue}`; | ||
|
||
if (!configResults.has(configKey)) { | ||
configResults.set(configKey, { | ||
locale, | ||
message, | ||
namespaceSize: nsValue, | ||
results: {} | ||
}); | ||
} | ||
|
||
// Add library results for this configuration | ||
const libraryResults = configResults.get(configKey)!.results; | ||
for (const [key, _] of libraryModeMap) { | ||
libraryResults[key] = results[locale][message][nsKey][key]; | ||
} | ||
} | ||
} | ||
} | ||
|
||
// Sort configurations | ||
const sortedConfigs = Array.from(configResults.entries()) | ||
.sort((a, b) => { | ||
// First sort by locale | ||
if (a[1].locale !== b[1].locale) { | ||
return a[1].locale - b[1].locale; | ||
} | ||
// Then by message count | ||
if (a[1].message !== b[1].message) { | ||
return a[1].message - b[1].message; | ||
} | ||
// Finally by namespace size | ||
return (a[1].namespaceSize || 0) - (b[1].namespaceSize || 0); | ||
}); | ||
|
||
// Generate a section for each configuration | ||
let runNumber = 1; | ||
for (const [configKey, config] of sortedConfigs) { | ||
// Skip configurations with no results | ||
const hasResults = Object.values(config.results).some(size => size > 0); | ||
if (!hasResults) continue; | ||
|
||
// Add configuration details as code blocks | ||
markdownOutput += `\`Locales: ${config.locale}\` \n`; | ||
markdownOutput += `\`Messages: ${config.message}\` \n`; | ||
markdownOutput += `\`Namespace Size: ${config.namespaceSize}\` \n\n`; | ||
|
||
// Create table for this configuration | ||
let tableData: string[][] = []; | ||
tableData.push(["Library", "Total Transfer Size"]); | ||
|
||
// Sort libraries (paraglide first, then i18next) | ||
const sortedLibraryEntries = Object.entries(config.results) | ||
.sort((a, b) => { | ||
if (a[0].startsWith("paraglide")) return -1; // paraglide comes first | ||
if (b[0].startsWith("paraglide")) return 1; | ||
return a[0].localeCompare(b[0]); | ||
}); | ||
|
||
// Add library results | ||
for (const [key, size] of sortedLibraryEntries) { | ||
if (size === 0) continue; // Skip libraries with no results | ||
|
||
const displayName = libraryModeMap.get(key) || key; | ||
tableData.push([displayName, formatBytes(size)]); | ||
} | ||
|
||
// Convert to CSV format for csvToMarkdown | ||
const csvData = tableData.map(row => row.join(',')).join('\n'); | ||
|
||
// Convert to markdown table using csvToMarkdown | ||
const markdownTable = csvToMarkdown(csvData, ',', true); | ||
|
||
markdownOutput += markdownTable + '\n\n'; | ||
runNumber++; | ||
} | ||
|
||
// Write the markdown tables to a file for easy copying | ||
fs.writeFileSync("benchmark-results.md", markdownOutput); | ||
console.log("\nResults saved to benchmark-results.md"); | ||
|
||
return markdownOutput; | ||
} | ||
|
||
runBenchmarks(); |
Oops, something went wrong.