Skip to content

Instantly share code, notes, and snippets.

@ryanatkn
Last active March 12, 2026 11:45
Show Gist options
  • Select an option

  • Save ryanatkn/1a2ad5f0988e48945b783fa9c4767c67 to your computer and use it in GitHub Desktop.

Select an option

Save ryanatkn/1a2ad5f0988e48945b783fa9c4767c67 to your computer and use it in GitHub Desktop.
JS UI framework token cost: how many LLM tokens does each framework need for the same patterns? Analyzing component-party.dev

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 svelte5

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 - 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).

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.');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment