Design patternStructural Patterns
Flyweight
Share one immutable object for state that many objects have in common, and pass the per-object rest in, to cut memory when instances number in the millions.
- Complexity
- High
- Seen in the wild
- Low
- Also known as
- Interning, Hash Consing
Intent
Fit far more objects in memory by splitting each object's state in two. The intrinsic part, identical across many objects, moves into a shared, immutable flyweight. The extrinsic part, unique to each object, stays with the object or is passed into the flyweight's methods. A factory hands out one flyweight per distinct intrinsic value.
Forces
- Millions of fine-grained objects vs finite memory: a spreadsheet with 600,000 cells cannot afford 600,000 copies of the same font, size and number format.
- Sharing vs mutability: a shared object that one owner changes silently changes for every other owner.
- Object-oriented clarity vs memory layout: each cell "having a style" reads well; each cell owning a style wastes space.
- Saving memory vs spending CPU: lookups in the flyweight pool and passing extrinsic state around cost time.
Therefore
Move the repeated, immutable state into a shared object, obtain instances only through a factory that returns an existing one when an equal value is requested, and keep or pass the varying state outside it.
The flyweight must be immutable. Without that, sharing is a bug. The factory is a map from the intrinsic value to the one shared instance. In many languages this is a few lines, and sometimes the runtime already does it for you (string interning, enums, small integer caches).
Structure
Diagram source (Mermaid)
classDiagram
class CellStyle {
-String font
-int sizePt
-boolean bold
-String numberFormat
+format(value) String
}
class StyleRegistry {
-Map pool
+get(font, sizePt, bold, numberFormat) CellStyle
+size() int
}
class Cell {
-int row
-int col
-double value
-CellStyle style
}
class Sheet {
-Cell[] cells
+set(row, col, value, style) void
}
StyleRegistry o-- CellStyle : pool
Cell --> CellStyle : shares
Sheet *-- Cell
Sheet ..> StyleRegistryParticipants
- Flyweight (
CellStyle): immutable intrinsic state. Methods take extrinsic state as arguments (format(value)). - Flyweight factory (
StyleRegistry): returns the existing instance for an equal key, or creates and stores a new one. - Context (
Cell): holds the extrinsic state (position, value) and a reference to a shared flyweight. - Client (
Sheet): creates contexts and gets flyweights only through the factory, never withnew.
Use it when
- A profiler or heap dump shows millions of objects with duplicated field values dominating memory.
- Most of each object's state can be made immutable and is shared by many objects: styles, glyph metrics, tile types, map-marker icons, parsed schema fragments.
- The remaining per-object state is small or can be computed from context (position, index).
- Object identity does not matter for the shared part; equal values may be the same instance.
Do not use it when
- You have not measured memory pressure. Thousands, or even a few hundred thousand, small objects are cheap on modern runtimes. Profile first; flyweight without a measured problem is complexity for nothing.
- The language or runtime already deduplicates the value. Use string interning (
sys.intern,String.intern()with care), anenum, or a frozen constant table instead. - The shared part is mutable or would need per-owner changes. Copy-on-write or plain separate objects are safer.
- A columnar layout fits better. Storing a
uint16style index per cell in a parallel array (struct of arrays) saves more memory than object references and needs no pattern vocabulary. - You only need to cache expensive results. That is memoization (
functools.cache, aMap), not object sharing.
Application grill
Inspect first: the measured object counts and heap profile first - this pattern is for the millions, not the thousands; the state that repeats (immutable strings, geometry, icons, parsed configs); the language's built-in interning (string pools, Symbol.for, constant objects).
Missing pieces: none beyond the language: an intern map keyed by the shared state. Measure instance count and memory before and after; if the numbers are not in the millions, the map is overhead.
Ask before you build:
- Scale: what is the measured instance count and per-instance memory today?
- Performance: does the intern lookup (a hash on the hot path) cost more than the memory it saves?
- Reliability: the shared state must stay immutable - who enforces that as the code evolves?
- Budget: engineering time on a profile-driven optimization versus the memory it saves?
- Implementation: what is the key that proves two instances can share (equality semantics), and where is it documented?
Implementation steps
- Measure. Capture a heap profile and identify the class whose instances and duplicated fields dominate.
- Split that class's fields into intrinsic (shared, never changes after creation) and extrinsic (per instance or computed from context).
- Create the flyweight class with only intrinsic fields, make it immutable, and give it value-based equality.
- Change methods that used extrinsic fields to take them as parameters.
- Write the factory: a map keyed by the intrinsic value. Make it thread-safe if it is shared across threads.
- Replace every
newof the old class with a factory call, and measure again to confirm the saving.
Example
A spreadsheet engine loads a 200,000-row export. Every cell had its own style object, and the heap profile showed styles taking more memory than the values. Only four distinct styles exist.
type NumberFormat = "plain" | "currency" | "percent";
// Flyweight: intrinsic, shared, immutable.
class CellStyle {
constructor(
readonly font: string,
readonly sizePt: number,
readonly bold: boolean,
readonly numberFormat: NumberFormat,
) {
Object.freeze(this);
}
// Extrinsic state (the value) is passed in, never stored.
format(value: number): string {
switch (this.numberFormat) {
case "currency": return `$${value.toFixed(2)}`;
case "percent": return `${(value * 100).toFixed(1)}%`;
default: return String(value);
}
}
}
class StyleRegistry {
private readonly pool = new Map<string, CellStyle>();
get(font: string, sizePt: number, bold: boolean, numberFormat: NumberFormat): CellStyle {
const key = `${font}|${sizePt}|${bold}|${numberFormat}`;
let style = this.pool.get(key);
if (!style) {
style = new CellStyle(font, sizePt, bold, numberFormat);
this.pool.set(key, style);
}
return style;
}
get size(): number {
return this.pool.size;
}
}
// Context: per-cell state plus a reference to a shared style.
interface Cell { row: number; col: number; value: number; style: CellStyle }
const styles = new StyleRegistry();
const sheet: Cell[] = [];
for (let row = 0; row < 200_000; row++) {
sheet.push({ row, col: 0, value: 19.99 + row, style: styles.get("Inter", 11, false, "currency") });
sheet.push({ row, col: 1, value: 0.08, style: styles.get("Inter", 11, false, "percent") });
sheet.push({ row, col: 2, value: row % 7, style: styles.get("Inter", 11, row % 7 === 0, "plain") });
}
console.log(`${sheet.length} cells share ${styles.size} style objects`); // 600000 cells share 4
const cell = sheet[3];
console.log(cell.style.format(cell.value), cell.style === sheet[0].style); // $20.99 trueIn Go or any language with value types, consider going one step further: store a small style index in each cell and keep the styles in a slice. That is how spreadsheet file formats store styles on disk.
Trade-offs
Gains
- Memory drops from one copy per object to one copy per distinct value.
- Equality checks on flyweights can use identity, which is fast.
- Fewer allocations reduce garbage-collector pressure in managed runtimes.
Costs
- Immutability is mandatory. Editing a cell's style now means asking the factory for a different flyweight, not mutating a field.
- Extrinsic state must be passed around, which makes method signatures longer and code harder to follow.
- The factory's pool is a cache that never shrinks unless you add eviction, and it needs locking if shared across threads.
- The savings are invisible in code review and easy to break by a later
new CellStyle(...).
Relations
- Composite trees with many identical leaves can share them as flyweights, as long as leaves hold no parent pointer.
- Singleton has exactly one instance of a class. Flyweight has one instance per distinct intrinsic value, and many of them.
- Factory Method is the natural home for the "return an existing instance" logic.
- Proxy can make a heavy object cheap to hold by deferring it. Flyweight makes many objects cheap by sharing them.
- State objects with no fields are often shared as flyweights across all contexts.
Agent checklist
- A heap profile or measurement shows the duplicated state is a real memory problem.
- The flyweight class is immutable and has value-based equality and hashing.
- Every construction goes through the factory; a search for direct constructor calls returns only the factory.
- The factory is thread-safe if used from more than one thread.
- You compared against simpler options: built-in interning, an enum, or an index into a table.
- Memory was measured again after the change and the saving is recorded.
See also
- CompositeTreat single objects and nested groups of them through one interface, so code can walk a tree of any depth without checking node types.
- SingletonGuarantee one instance of a class per process with a global access point. Usually better replaced by one instance created at startup and injected.
- Factory MethodLet a subclass or a supplied function decide which concrete object to create, so shared workflow code depends only on an interface.
- ProxyPut a stand-in with the same interface in front of an object to control access to it: create it lazily, check permissions, or reach it remotely.
- StateMove each mode of an object into its own class so behavior and transitions change with the mode instead of branching on a status field.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.