Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 025 / 100

Flyweight pattern: a Design Patterns guide for developers

Share common state to save memory.

Expona Admin13 min read

The memory crisis nobody talks about

You've just launched a mapping feature that renders ten thousand location pins across a city. Your monitoring dashboard lights up red—memory usage has tripled. The Flyweight pattern: a Design Patterns guide for developers exists precisely to solve this kind of problem. By sharing common state across many similar objects, you can drastically reduce memory footprint without sacrificing functionality.

This is day 25 of our 100-day Design Patterns in Practice series. We're diving deep into a pattern that often gets overlooked until it becomes critical. Whether you're building real-time dashboards, collaborative editors, or geographic systems, understanding when and how to apply the Flyweight pattern will make you a more thoughtful systems designer.

Intrinsic vs extrinsic state: the core distinction

The Flyweight pattern hinges on one fundamental separation: splitting object state into two categories.

Intrinsic state is immutable and shared across many instances. Think of it as the essence of an object that never changes—a red pin icon, a bullet character in a font, a soldier's uniform color. This state belongs inside the flyweight object itself and is created once, then reused.

Extrinsic state is mutable and unique to each context. It's the position of a pin on the map, the line number where a glyph appears, or the soldier's assigned battalion. This state lives outside the flyweight and is passed in when needed.

The pattern works because we typically have far fewer unique intrinsic states than total object instances. If you have 10,000 map markers but only 5 marker types, you create 5 flyweights and reuse them everywhere.

typescript

// Without Flyweight: each marker stores position AND icon
class MarkerWithoutFlyweight {
  iconUrl: string;
  iconWidth: number;
  iconHeight: number;
  x: number;
  y: number;

  constructor(iconUrl: string, x: number, y: number) {
    this.iconUrl = iconUrl;
    this.iconWidth = 32;
    this.iconHeight = 32;
    this.x = x;
    this.y = y;
  }

  render() {
    console.log(`Rendering ${this.iconUrl} at (${this.x}, ${this.y})`);
  }
}

// With Flyweight: separate shared icon from position
class MarkerIcon {
  iconUrl: string;
  iconWidth: number;
  iconHeight: number;

  constructor(iconUrl: string, width: number = 32, height: number = 32) {
    this.iconUrl = iconUrl;
    this.iconWidth = width;
    this.iconHeight = height;
  }
}

class Marker {
  icon: MarkerIcon;
  x: number;
  y: number;

  constructor(icon: MarkerIcon, x: number, y: number) {
    this.icon = icon;
    this.x = x;
    this.y = y;
  }

  render() {
    console.log(
      `Rendering ${this.icon.iconUrl} (${this.icon.iconWidth}x${this.icon.iconHeight}) at (${this.x}, ${this.y})`
    );
  }
}

// Factory ensures we reuse icon instances
class MarkerIconFactory {
  private cache: Map = new Map();

  getIcon(iconUrl: string): MarkerIcon {
    if (!this.cache.has(iconUrl)) {
      this.cache.set(iconUrl, new MarkerIcon(iconUrl));
    }
    return this.cache.get(iconUrl)!;
  }
}

In this example, MarkerIcon is the flyweight—immutable, shared, held in a factory cache. Marker is the client object that combines a flyweight with extrinsic state (position). When you create 10,000 markers, you might only instantiate 3 MarkerIcon objects total.

Thousands of map markers sharing one icon

Let's build a realistic mapping scenario. You're rendering a city's delivery network: thousands of driver locations, store locations, and hub locations. Each type has its own icon. Without the Flyweight pattern, each marker object dupes the icon bitmap, color codes, and dimensions.

typescript

interface Position {
  lat: number;
  lng: number;
}

class MapMarkerFlyweight {
  readonly type: string;
  readonly iconUrl: string;
  readonly color: string;
  readonly size: { width: number; height: number };

  constructor(
    type: string,
    iconUrl: string,
    color: string,
    size: { width: number; height: number }
  ) {
    this.type = type;
    this.iconUrl = iconUrl;
    this.color = color;
    this.size = size;
  }
}

class MapMarker {
  flyweight: MapMarkerFlyweight;
  position: Position;
  id: string;
  metadata?: Record;

  constructor(flyweight: MapMarkerFlyweight, position: Position, id: string) {
    this.flyweight = flyweight;
    this.position = position;
    this.id = id;
  }
}

class MapMarkerFactory {
  private flyweights: Map = new Map();

  createFlyweight(
    type: string,
    iconUrl: string,
    color: string,
    size: { width: number; height: number }
  ): MapMarkerFlyweight {
    const key = `${type}:${color}`;

    if (!this.flyweights.has(key)) {
      this.flyweights.set(
        key,
        new MapMarkerFlyweight(type, iconUrl, color, size)
      );
    }

    return this.flyweights.get(key)!;
  }

  getFlyweight(key: string): MapMarkerFlyweight | undefined {
    return this.flyweights.get(key);
  }
}

class Map {
  private markers: Map = new Map();
  private factory = new MapMarkerFactory();

  addMarker(
    id: string,
    type: string,
    position: Position,
    iconUrl: string,
    color: string,
    size: { width: number; height: number }
  ) {
    const flyweight = this.factory.createFlyweight(type, iconUrl, color, size);
    const marker = new MapMarker(flyweight, position, id);
    this.markers.set(id, marker);
  }

  render() {
    for (const marker of this.markers.values()) {
      console.log(
        `[${marker.flyweight.type}] ${marker.flyweight.color} at (${marker.position.lat}, ${marker.position.lng})`
      );
    }
  }

  getMemoryInfo() {
    const uniqueFlyweights = new Set(
      Array.from(this.markers.values()).map((m) => m.flyweight)
    );
    return {
      totalMarkers: this.markers.size,
      uniqueFlyweights: uniqueFlyweights.size,
      reuseFactor: this.markers.size / uniqueFlyweights.size,
    };
  }
}

// Usage
const map = new Map();
const positions = generateRandomPositions(10000);

positions.forEach((pos, idx) => {
  const markerType = ['driver', 'store', 'hub'][idx % 3];
  map.addMarker(`marker-${idx}`, markerType, pos, '/icons/marker.png', 'red', {
    width: 32,
    height: 32,
  });
});

console.log(map.getMemoryInfo());
// { totalMarkers: 10000, uniqueFlyweights: 3, reuseFactor: 3333.33 }

With 10,000 markers but only 3 types, you're sharing the same icon data 3,333 times over. The memory savings compound as your marker count grows. Each marker now holds only position and metadata, while icon definitions live in the factory's cache.

Text editors sharing glyph data

Consider a rich text editor like the ones you'd find in collaborative SaaS platforms. A document with 50,000 characters creates 50,000 glyph objects. Without optimization, each glyph stores the character code, font metrics, ligature rules, and rendering cache. That's massive redundancy—the letter "e" appears hundreds of times but needs to be stored just once.

typescript

class GlyphFlyweight {
  readonly char: string;
  readonly fontFamily: string;
  readonly fontSize: number;
  readonly metrics: {
    width: number;
    height: number;
    ascent: number;
    descent: number;
  };

  constructor(
    char: string,
    fontFamily: string,
    fontSize: number,
    metrics: object
  ) {
    this.char = char;
    this.fontFamily = fontFamily;
    this.fontSize = fontSize;
    this.metrics = metrics as any;
  }

  getWidth(): number {
    return this.metrics.width;
  }
}

class GlyphInstance {
  flyweight: GlyphFlyweight;
  position: { x: number; y: number };
  lineNumber: number;
  columnNumber: number;
  style?: { bold?: boolean; italic?: boolean; color?: string };

  constructor(
    flyweight: GlyphFlyweight,
    x: number,
    y: number,
    line: number,
    col: number
  ) {
    this.flyweight = flyweight;
    this.position = { x, y };
    this.lineNumber = line;
    this.columnNumber = col;
  }
}

class GlyphFactory {
  private cache: Map = new Map();
  private fontFamily: string = 'Arial';
  private fontSize: number = 12;

  // Precompute glyph metrics (in production, use actual font rendering)
  private metricsCache: Map = new Map();

  getGlyph(char: string): GlyphFlyweight {
    const key = `${char}:${this.fontFamily}:${this.fontSize}`;

    if (!this.cache.has(key)) {
      const metrics = this.computeMetrics(char);
      this.cache.set(key, new GlyphFlyweight(char, this.fontFamily, this.fontSize, metrics));
    }

    return this.cache.get(key)!;
  }

  private computeMetrics(char: string): object {
    // In production, measure against actual font
    return {
      width: 8,
      height: 12,
      ascent: 10,
      descent: 2,
    };
  }

  getCacheStats() {
    return this.cache.size;
  }
}

class TextDocument {
  private glyphs: GlyphInstance[] = [];
  private factory: GlyphFactory;

  constructor(factory: GlyphFactory) {
    this.factory = factory;
  }

  insertText(text: string, line: number, col: number) {
    let x = col * 8;
    let y = line * 12;

    for (const char of text) {
      const flyweight = this.factory.getGlyph(char);
      const instance = new GlyphInstance(flyweight, x, y, line, col);
      this.glyphs.push(instance);
      x += flyweight.getWidth();
    }
  }

  getStats() {
    return {
      totalGlyphs: this.glyphs.length,
      uniqueFlyweights: this.factory.getCacheStats(),
    };
  }
}

// Usage
const factory = new GlyphFactory();
const doc = new TextDocument(factory);

const longText = 'The quick brown fox jumps over the lazy dog. '.repeat(1000);
doc.insertText(longText, 0, 0);

console.log(doc.getStats());
// { totalGlyphs: 45000, uniqueFlyweights: ~30 }
</code><p></p>

<p>A 45,000-character document needs roughly 30 unique glyphs (alphanumerics, punctuation, spaces). Instead of 45,000 glyph objects with full font metrics, you have 30 flyweights and 45,000 lightweight instances referencing them.</p>

<h2 id="measure-memory-before-optimizing">Measure memory before optimizing</h2>

<p>Here's where we separate real problems from premature optimization: <strong>always measure first</strong>.</p>

<p>The Flyweight pattern introduces complexity. You add a factory, you split state across objects, you manage external state manually. That overhead is only worth it if you're actually saving significant memory. Measure your application's memory baseline before refactoring.</p>

<pre><code class="language-typescript">
// Helper to measure object size (rough estimation in Node.js)
function getApproximateSize(obj: unknown): number {
  const seen = new WeakSet();

  function sizeOf(obj: any): number {
    if (obj === null) return 0;
    if (typeof obj !== 'object') return 0;
    if (seen.has(obj)) return 0;

    seen.add(obj);
    let bytes = 0;

    for (const key in obj) {
      if (obj.hasOwnProperty(key)) {
        const value = obj[key];
        if (typeof value === 'string') {
          bytes += value.length * 2;
        } else if (typeof value === 'number') {
          bytes += 8;
        } else if (typeof value === 'boolean') {
          bytes += 4;
        } else if (typeof value === 'object') {
          bytes += sizeOf(value);
        }
      }
    }

    return bytes;
  }

  return sizeOf(obj);
}

// Benchmark both approaches
class MemoryBenchmark {
  benchmarkWithoutFlyweight(count: number) {
    const start = process.memoryUsage().heapUsed;
    const markers = [];

    for (let i = 0; i < count; i++) {
      markers.push(
        new MarkerWithoutFlyweight('/icons/pin.png', Math.random() * 100, Math.random() * 100)
      );
    }

    const end = process.memoryUsage().heapUsed;
    return (end - start) / 1024 / 1024; // MB
  }

  benchmarkWithFlyweight(count: number) {
    const start = process.memoryUsage().heapUsed;
    const factory = new MarkerIconFactory();
    const markers = [];

    for (let i = 0; i < count; i++) {
      const icon = factory.getIcon('/icons/pin.png');
      markers.push(new Marker(icon, Math.random() * 100, Math.random() * 100));
    }

    const end = process.memoryUsage().heapUsed;
    return (end - start) / 1024 / 1024; // MB
  }
}

const bench = new MemoryBenchmark();
console.log('Without Flyweight (10k objects):', bench.benchmarkWithoutFlyweight(10000), 'MB');
console.log('With Flyweight (10k objects):', bench.benchmarkWithFlyweight(10000), 'MB');

Run this benchmark with 1,000 objects, 10,000 objects, and 100,000 objects. Plot the results. If the difference is under 5%, the Flyweight pattern may be overkill for your use case. If it's 50% or more, you've found a genuine optimization opportunity.

Also consider the cost of the factory lookup. If you're creating and destroying objects frequently, the cache miss rate might negate memory savings. Profile both memory usage and CPU time.

Common mistakes

Mutating flyweight state: The biggest trap is modifying intrinsic state after creation. If you change an icon's color and all 10,000 markers suddenly change color, you've introduced a subtle bug. Keep flyweights immutable and marked as such in your type system.

Over-sharing: Not every object benefits from this pattern. Don't apply it to 10 objects or even 100 objects. The factory overhead and code complexity only pay off with thousands of instances. Start with a simpler design and refactor when profiling shows memory pressure.

Forgetting extrinsic state is mutable: External state like position, styling, or metadata can change throughout the object's lifetime. Make sure your architecture cleanly separates immutable flyweight references from mutable extrinsic state. If you mix them, you lose the pattern's benefits and gain its complexity.

Leaking memory in the cache: If your factory caches flyweights indefinitely, it can hold references to objects you meant to garbage collect. Implement cache eviction or tie flyweight lifetime to application lifecycle. In some systems, you might need to explicitly clear the cache when data invalidates.

Key takeaways

  • The Flyweight pattern separates intrinsic (shared, immutable) state from extrinsic (unique, mutable) state to reduce memory overhead when dealing with thousands of similar objects.
  • Use a factory to cache and reuse flyweight instances, ensuring each unique intrinsic state exists only once in memory.
  • Common scenarios include map markers with shared icon data, text editors with shared glyph metrics, and any domain where you have many instances of a few types.
  • Always measure memory and CPU before and after applying the pattern. The complexity overhead is only justified by substantial savings.
  • Keep flyweights immutable and their cache managed explicitly. Mixing mutable state into flyweights defeats the pattern and creates bugs.
  • Consider the cost of factory lookups and cache misses, especially in hot code paths or high-throughput systems.
  • Combine Flyweight with other patterns. Object pools work well with Flyweight for managing lifecycle. Factory pattern is essential for implementing the cache.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles