Skip to content
academia.sh

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 node v24.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.

Start typing to search.

↑↓ Esc navigate · open · close