JS UI framework token cost: how many LLM tokens does each framework need for the same patterns? Analyzing component-party.dev
NOTE: AI-generated
$ git clone https://github.com/matschik/component-party.dev
$ deno run --allow-read count_component_party_tokens.ts svelte5How many tokens does it take to express the same UI pattern in different JS frameworks?
This script counts tokens from github.com/matschik/component-party.dev - 22 patterns
across 18 frameworks - using gpt-tokenizer on npm.
Fewer tokens means lower costs, faster inference, and more remaining context
for the task - helping models produce better results on harder problems at lower prices.
The data is imperfect, so take these as rough figures, but the script attempts to clean unfair patterns across frameworks (currently only React PropTypes usage is removed because it has no equivalent elsewhere).
- source: https://github.com/matschik/component-party.dev/tree/main/content
- tokenizer: gpt-tokenizer (cl100k_base) https://www.npmjs.com/package/gpt-tokenizer
- baseline: svelte5 (2072 tokens across 22 sections)
Results (baseline: svelte5)
─────────────────────────────────────────────────────────────────────────────────────────
# Framework Tokens Delta % Chars Median Files Sections
─────────────────────────────────────────────────────────────────────────────────────────
1 marko 1611 -461 -22% 4935 61 29 22/22
2 aurelia2 1943 -129 -6% 6496 62 52 22/22
3 svelte5 (baseline) 2072 +0 +0% 6930 58.5 31 22/22
4 svelte4 2098 +26 +1% 7032 60.5 31 22/22
5 alpine 2195 +123 +6% 5907 46 21 21+1/22
6 vue3 2459 +387 +19% 8334 71 30 22/22
7 ripple 2497 +425 +21% 6385 77 26 20+2/22
8 aurelia1 2507 +435 +21% 7261 57.5 44 20+2/22
9 emberOctane 2600 +528 +25% 9148 116 44 21+1/22
10 emberPolaris 2600 +528 +25% 9859 84.5 31 22/22
11 mithril 2686 +614 +30% 9652 91.5 30 22/22
12 solid 2694 +622 +30% 10025 87.5 31 22/22
13 vue2 2730 +658 +32% 9280 91 29 22/22
14 react 2755 +683 +33% 10136 98.5 32 22/22
15 qwik 3062 +990 +48% 11359 99 29 22/22
16 angularRenaissance 3277 +1205 +58% 12429 96.5 31 22/22
17 lit 3830 +1758 +85% 14267 128.5 30 22/22
18 angular 4172 +2100 +101% 16318 124.5 32 22/22
─────────────────────────────────────────────────────────────────────────────────────────
TOTAL 47788 165753
Column guide:
Tokens Total tokens (with imputed estimates for missing sections)
Delta Absolute token difference from baseline
% Percentage difference from baseline
Chars Raw character count (actual only, no imputation)
Median Median tokens per section
Files Total source files across all sections
Sections Sections present / total (N+M = N actual + M imputed)
Pass --verbose for per-section breakdown and extended stats.
/**
* Counts tokens per framework in github.com/matschik/component-party.dev/content
* using gpt-tokenizer (cl100k_base). Missing sections are imputed via each
* framework's weighted median ratio to cross-framework section medians.
*
* @example
* ```sh
* git clone https://github.com/matschik/component-party.dev
* deno run --allow-read count_component_party_tokens.ts [baseline] [--verbose]
* ```
*/
import {encode} from 'npm:gpt-tokenizer';
import {join} from 'jsr:@std/path';
import {walk} from 'jsr:@std/fs/walk';
const CONTENT_DIR = join(import.meta.dirname!, 'component-party.dev', 'content');
interface SectionData {
files: string[];
tokens: number;
chars: number;
}
// Collect all content sections and per-lang data
const sections = new Set<string>();
const lang_sections = new Map<string, Map<string, SectionData>>();
for await (const entry of Deno.readDir(CONTENT_DIR)) {
if (!entry.isDirectory) continue;
const category_path = join(CONTENT_DIR, entry.name);
for await (const section_entry of Deno.readDir(category_path)) {
if (!section_entry.isDirectory) continue;
const section_id = `${entry.name}/${section_entry.name}`;
sections.add(section_id);
const section_path = join(category_path, section_entry.name);
for await (const lang_entry of Deno.readDir(section_path)) {
if (!lang_entry.isDirectory) continue;
const lang = lang_entry.name;
if (!lang_sections.has(lang)) lang_sections.set(lang, new Map());
const lang_map = lang_sections.get(lang)!;
const lang_dir = join(section_path, lang);
const files: string[] = [];
let combined = '';
for await (const file_entry of walk(lang_dir, {includeDirs: false})) {
let content = await Deno.readTextFile(file_entry.path);
// Strip React PropTypes — runtime prop validation with no equivalent
// in other frameworks. Removes import and .propTypes = {...} blocks.
if (lang === 'react') {
content = content.replace(/import PropTypes from "prop-types";\n\n?/g, '');
content = content.replace(/\n\n\w+\.propTypes = \{[^}]*\};\n?/gs, '\n');
} // any other cleanup? didn't find any in a quick pass
combined += content;
files.push(file_entry.path.replace(lang_dir + '/', ''));
}
const tokens = encode(combined).length;
lang_map.set(section_id, {files, tokens, chars: combined.length});
}
}
}
const sorted_sections = [...sections].sort();
// Parse CLI args: [baseline] [--verbose]
const all_langs = [...lang_sections.keys()];
const verbose = Deno.args.includes('--verbose');
const positional_args = Deno.args.filter((a) => !a.startsWith('--'));
const baseline_arg = positional_args[0];
const baseline_lang =
baseline_arg && lang_sections.has(baseline_arg)
? baseline_arg
: all_langs.reduce((min, lang) => {
const min_total = [...lang_sections.get(min)!.values()].reduce((s, d) => s + d.tokens, 0);
const lang_total = [...lang_sections.get(lang)!.values()].reduce((s, d) => s + d.tokens, 0);
return lang_total < min_total ? lang : min;
});
if (baseline_arg && !lang_sections.has(baseline_arg)) {
console.error(`Unknown framework "${baseline_arg}". Available: ${all_langs.sort().join(', ')}`);
Deno.exit(1);
}
const baseline_sections = lang_sections.get(baseline_lang)!;
// --- Statistics helpers ---
const median = (values: number[]): number => {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
};
const quartiles = (values: number[]): {q1: number; q3: number} => {
const sorted = [...values].sort((a, b) => a - b);
const lower = sorted.slice(0, Math.floor(sorted.length / 2));
const upper = sorted.slice(Math.ceil(sorted.length / 2));
return {q1: median(lower), q3: median(upper)};
};
/** Weighted median: values paired with weights. */
const weighted_median = (pairs: {value: number; weight: number}[]): number => {
const sorted = [...pairs].sort((a, b) => a.value - b.value);
const total_weight = sorted.reduce((s, p) => s + p.weight, 0);
let cumulative = 0;
for (const p of sorted) {
cumulative += p.weight;
if (cumulative >= total_weight / 2) return p.value;
}
return sorted[sorted.length - 1].value;
};
const fmt_pct = (value: number, base: number): string => {
const ratio = value / base;
return `${ratio >= 1 ? '+' : ''}${((ratio - 1) * 100).toFixed(0)}%`;
};
// --- Compute per-section medians (across all frameworks) ---
const section_medians = new Map<string, {tokens: number; chars: number}>();
for (const section_id of sorted_sections) {
const token_values: number[] = [];
const char_values: number[] = [];
for (const section_map of lang_sections.values()) {
const data = section_map.get(section_id);
if (data) {
token_values.push(data.tokens);
char_values.push(data.chars);
}
}
section_medians.set(section_id, {
tokens: median(token_values),
chars: median(char_values),
});
}
// --- Compute per-lang ratios to section medians (baseline-independent) ---
interface LangRatios {
ratios: number[];
median_ratio: number;
iqr: {q1: number; q3: number};
}
const lang_ratio_data = new Map<string, LangRatios>();
for (const [lang, section_map] of lang_sections) {
const ratios: number[] = [];
const weighted_pairs: {value: number; weight: number}[] = [];
for (const [section_id, data] of section_map) {
const sm = section_medians.get(section_id)!;
if (sm.tokens > 0) {
const ratio = data.tokens / sm.tokens;
ratios.push(ratio);
weighted_pairs.push({value: ratio, weight: sm.tokens});
}
}
const med = weighted_pairs.length > 0 ? weighted_median(weighted_pairs) : 1;
const q = quartiles(ratios);
lang_ratio_data.set(lang, {
ratios,
median_ratio: med,
iqr: q,
});
}
// --- Impute missing sections: section_median * framework's median ratio ---
interface ImputedSection {
tokens: number;
chars: number;
imputed: boolean;
}
const lang_all_sections = new Map<string, Map<string, ImputedSection>>();
for (const [lang, section_map] of lang_sections) {
const ratio = lang_ratio_data.get(lang)!.median_ratio;
const all = new Map<string, ImputedSection>();
for (const section_id of sorted_sections) {
const actual = section_map.get(section_id);
if (actual) {
all.set(section_id, {tokens: actual.tokens, chars: actual.chars, imputed: false});
} else {
const sm = section_medians.get(section_id)!;
all.set(section_id, {
tokens: Math.round(sm.tokens * ratio),
chars: Math.round(sm.chars * ratio),
imputed: true,
});
}
}
lang_all_sections.set(lang, all);
}
// --- Compute final stats ---
interface LangStats {
actual_tokens: number;
actual_chars: number;
adjusted_tokens: number;
imputed_tokens: number;
imputed_count: number;
imputed_pct: number;
total_files: number;
section_count: number;
actual_token_values: number[];
tok_per_char: number;
}
const lang_stats = new Map<string, LangStats>();
for (const [lang, section_map] of lang_sections) {
let actual_tokens = 0;
let actual_chars = 0;
let total_files = 0;
const actual_token_values: number[] = [];
for (const data of section_map.values()) {
actual_tokens += data.tokens;
actual_chars += data.chars;
total_files += data.files.length;
actual_token_values.push(data.tokens);
}
let adjusted_tokens = 0;
let imputed_tokens = 0;
let imputed_count = 0;
for (const data of lang_all_sections.get(lang)!.values()) {
adjusted_tokens += data.tokens;
if (data.imputed) {
imputed_tokens += data.tokens;
imputed_count++;
}
}
lang_stats.set(lang, {
actual_tokens,
actual_chars,
adjusted_tokens,
imputed_tokens,
imputed_count,
imputed_pct: adjusted_tokens > 0 ? (imputed_tokens / adjusted_tokens) * 100 : 0,
total_files,
section_count: section_map.size,
actual_token_values,
tok_per_char: actual_chars > 0 ? actual_tokens / actual_chars : 0,
});
}
const baseline = lang_stats.get(baseline_lang)!;
const by_adjusted = [...lang_stats.keys()].sort(
(a, b) => lang_stats.get(a)!.adjusted_tokens - lang_stats.get(b)!.adjusted_tokens,
);
// === Per-section breakdown (--verbose only) ===
if (verbose) {
console.log('=== Per-Section Token Counts ===\n');
for (const section of sorted_sections) {
console.log(`--- ${section} ---`);
const baseline_tokens = baseline_sections.get(section)?.tokens ?? 0;
const entries: {lang: string; tokens: number; file_count: number; imputed: boolean}[] = [];
for (const lang of by_adjusted) {
const actual = lang_sections.get(lang)?.get(section);
if (actual) {
entries.push({
lang,
tokens: actual.tokens,
file_count: actual.files.length,
imputed: false,
});
} else {
const imputed = lang_all_sections.get(lang)?.get(section);
if (imputed) {
entries.push({lang, tokens: imputed.tokens, file_count: 0, imputed: true});
}
}
}
entries.sort((a, b) => a.tokens - b.tokens);
for (const {lang, tokens, file_count, imputed} of entries) {
const diff = tokens - baseline_tokens;
const diff_str = baseline_tokens > 0 ? `${diff >= 0 ? '+' : ''}${diff}` : 'N/A';
const pct = baseline_tokens > 0 ? fmt_pct(tokens, baseline_tokens) : 'N/A';
const marker = lang === baseline_lang ? ' *' : '';
const files_note = file_count > 1 ? ` (${file_count} files)` : '';
const imputed_note = imputed ? ' ~imputed' : '';
console.log(
` ${lang.padEnd(22)} ${String(tokens).padStart(6)} ${diff_str.padStart(6)} ${pct.padStart(8)}${marker}${files_note}${imputed_note}`,
);
}
console.log();
}
}
//
// Everything below here is the gist-friendly output.
//
console.log(`How many tokens does it take to express the same UI pattern in different JS frameworks?
This script counts tokens from github.com/matschik/component-party.dev - ${sorted_sections.length} patterns
across ${by_adjusted.length} frameworks - using \`gpt-tokenizer\` on npm.
Fewer tokens means lower costs, faster inference, and more remaining context
for the task - helping models produce better results on harder problems at lower prices.
`);
console.log(
`The data is imperfect, so take these as rough figures,
but the script attempts to clean unfair patterns across frameworks
(e.g. React PropTypes usage is removed because it has no equivalent elsewhere).
`,
);
console.log(`Source: github.com/matschik/component-party.dev/tree/main/content`);
console.log(`Tokenizer: gpt-tokenizer (cl100k_base) https://www.npmjs.com/package/gpt-tokenizer`);
console.log(
`Baseline: ${baseline_lang} (${baseline.actual_tokens} tokens across ${baseline.section_count} sections)`,
);
console.log();
// === Coverage audit ===
const frameworks_with_missing: {lang: string; missing: string[]}[] = [];
for (const lang of by_adjusted) {
const present = lang_sections.get(lang)!;
const missing = sorted_sections.filter((s) => !present.has(s));
if (missing.length > 0) {
frameworks_with_missing.push({lang, missing});
}
}
// if (frameworks_with_missing.length > 0) {
// console.log('Coverage gaps');
// console.log('─'.repeat(70));
// console.log(
// `${by_adjusted.length - frameworks_with_missing.length}/${by_adjusted.length} frameworks have all ${sorted_sections.length} sections. Gaps filled via imputation`,
// );
// console.log("(each framework's median ratio to the cross-framework section median,");
// console.log('applied to the missing section median — baseline-independent).\n');
// for (const {lang, missing} of frameworks_with_missing) {
// const rd = lang_ratio_data.get(lang)!;
// const stats = lang_stats.get(lang)!;
// console.log(
// ` ${lang} — ${missing.length} missing, ratio ${rd.median_ratio.toFixed(2)}x (IQR ${rd.iqr.q1.toFixed(2)}–${rd.iqr.q3.toFixed(2)}x), ${stats.imputed_pct.toFixed(0)}% of adjusted total imputed`,
// );
// for (const s of missing) {
// const imputed = lang_all_sections.get(lang)!.get(s)!;
// const sm = section_medians.get(s)!;
// console.log(` ${s} (median: ${sm.tokens} → ~${imputed.tokens})`);
// }
// }
// console.log();
// }
// === Summary table ===
const W = 89;
console.log(`\nResults (baseline: ${baseline_lang})`);
console.log('─'.repeat(W));
console.log(
[
'#'.padStart(3),
'Framework'.padEnd(24),
'Tokens'.padStart(7),
'Delta'.padStart(7),
'%'.padStart(7),
'Chars'.padStart(7),
'Median'.padStart(7),
'Files'.padStart(6),
'Sections'.padStart(13),
].join(' '),
);
console.log('─'.repeat(W));
let rank = 0;
for (const lang of by_adjusted) {
rank++;
const stats = lang_stats.get(lang)!;
const med = median(stats.actual_token_values);
const tokens_display = stats.imputed_count > 0 ? stats.adjusted_tokens : stats.actual_tokens;
const delta = tokens_display - baseline.adjusted_tokens;
const delta_str = `${delta >= 0 ? '+' : ''}${delta}`;
const pct = fmt_pct(tokens_display, baseline.adjusted_tokens);
const marker = lang === baseline_lang ? ' (baseline)' : '';
const sect_str =
stats.imputed_count > 0
? `${stats.section_count}+${stats.imputed_count}/${sorted_sections.length}`
: `${String(stats.section_count).padStart(5)}/${sorted_sections.length}`;
console.log(
[
String(rank).padStart(3),
(lang + marker).padEnd(24),
String(tokens_display).padStart(7),
delta_str.padStart(7),
pct.padStart(7),
String(stats.actual_chars).padStart(7),
String(med).padStart(7),
String(stats.total_files).padStart(6),
sect_str.padStart(13),
].join(' '),
);
}
const grand_adjusted = [...lang_stats.values()].reduce((a, b) => a + b.adjusted_tokens, 0);
const grand_chars = [...lang_stats.values()].reduce((a, b) => a + b.actual_chars, 0);
console.log('─'.repeat(W));
console.log(
[
''.padStart(3),
'TOTAL'.padEnd(24),
String(grand_adjusted).padStart(7),
''.padStart(7),
''.padStart(7),
String(grand_chars).padStart(7),
].join(' '),
);
console.log(`
Column guide:
Tokens Total tokens (with imputed estimates for missing sections)
Delta Absolute token difference from baseline
% Percentage difference from baseline
Chars Raw character count (actual only, no imputation)
Median Median tokens per section
Files Total source files across all sections
Sections Sections present / total (N+M = N actual + M imputed)`);
if (verbose) {
// Extended stats table
const W2 = 80;
console.log(`\nExtended stats`);
console.log('─'.repeat(W2));
console.log(
[
''.padStart(3),
'Framework'.padEnd(24),
'Tok/Ch'.padStart(7),
'Ratio'.padStart(7),
'IQR'.padStart(13),
'Min'.padStart(7),
'Max'.padStart(7),
'Imputed%'.padStart(9),
].join(' '),
);
console.log('─'.repeat(W2));
for (const lang of by_adjusted) {
const stats = lang_stats.get(lang)!;
const rd = lang_ratio_data.get(lang)!;
const tok_char = stats.tok_per_char.toFixed(2);
const ratio_str = lang === baseline_lang ? '—' : `${rd.median_ratio.toFixed(2)}x`;
const iqr_str =
lang === baseline_lang ? '—' : `${rd.iqr.q1.toFixed(2)}–${rd.iqr.q3.toFixed(2)}`;
const min_ratio = lang === baseline_lang ? '—' : `${Math.min(...rd.ratios).toFixed(2)}x`;
const max_ratio = lang === baseline_lang ? '—' : `${Math.max(...rd.ratios).toFixed(2)}x`;
const imputed_pct = stats.imputed_count > 0 ? `${stats.imputed_pct.toFixed(0)}%` : '—';
console.log(
[
''.padStart(3),
lang.padEnd(24),
tok_char.padStart(7),
ratio_str.padStart(7),
iqr_str.padStart(13),
min_ratio.padStart(7),
max_ratio.padStart(7),
imputed_pct.padStart(9),
].join(' '),
);
}
console.log('─'.repeat(W2));
console.log(`
Tok/Ch Tokens per character (tokenization density)
Ratio Median of per-section token ratios to cross-framework section medians
IQR Interquartile range of ratios (tight = consistent verbosity)
Min/Max Most favorable and least favorable per-section ratios
Imputed% Percentage of adjusted total that was estimated`);
} else {
console.log('\nPass --verbose for per-section breakdown and extended stats.');
}