Flyweight

The dispatch team wanted every driver on one map. Forty thousand little pins, live, updating every few seconds. It rendered fine on my laptop. On the warehouse tablets it painted once, froze, and reloaded itself, because mobile Safari had killed the tab for using too much memory.

Here is the object we were building, forty thousand times a refresh:

function makeMarker(driver) {
  return {
    id: driver.id,
    lat: driver.lat,
    lng: driver.lng,
    status: driver.status,
    // ...and the look of the pin:
    icon: {
      shape: "M12 0C5 0 0 5 0 12c0 9 12 24 12 24s12-15 12-24C24 5 19 0 12 0z",
      width: 24,
      height: 36,
      palette: { onShift: "#16a34a", idle: "#d97706", offline: "#9ca3af" },
      shadow: "0 1px 2px rgba(0,0,0,.4)",
      labelFont: "600 11px system-ui",
    },
  };
}

Read the icon block. It is the same object every single time. There are three kinds of driver, on shift, idle, and offline, and every pin of a given kind carries a byte-for-byte identical copy of that path string, that palette, those font declarations. Forty thousand pins, a few hundred bytes of duplicated styling each. That is the whole leak. Twenty-odd megabytes of the exact same three descriptions, copied until the tablet gave up.

The fix is embarrassingly small once you see the split. Not “compress the icons,” not “draw fewer pins.” Just stop copying the part that never changes.

The part that repeats and the part that doesn’t

Every one of those marker objects is really two things wearing one coat.

The position and identity are unique. This pin is driver 4471, at this latitude and longitude, currently idle. No other pin has that combination. Change it and you have changed exactly one pin.

The look is shared. The pin shape, the palette, the fonts. Thousands of pins have the identical look, and if you changed it you would want to change all of them at once.

The pattern gives these two halves names, and the names are the entire idea.

Intrinsic state is the part that is the same across many objects and does not depend on where the object is used. The glyph shape, the sprite, the palette. It is context-free: you could describe it fully without knowing anything about one specific pin.

Extrinsic state is the part that is unique to one instance and only makes sense in context. The latitude, the longitude, the id. It belongs to this pin and no other.

Flyweight is one move. Store the intrinsic state once, in a shared object, and keep the extrinsic state out of it. Instead of forty thousand objects that each own a full copy of the look, you get three shared look objects and forty thousand thin records that point at one of them and carry only their own position.

one rendered pinextrinsic · unique to this pinid: 4471lat: 51.507 lng: -0.127status: idlestays on the thin record40,000 of theseintrinsic · shared by thousandsshape, width, heightpalette, shadowlabelFontstored once, read-only3 of these, total
One rendered pin splits in two: the unique position stays on the record, the identical look is stored once and shared.

The refactor: share the look

Pull the intrinsic state out into shared objects and hand them out by kind. There are only three kinds, so there should only ever be three look objects:

// marker-style.js: hands out one shared, frozen style per kind
const PIN = "M12 0C5 0 0 5 0 12c0 9 12 24 12 24s12-15 12-24C24 5 19 0 12 0z";
const LABEL = "600 11px system-ui";

function buildStyle(kind) {
  const fill = { onShift: "#16a34a", idle: "#d97706", offline: "#9ca3af" }[kind];
  return Object.freeze({ shape: PIN, width: 24, height: 36, fill, labelFont: LABEL });
}

const cache = new Map();

export function markerStyle(kind) {
  const cached = cache.get(kind);
  if (cached) return cached;          // already built → hand back the same object

  const style = buildStyle(kind);     // first time we see this kind → build it once
  cache.set(kind, style);
  return style;
}

Now the marker itself gets thin. It holds its own position and a reference to the shared style, not a copy of it:

function makeMarker(driver) {
  return {
    id: driver.id,
    lat: driver.lat,
    lng: driver.lng,
    style: markerStyle(driver.status), // a shared reference, not a fresh copy
  };
}

The load-bearing line is markerStyle(driver.status). Call it ten thousand times with "idle" and it returns the same object ten thousand times. Not an equal object. The same one.

markerStyle("idle") === markerStyle("idle"); // true, one object, shared

Forty thousand markers, three style objects. The twenty megabytes of duplicated look drops to a few hundred bytes, and the tablet stops dying. When it comes time to draw, the render function reads the shared intrinsic state off the style and gets the extrinsic state handed to it from the marker:

function drawMarker(ctx, marker) {
  const { style, lat, lng } = marker;
  drawPin(ctx, style.shape, style.fill, project(lat, lng)); // shared look, this pin's spot
}

The textbook phrases this as “pass the extrinsic state into the flyweight’s methods.” In real code the extrinsic state is just the arguments, and the flyweight is the thing you looked up. Same idea, none of the ceremony.

before · each marker owns its lookid 4471 · lat,lngshape, w, hpalette, shadowlabelFontid 4472 · lat,lngshape, w, hpalette, shadowlabelFontid 4473 · lat,lngshape, w, hpalette, shadowlabelFont40,000 copies of the same 3 looksafter · markers share the lookid 4471 · lat,lngstyle →id 4472 · lat,lngstyle →id 4473 · lat,lngstyle →onShift styleidle styleoffline style3 shared, frozen
Before, every marker owns a full copy of the look. After, thin records share three frozen style objects.

The factory is the whole trick

That markerStyle function is the beating heart of the pattern, and it is doing two jobs at once. It builds the object, and it owns the cache. Ask for a style; if one already exists for that key, you get the existing one; if not, it builds it, stores it, and hands it back. That “return the existing one or create and remember it” move has a name of its own. It is called interning.

This is the factory pattern pulling double duty. In a class-heavy language you would wrap it in a FlyweightFactory object with a getFlyweight method and a private field for the pool. In JavaScript it collapses to a function that closes over a Map. That is the honest shape, and you should not dress it up as more.

The one thing that takes real thought is the key. Two rules, and both bite if you get them wrong.

Put everything intrinsic in the key. If a style is distinguished by more than its kind, say a kind and a zoom level, then the key has to capture both, or two genuinely different looks collide onto one shared object and half your pins render wrong.

Put nothing extrinsic in the key. This is the more seductive mistake. Fold the position into the key and every pin gets its own cache entry, you are back to one object per instance, and now you are paying for a Map on top. The key must describe the shared thing and only the shared thing.

Here is the canonical version, a cache of rendered glyphs keyed by exactly what makes a glyph a glyph:

const glyphs = new Map();

export function glyphFor(char, font, size) {
  const key = `${char}|${font}|${size}`;   // everything intrinsic, nothing else
  let glyph = glyphs.get(key);
  if (!glyph) {
    glyph = Object.freeze(rasterize(char, font, size)); // the expensive bit, done once
    glyphs.set(key, glyph);
  }
  return glyph;
}
markerStyle(kind)cache.get(kind)in the Map?hit · return the SAME objectno allocation, shared by allmiss · build once, freeze itcache.set(kind, style), then returnfoundmissingevery kind is built at most once, then reused forever
A request either hits the cache and reuses the one shared object, or misses, builds it once, freezes it, and stores it.

Never mutate a shared flyweight

There is exactly one rule you cannot break, and breaking it produces the worst kind of bug. A shared object is shared. Mutate it for one instance and you have mutated it for every instance that holds the same reference.

const style = markerStyle("idle");
style.fill = "#000"; // every idle pin on the map just turned black

That is not a typo you catch in review. It is a line that looks local and acts global, and it will have you staring at the one pin you meant to change while forty thousand others quietly changed with it. This is why buildStyle returns Object.freeze(...). A frozen object rejects writes: the assignment above throws in strict mode, which every module is, so the bug becomes a stack trace at the scene of the crime instead of a mystery three screens away.

The deeper principle is that a flyweight is read-only by definition. The whole reason it is safe to share is that nobody owns it enough to change it. Anything that genuinely varies per instance is extrinsic by definition, and extrinsic state lives on the thin record where mutating it hurts only that one record.

You have shipped this already

The clearest example of flyweight in everyday JavaScript is not on a map at all. It is every formatter cache you have ever written:

const money = new Map();

export function formatUsd(cents, currency = "USD") {
  let fmt = money.get(currency);
  if (!fmt) {
    fmt = new Intl.NumberFormat("en-US", { style: "currency", currency });
    money.set(currency, fmt);
  }
  return fmt.format(cents / 100);
}

That Intl.NumberFormat object is a flyweight, and it is a textbook-perfect one. Building it is genuinely expensive, because it loads locale data and compiles a formatting pipeline, and reusing one instead of rebuilding it per call is measured in tens of times faster, sometimes far more. The locale and options are the intrinsic state, identical for every dollar amount you will ever format. The number you pass to format() is the extrinsic state. Look at that last line again: fmt.format(cents / 100) is the pattern’s defining move, passing the per-call state into a method on the shared object. You were doing flyweight the whole time and calling it “not creating the formatter in a loop.”

Once you have the shape in your eye, you see it everywhere, usually under a different name.

Atomic CSS is a flyweight pool for style rules. In a Tailwind-style setup the class p-4 exists in the stylesheet exactly once, as one padding: 1rem declaration, and the ten thousand elements that use it each carry a four-character class name rather than their own copy of the rule. The rule is intrinsic, the element is where it gets applied.

Instanced rendering is the same trick pushed onto the GPU. Drawing a forest with a separate mesh object per tree would melt the machine, so a WebGL scene keeps one geometry and one material and draws them thousands of times with thousands of transform matrices. The mesh and texture are intrinsic, shared by every tree; the position matrix is extrinsic, one per tree. The GPU does the multiplying.

String interning is flyweight for text, and the engine already does some of it for you. Identical string literals in your source get deduplicated, so "idle" written ten thousand times across your files is one string in memory. A string you build at runtime, out of parsed JSON or concatenation, is a fresh allocation every time, and if you are holding millions of them with heavy repetition, interning them through a Map keyed by the string itself is this exact pattern applied to strings.

The canonical case: a million characters

The example everyone reaches for, and the reason the pattern was invented, is a text editor. Open a novel in one and the document is around a million characters. Model each character the naive way, as an object carrying its font, its size, its weight, its color, and the rasterized bitmap of its glyph, and you are holding a million glyph bitmaps in memory. Scrolling turns into swapping.

But an English document uses maybe ninety distinct characters, across a handful of fonts and sizes. The bitmap for a lowercase e in 14px regular is identical every one of the tens of thousands of times it appears. Rasterize it once, intern it, and the document becomes a million thin records, each a codepoint and a position and a pointer to a shared glyph, sitting on top of a few hundred shared glyph objects. The bitmaps went from a million to a few hundred. Editors, terminals, and text layout engines live and die by this, because “a million of something” is their ordinary Tuesday.

1,000,000 characterseach carries glyph + font + sizea million of theseintern byglyph,font,size≈ 90 shared glyphsthe e bitmap exists oncea few dozen of theseevery character still exists as a record; only the heavy bitmap is sharedrecord = codepoint + position + a pointer to one shared glyph
A million document characters point at a few dozen shared glyph objects. The bitmap for each letter exists exactly once.

See the collapse

Two thousand particles, five kinds. In duplicate mode each particle builds its own little type object, the way the naive marker did. In flyweight mode they share one interned type per kind. The picture on screen is identical either way. Watch the object count.

interactiveTwo thousand particles, five type objects

The dots do not move and do not change color. That is the point worth sitting with. Flyweight is invisible from the outside. Nothing about the behaviour changes; the only thing that changes is how many objects it took to get there, which is exactly why it is a memory optimization and not a design you feel in the API.

Flyweight is not an object pool

These two get filed together because both are “make fewer objects,” and confusing them will lead you to build the wrong one. The goals are actually opposite in a way that matters.

Flyweight shares immutable intrinsic state across many live objects at the same time. All ten thousand markers hold the one idle style simultaneously, forever, and nobody ever writes to it. You save memory by refusing to duplicate identical data.

An object pool recycles a small set of mutable objects over time. A game keeps a pool of two hundred bullet objects: fire one, it flies, it dies, it goes back in the pool to be handed out again later as a completely different bullet. The point is to stop allocating and garbage-collecting short-lived churny objects, not to share data between two that exist at once.

One shares read-only data across space. The other reuses writable slots across time. They compose without friction, and a serious particle system uses both: a pool of recycled particle records, each holding the extrinsic state, every one of them pointing at a shared type flyweight for the intrinsic state. Different problems, stacked.

Measure before you reach for it

Here is the honest part, and it matters more for this pattern than for most, because flyweight is over-taught relative to how often you should actually use it. It is a memory optimization. If memory is not your bottleneck, applying it is pure cost: a layer of indirection, a cache to keep coherent, and extrinsic state you now have to thread through every call that used to just read a field.

The threshold is real scale plus real pressure. Thousands of near-identical objects, ideally hundreds of thousands, and a heap profile that actually shows them dominating. Trees, particles, tiles, glyphs, grid cells, log rows, map markers. If you have four hundred of something, this is a solution to a problem you do not have. Reach for the profiler before you reach for the pattern, the same rule that governs every pattern: the messy version is the default, and structure has to earn its place by fixing a measured problem.

There is a subtlety that trips people who half-remember how engines work. Modern JavaScript engines already deduplicate part of this for free, but not the part you think.

what the engine dedupes: the shapeshapeglyph, font, size (copy)shapeglyph, font, size (copy)shapeglyph, font, size (copy)one sharedhidden classshape shared once, values copied N timeswhat flyweight dedupes: the valuesrecord →record →record →shared glyphone bitmapvalues shared once, records point at themV8 gives look-alike objects one hidden class, so the layout is free,but a million objects still hold a million copies of the values inside them
The engine shares the shape of look-alike objects but keeps a full copy of every value. Flyweight is what shares the values.

When you create a lot of objects with the same keys in the same order, V8 hands them a single shared hidden class, a description of the layout, so the shape metadata is not duplicated. That is a genuine and automatic saving. It does nothing for the values. A million objects with identical field contents still store a million copies of those contents, and if the intrinsic blob is a big string, a typed array, or a bitmap, that copy is the whole cost and the engine will not lift a finger to share it. Flyweight shares the values; the engine shares the shape. Knowing which one is actually eating your heap is the difference between fixing the leak and cargo-culting a pattern onto code that was fine. A future chapter on the engine’s memory internals goes deeper on hidden classes; for now the takeaway is narrow: measure, confirm the duplicated values are what is hurting, then intern them.

Summary

  • Flyweight saves memory by sharing the parts of many objects that are identical, instead of letting every instance carry its own copy. It is a niche optimization, and the right tool when you have a huge number of similar objects and memory is the actual bottleneck.
  • The pattern rests on one split. Intrinsic state is shared, context-free, the same for many objects (a glyph bitmap, a sprite, a palette). Extrinsic state is unique per instance and depends on context (a position, an id). Store the intrinsic once; keep the extrinsic on the thin record and pass it in from outside.
  • The mechanism is a factory that interns: return the cached shared object if it exists, otherwise build it once, store it, and return it. In JavaScript that is a function closing over a Map, not a FlyweightFactory class.
  • The cache key must hold everything intrinsic and nothing extrinsic. Miss an intrinsic field and different looks collide; include an extrinsic one and you get an entry per instance, which defeats the point. Canonicalise object keys with a sorted JSON.stringify.
  • A shared flyweight must never be mutated per instance. Mutate it for one and you mutate it for all. Object.freeze your flyweights (and remember it is shallow), and keep them flat.
  • You already ship this constantly: cached Intl formatters, atomic CSS classes, GPU instanced rendering, and string interning are all flyweight. formatter.format(value) passing the value into a method on the shared formatter is the pattern in miniature.
  • Interning is the opposite of cloning. You clone so two things can diverge; you intern so they cannot. That is why a flyweight is immutable.
  • Flyweight is not an object pool. Flyweight shares read-only data across many concurrent objects; a pool recycles writable objects across time to dodge allocation and GC. They compose but solve different problems.
  • The engine already shares the object shape via hidden classes; it does not share the values. Flyweight is what shares the values, and only the values are usually big enough to matter.
  • When to use: hundreds of thousands of near-identical objects whose shared data a profiler shows dominating the heap. When to avoid: any smaller scale, or when memory is not your problem, because the indirection and threaded extrinsic state cost more than the bytes you save.