Structural Pattern (GoF)

Flyweight

Shares immutable intrinsic state across large numbers of similar objects, externalizing the context-dependent extrinsic state — drastically reducing memory usage when there are thousands of near-identical instances.

Intent

Share fine-grained objects to support large numbers of them efficiently. The Flyweight splits an object's state into two parts: the intrinsic state (immutable, context-independent, shared across many instances) and the extrinsic state (variable, context-dependent, supplied by the client at the moment of use).

Cataloged by the GoF (1994) as a structural pattern, the Flyweight is indicated exclusively in scenarios where the number of objects is so large that the memory cost becomes prohibitive. A central factory (FlyweightFactory) works as a cache: it returns the same instance for objects with the same intrinsic state, instead of creating a new object every time. The client passes the extrinsic state as an argument at the moment it uses the Flyweight — never storing it inside the shared object.

Problem

Imagine a particle-based game where explosions create thousands of fragments simultaneously. The naive approach creates a full object for every particle:

// Naive approach — do NOT do this with 100,000 particles:
class Particle {
  type: string;        // "fire" | "smoke" | "debris"
  color: string;       // "#FF4500"
  texture: string;     // sprite data — can be kilobytes
  x: number;
  y: number;
  velocityX: number;
  velocityY: number;
}

// 100,000 instances × (type + color + texture) = memory exhausted
const particles: Particle[] = [];
for (let i = 0; i < 100_000; i++) {
  particles.push({
    type: 'fire', color: '#FF4500', texture: fireSprite, // repeated 100,000×
    x: Math.random() * 800, y: Math.random() * 600,
    velocityX: Math.random(), velocityY: Math.random(),
  });
}

The problem is obvious: type, color and texture are identical for every particle of the same type. We're replicating that data 100,000 times, while only x, y, velocityX and velocityY vary per instance. The Flyweight solves this by sharing what's common and receiving what varies as an argument.

The pattern's core distinction:

  • Intrinsic state — immutable, context-independent: particle type, color, texture/sprite data. This is what can be safely shared because it never changes per instance.
  • Extrinsic state — variable, context-dependent: x/y position, velocity, lifespan. It's supplied by the client (ParticleContext) on every operation — it's never stored inside the Flyweight.

Solution

The Flyweight organizes the code into three participants:

  1. Flyweight: stores exclusively the intrinsic state. Its methods receive the extrinsic state as a parameter — never store it as a field. E.g.: ParticleType with type, color and texture.
  2. FlyweightFactory: keeps a cache (Map) of Flyweight instances indexed by the intrinsic state's key. Returns an existing instance if the key already exists, or creates and stores a new one. It's the central element of the pattern — without it, sharing doesn't happen. It's frequently implemented as a Singleton.
  3. Context (client): stores the extrinsic state and a reference to the shared Flyweight. E.g.: ParticleContext with x, y, velocityX, velocityY and a reference to ParticleType. The number of Contexts can be huge; the number of Flyweights is small (one per unique intrinsic state value).

The memory savings come from the number of Flyweight instances created. If there are 3 particle types and 100,000 particles, there are only 3 Flyweight objects — not 100,000.

Structure

           ParticleType (Flyweight)
  ┌─────────────────────────────────────────┐
  │ + type: string        ← intrinsic       │
  │ + color: string       ← intrinsic       │
  │ + texture: string     ← intrinsic       │
  │                                         │
  │ + render(x, y, vx, vy): void            │
  │   ↑ extrinsic state passed as argument  │
  └─────────────────────────────────────────┘
                       ▲ created and cached by
  ┌─────────────────────────────────────────┐
  │ ParticleTypeFactory (Factory)           │
  │                                         │
  │ - cache: Map<string, ParticleType>      │
  │ + get(type, color, texture):            │
  │     ParticleType                        │
  │   → returns from cache or creates new   │
  │ + countInstances(): number              │
  └─────────────────────────────────────────┘
                       ▲ referenced by
  ┌─────────────────────────────────────────┐
  │ ParticleContext (Context)               │
  │                                         │
  │ - x: number             ← extrinsic     │
  │ - y: number              ← extrinsic    │
  │ - velocityX: number     ← extrinsic     │
  │ - velocityY: number     ← extrinsic     │
  │ - type: ParticleType    ← flyweight ref │
  │                                         │
  │ + move(): void                          │
  │ + render(): void                        │
  │   → this.type.render(x, y, vx, vy)      │
  └─────────────────────────────────────────┘


