Lesson 12 / 30
Flyweight
Saving memory through sharing: comparing, by object count and a flagged heap measurement, the arrangement where every shipment carries its own tariff copy during batch fee calculation against the arrangement gathering the unchanging tariff information into a shared object; the cost of sharing in the immutability it requires and its sharing width.
Contents
In the previous four patterns, the measure was files, types, and dependency count; all of them looked at the size of the source text. This lesson’s measure lives at run time: memory.
A batch fee job processes two hundred thousand shipment rows. Every shipment needs a piece of tariff information: the weight-tier table, the zone coefficients, the currency. This information’s content does not vary by shipment — it is identical across every shipment carrying the same tariff code. What varies by shipment is only weight and address. The flyweight pattern separates these two: the unchanging part is shared as intrinsic state in a single object, the varying part stays on the record itself as extrinsic state. The measures are the object count built and heap usage.
Problem: Two Hundred Thousand Copies of the Same Table
The raw rows and the tariff tables are common to both arrangements. Since the rows are derived from the index, they are the same on every run; there is no randomness.
// data.mjs — raw shipment rows and tariff tables; values are derived from the index export const TIER_TABLE = { standard: [[1, 4990], [5, 8490], [15, 14990], [30, 24990]], economy: [[1, 3990], [5, 6990], [15, 11990], [30, 19990]], express: [[1, 6490], [5, 10990], [15, 18990], [30, 31990]], }; export const COEFFICIENT_TABLE = { 34: 100, "06": 118, 35: 120, 65: 145 }; export const TARIFF_CODE = ["standard", "economy", "express"]; const POSTAL = ["34100", "06500", "35400", "65100"]; export function* rawRows(n) { for (let i = 0; i < n; i += 1) { yield { code: `G-${i}`, weight: 0.5 + (i % 59) / 2, postalCode: POSTAL[i % POSTAL.length], tariffCode: TARIFF_CODE[i % TARIFF_CODE.length], }; } }
In the first arrangement, every record builds its own tariff object. This is the most natural way not to share the tables: a record is self-sufficient and shares nothing.
// copied/main.mjs — every shipment carries its own tariff copy import { TIER_TABLE, COEFFICIENT_TABLE } from "../data.mjs"; let created = 0; export const createdTariffs = () => created; export function build(rows) { const list = []; for (const s of rows) { created += 1; list.push({ code: s.code, weight: s.weight, postalCode: s.postalCode, tariff: { code: s.tariffCode, tiers: TIER_TABLE[s.tariffCode].map(([u, f]) => [u, f]), coefficients: { ...COEFFICIENT_TABLE }, currency: "cents", }, }); } return list; } export function fee(g) { const t = g.tariff.tiers.find(([cap]) => g.weight <= cap) ?? [0, 24990]; return Math.round((t[1] * (g.tariff.coefficients[g.postalCode.slice(0, 2)] ?? 165)) / 100); }
Solution: A Pool Sharing Intrinsic State
The flyweight factory returns a single object for the same intrinsic state. The object must be immutable; the reason is measured below.
// shared/tariff.mjs — flyweight factory: one object per tariff code import { TIER_TABLE, COEFFICIENT_TABLE } from "../data.mjs"; const pool = new Map(); let created = 0; export const createdTariffs = () => created; export const poolSize = () => pool.size; export function getTariff(code) { let t = pool.get(code); if (t === undefined) { created += 1; t = Object.freeze({ code, tiers: Object.freeze(TIER_TABLE[code].map(([u, f]) => Object.freeze([u, f]))), coefficients: Object.freeze({ ...COEFFICIENT_TABLE }), currency: "cents", }); pool.set(code, t); } return t; }
// shared/main.mjs — a shipment holds a reference to the tariff; intrinsic state is shared, extrinsic state stays on the row import { getTariff, createdTariffs, poolSize } from "./tariff.mjs"; export { createdTariffs, poolSize }; export function build(rows) { const list = []; for (const s of rows) { list.push({ code: s.code, weight: s.weight, postalCode: s.postalCode, tariff: getTariff(s.tariffCode) }); } return list; } export function fee(g) { const t = g.tariff.tiers.find(([cap]) => g.weight <= cap) ?? [0, 24990]; return Math.round((t[1] * (g.tariff.coefficients[g.postalCode.slice(0, 2)] ?? 165)) / 100); }
The fee calculation’s body is identical in both arrangements; what changes is what
g.tariff belongs to.
// run.mjs — do the two arrangements give the same total fee, and how many tariff objects get built import { rawRows } from "./data.mjs"; import * as copied from "./copied/main.mjs"; import * as shared from "./shared/main.mjs"; const N = 20000; const total = (arrangement) => arrangement.build(rawRows(N)).reduce((t, g) => t + arrangement.fee(g), 0); const a = total(copied), b = total(shared); console.log(`copied: total=${a} tariff objects=${copied.createdTariffs()}`); console.log(`shared: total=${b} tariff objects=${shared.createdTariffs()}`); console.log(`total difference = ${a - b} shipments = ${N}`);
copied: total=463093345 tariff objects=20000 shared: total=463093345 tariff objects=3 total difference = 0 shipments = 20000
For twenty thousand shipments, the copied arrangement built 20,000 tariff objects, the shared arrangement 3. The total fee matches to the cent. Object count is an exact number independent of environment: as many objects get built as there are varieties of intrinsic state.
Heap Measurement
The second measure is heap usage, and it is environment-dependent: the megabyte values
below were taken on this machine with node v24.18.0; the numbers change when the version,
operating system, or garbage collector settings change. What does not change is the order
of magnitude. The measurement requires calling the garbage collector by hand, so the script
runs with the --expose-gc flag.
// memory.mjs — builds one arrangement and measures heap usage; run with node --expose-gc import { rawRows } from "./data.mjs"; if (globalThis.gc === undefined) { console.error("this script is run as 'node --expose-gc memory.mjs <arrangement> <count>'"); process.exit(1); } const arrangement = process.argv[2]; const count = Number(process.argv[3]); const { build, fee, createdTariffs } = await import(`./${arrangement}/main.mjs`); globalThis.gc(); const before = process.memoryUsage().heapUsed; const list = build(rawRows(count)); globalThis.gc(); const after = process.memoryUsage().heapUsed; const total = list.reduce((t, g) => t + fee(g), 0); console.log(`${arrangement.padEnd(11)} shipments=${list.length} tariff objects=${createdTariffs()} ` + `heap=${Math.round((after - before) / 1048576)} MB total=${total}`);
node --expose-gc memory.mjs copied 200000 node --expose-gc memory.mjs shared 200000
copied shipments=200000 tariff objects=200000 heap=208 MB total=4631039370 shared shipments=200000 tariff objects=3 heap=20 MB total=4631039370
208 megabytes against 20 megabytes for two hundred thousand shipments — a ratio a little above tenfold. The remaining 20 megabytes is the extrinsic state itself — shipment code, weight, postal code — and cannot be shrunk by sharing, because it differs in every record. The measurement was taken in two separate processes; building both arrangements in the same process would risk the first arrangement’s leftover objects bleeding into the second measurement.
The total fee came out the same in both runs; sharing did not change the result.
Cost: Immutability and Sharing Width
The shared object’s most visible cost is that it no longer belongs to anyone.
// cost.mjs — the cost of sharing: mutability, sharing width, object identity import { rawRows } from "./data.mjs"; import * as copied from "./copied/main.mjs"; import * as shared from "./shared/main.mjs"; const N = 20000; const k = copied.build(rawRows(N)); const p = shared.build(rawRows(N)); const previousCopied = k.map(copied.fee); k[0].tariff.coefficients["34"] = 300; console.log(`copied: one record's table changed -> changed results = ` + `${k.map(copied.fee).filter((v, i) => v !== previousCopied[i]).length} / ${N}`); try { p[0].tariff.coefficients["34"] = 300; console.log("shared: same change accepted"); } catch (e) { console.log(`shared: same change rejected (${e.constructor.name})`); } console.log(`shared: shipments sharing the same object = ${p.filter((g) => g.tariff === p[0].tariff).length} / ${N}`); console.log(`copied: shipments sharing the same object = ${k.filter((g) => g.tariff === k[0].tariff).length} / ${N}`); const variety = shared.poolSize(); console.log(`intrinsic state variety = ${variety} sharing ratio = ${Math.round(N / variety)} shipments/object`);
copied: one record's table changed -> changed results = 1 / 20000 shared: same change rejected (TypeError) shared: shipments sharing the same object = 6667 / 20000 copied: shipments sharing the same object = 1 / 20000 intrinsic state variety = 3 sharing ratio = 6667 shipments/object
In the copied arrangement, changing one record’s table broke only that record: 1/20000. In
the shared arrangement, the same change was rejected by Object.freeze and raised a
TypeError. Without the freeze, the change would have been accepted, and the 6667
shipments sharing that object would have silently drifted. The sharing ratio measures both
the gain and the risk: every object serves 6667 records, so a bug in that object propagates
to 6667 records.
The second cost is that object identity changes meaning. In the copied arrangement, a
g1.tariff === g2.tariff comparison holds only for the same record; in the shared
arrangement, it holds for every shipment carrying the same tariff code. Code that infers,
from identity equality, something like “if these two shipments use the same tariff object,
they belong to the same customer” gets it wrong once sharing is introduced.
The third cost is the pool itself: one file, one map structure, and one lookup while building every record. The pool also never empties; if intrinsic-state variety is unbounded, the pool grows unbounded.
A condition for not applying the pattern follows directly. The gain grows with the ratio of record count to intrinsic-state variety: 200,000/3 in this run. If the ratio approaches 1 — if every record’s intrinsic state is its own — there is nothing to share, the pool holds as many objects as there are records, and a mapping overhead gets added on top. If the record count is small (hundreds), the 200-megabyte difference shrinks to a hundred kilobytes, and the immutability constraint goes unpaid for.
Summary
- The problem the flyweight solves is unchanging information filling memory by getting copied once per record; the solution is gathering intrinsic state into a shared object and leaving extrinsic state on the record.
- For twenty thousand shipments, the tariff objects built dropped from 20,000 to 3, and the total fee stayed the same to the cent; object count is a measure independent of environment.
- For two hundred thousand shipments, heap usage dropped from 208 megabytes to 20; these
values were measured on this machine with
nodev24.18.0 and change with the environment, while the ratio holds as an order of magnitude. - The shared object must be immutable: in the copied arrangement, one table change broke 1 record; in the shared arrangement, the same change was rejected thanks to the freeze — had it been accepted, it would have broken 6667 records sharing that object.
- The gain grows with the ratio of record count to intrinsic-state variety; if the ratio is close to 1 or the record count is small, the pattern only adds pool overhead and an immutability constraint.
Next Step
In the flyweight, many records reached one object through the same reference; the object
was shared, but access was unrestricted — anyone could read the tariff field and use it
directly. The next problem takes access itself as its subject. If the tariff table comes
from a remote source, it must not load until the first access, a second request for the
same table must not fetch it again, and it must be possible to count which record requested
which table. All three get solved, without touching the real object’s body, by an object
offering the same interface stepping in between. The next lesson counts the calls reaching
the source in both arrangements, writes the proxy pattern, and measures its cost in a level
of indirection and stale-data risk.
To keep your progress and take notes, Log in
My notes
Log in to take notes.