Instance count (example with 100,000 particles):

  Without Flyweight:  100,000 full Particle objects
  With Flyweight:           3 ParticleType objects (fire, smoke, debris)
                    + 100,000 ParticleContext objects (light — just numbers)

Code examples

Example 1 — Particles in a game with a FlyweightFactory and instance counting

The example below demonstrates the split between intrinsic state (ParticleType) and extrinsic state (ParticleContext), the factory with cache, and the count of created instances — highlighting the memory savings.

// ── Flyweight — stores ONLY intrinsic state ────────────────────
class ParticleType {
  constructor(
    readonly type: string,       // intrinsic: immutable
    readonly color: string,      // intrinsic: immutable
    readonly texture: string     // intrinsic: heavy sprite/data
  ) {}

  // Extrinsic state (x, y, vx, vy) arrives as an argument — never a field.
  render(x: number, y: number, vx: number, vy: number): void {
    console.log(
      `[${this.type}] color=${this.color} @ (${x.toFixed(1)},${y.toFixed(1)})` +
      ` vel=(${vx.toFixed(2)},${vy.toFixed(2)})`
    );
  }
}

// ── FlyweightFactory — cache of instances by intrinsic key ─────
class ParticleTypeFactory {
  private static readonly cache = new Map<string, ParticleType>();

  static get(type: string, color: string, texture: string): ParticleType {
    const key = `${type}|${color}`;
    if (!ParticleTypeFactory.cache.has(key)) {
      console.log(`  [Factory] Creating new Flyweight: "${key}"`);
      ParticleTypeFactory.cache.set(key, new ParticleType(type, color, texture));
    }
    return ParticleTypeFactory.cache.get(key)!;
  }

  static countInstances(): number {
    return ParticleTypeFactory.cache.size;
  }
}

// ── Context — stores extrinsic state + reference to the Flyweight
class ParticleContext {
  private type: ParticleType;

  constructor(
    typeName: string,
    color: string,
    texture: string,
    private x: number,
    private y: number,
    private velocityX: number,
    private velocityY: number
  ) {
    // Gets (or creates) the shared Flyweight
    this.type = ParticleTypeFactory.get(typeName, color, texture);
  }

  move(dt: number): void {
    this.x += this.velocityX * dt;
    this.y += this.velocityY * dt;
  }

  render(): void {
    // Passes extrinsic state as an argument — not stored in the Flyweight
    this.type.render(this.x, this.y, this.velocityX, this.velocityY);
  }
}

// ── Simulation ────────────────────────────────────────────────
const TOTAL = 10_000;
const TYPES = [
  { type: 'fire',   color: '#FF4500', texture: 'sprite_fire.png'   },
  { type: 'smoke',  color: '#808080', texture: 'sprite_smoke.png'  },
  { type: 'debris', color: '#C0C0C0', texture: 'sprite_debris.png' },
];

console.log(`Creating ${TOTAL} particles of ${TYPES.length} types:`);

const particles: ParticleContext[] = [];
for (let i = 0; i < TOTAL; i++) {
  const t = TYPES[i % TYPES.length];
  particles.push(new ParticleContext(
    t.type, t.color, t.texture,
    Math.random() * 800,
    Math.random() * 600,
    (Math.random() - 0.5) * 5,
    (Math.random() - 0.5) * 5
  ));
}

// Result:
//   [Factory] Creating new Flyweight: "fire|#FF4500"
//   [Factory] Creating new Flyweight: "smoke|#808080"
//   [Factory] Creating new Flyweight: "debris|#C0C0C0"

console.log(`\nParticleType instances (Flyweight): ${ParticleTypeFactory.countInstances()}`);
console.log(`ParticleContext instances (Context): ${TOTAL}`);
// ParticleType instances (Flyweight): 3
// ParticleContext instances (Context): 10000

// Renders the first 3 to demonstrate
particles.slice(0, 3).forEach(p => p.render());
// [fire]   color=#FF4500 @ (327.4,198.6) vel=( 2.13,-1.07)
// [smoke]  color=#808080 @ (542.1,391.2) vel=(-0.87, 3.45)
// [debris] color=#C0C0C0 @ ( 12.9,544.8) vel=( 1.22,-2.98)

Example 2 — Typographic characters in a text editor

The classic GoF example: an editor with 500,000 characters. The intrinsic state is the glyph's shape (family, weight, font size — immutable for every "A" in the same font). The extrinsic state is each character's position on the page (row, column — unique per instance). Without Flyweight: 500,000 objects with repeated font data. With Flyweight: one object per unique glyph, shared by every occurrence.

// ── Flyweight: font data (intrinsic) ───────────────────────────
class Glyph {
  constructor(
    readonly character: string,
    readonly family: string,    // e.g.: "Helvetica"
    readonly weight: string,    // e.g.: "regular" | "bold"
    readonly size: number       // e.g.: 12 (pt)
  ) {}

  // Position (extrinsic) comes as an argument
  draw(row: number, column: number): void {
    console.log(
      `'${this.character}' [${this.family} ${this.weight} ${this.size}pt]` +
      ` → row ${row}, col ${column}`
    );
  }
}

// ── FlyweightFactory ──────────────────────────────────────────
class GlyphFactory {
  private readonly cache = new Map<string, Glyph>();

  get(
    character: string,
    family: string,
    weight: string,
    size: number
  ): Glyph {
    const key = `${character}|${family}|${weight}|${size}`;
    if (!this.cache.has(key)) {
      this.cache.set(key, new Glyph(character, family, weight, size));
    }
    return this.cache.get(key)!;
  }

  countGlyphs(): number { return this.cache.size; }
}

// ── Context: position of each character in the document ───────
class CharacterInDocument {
  private readonly glyph: Glyph;

  constructor(
    character: string,
    family: string,
    weight: string,
    size: number,
    private readonly row: number,
    private readonly column: number,
    factory: GlyphFactory
  ) {
    this.glyph = factory.get(character, family, weight, size);
  }

  render(): void {
    this.glyph.draw(this.row, this.column);
  }
}

// ── Usage ────────────────────────────────────────────────────
const factory = new GlyphFactory();
const text    = 'Hello'; // each letter occurs at multiple positions

// Simulates two lines with the same text — same glyphs, different positions
const document: CharacterInDocument[] = [];
for (let row = 0; row < 2; row++) {
  for (let col = 0; col < text.length; col++) {
    document.push(new CharacterInDocument(
      text[col], 'Helvetica', 'regular', 12,
      row, col, factory
    ));
  }
}

// "Hello" × 2 lines = 10 CharacterInDocument instances
// but only 4 unique glyphs (H,e,l,o — 'l' reused 4×)
console.log(`Unique glyphs (Flyweight): ${factory.countGlyphs()}`); // 4
console.log(`Contexts (instances):      ${document.length}`);       // 10

// Renders row 0
document.filter((_, i) => i < text.length).forEach(c => c.render());
// 'H' [Helvetica regular 12pt] → row 0, col 0
// 'e' [Helvetica regular 12pt] → row 0, col 1
// 'l' [Helvetica regular 12pt] → row 0, col 2
// 'l' [Helvetica regular 12pt] → row 0, col 3  ← same Glyph 'l'
// 'o' [Helvetica regular 12pt] → row 0, col 4

When to use

  • When the number of objects is very large and the memory cost is measurable: Flyweight only pays off after profiling. The criterion is objective: there's a real memory bottleneck caused by near-identical objects in massive quantities (thousands or more).
  • When most of each object's state is identical across instances: if objects differ only in a few numeric fields (position, lifespan), but share heavy data (sprites, color schemes, business rules), the shareable intrinsic state is substantial.
  • When object identity doesn't matter to the client: the client can't distinguish a reused Flyweight from a new object — and doesn't need to. If instance identity is relevant (e.g.: two "equal" objects have independent lifecycles), Flyweight isn't suitable.

When to avoid

  • Before profiling: Flyweight introduces real complexity — extrinsic state becomes the client's responsibility, the code becomes harder to understand and debug. Applying it without evidence of a memory problem is classic over-engineering.
  • When the intrinsic state is small or non-repetitive: if every object has unique data, there's nothing to share. The pattern brings no benefit at all.
  • When the number of objects is reasonable: hundreds of objects with duplicated data don't justify the complexity. Rule of thumb: if the memory footprint isn't a measurable problem, don't apply it.

Pros and cons

Pros

  • Drastic reduction in memory usage when there are many objects with shareable intrinsic state.
  • Can improve CPU cache performance by reducing the number of distinct objects walked.
  • The FlyweightFactory centralizes creation and sharing — the client doesn't need to manage the cache manually.

Cons

  • The extrinsic state becomes the client's responsibility — whoever calls the Flyweight must supply and manage that data, increasing coupling.
  • Increases code complexity: what was a single object becomes a Flyweight + Context pair, plus the factory.
  • Can introduce subtle bugs if mutable state leaks into the shared Flyweight (violates the intrinsic-immutability premise).
  • The memory benefit can be negated by the overhead of managing and passing the extrinsic state on every operation.

Common pitfalls

1. Mutable intrinsic state

The fundamental premise of the Flyweight is that the intrinsic state is immutable. If a shared Flyweight is mutated by one client, every other Context referencing it will be affected — a subtle and hard-to-trace bug. The protection in TypeScript is to use readonly on every field and not expose setters.

Rule: if you feel the need to modify a field of the Flyweight, that field is probably extrinsic and belongs to the Context — not to the Flyweight.

2. Applying it before profiling (over-engineering)

The Flyweight is a memory-optimization pattern — not a design one. Applying it before identifying a real bottleneck is cost without benefit: the split between intrinsic and extrinsic state makes the code significantly more complex. Use a profiler to confirm that memory usage from repeated objects is the real problem before refactoring.

3. Extrinsic state accidentally becoming intrinsic

The distinction between intrinsic and extrinsic isn't always obvious. A field like color can be intrinsic (every bold "A" is blue) or extrinsic (each character can have an independent color). Classifying it incorrectly means the Flyweight will hold state that should vary per instance — making the sharing incorrect.

The diagnostic question: "if two objects have this value differently, can they still share the same Flyweight instance?" If not, the field is extrinsic.

4. Flyweight objects used by multiple threads

Because Flyweights are shared and (by design) immutable, they're naturally thread-safe for reading. The risk is the FlyweightFactory itself: creation and insertion into the cache must be protected in multithreaded environments (mutex, synchronization) to avoid two threads creating the same Flyweight simultaneously.

Related patterns

The Flyweight interacts with patterns that also deal with sharing, object composition and centralized creation:

The Composite frequently uses Flyweight: when a Composite tree contains many repeated leaves (e.g.: characters in a document, tiles in a map), the leaves can be implemented as Flyweights to save memory — the tree grows, but the number of unique objects doesn't. The Singleton is the pattern closest to the FlyweightFactory: the factory is frequently implemented as a Singleton to guarantee the Flyweight cache is unique across the whole application. The Visitor can be combined with Flyweight to traverse and operate over the instances without requiring each one to have the visiting behavior built in — the Visitor receives the extrinsic state from the Context and the shared Flyweight as parameters.