Use sharing to support very large numbers of fine-grained objects efficiently, by separating the state that can be shared from the state that cannot.
01
The problem Flyweight solves
The problem
Your game renders a forest: one million trees. Each Tree object holds its position, its scale, a slight rotation — and its mesh, its bark texture and its leaf texture, about 2 MB of geometry and pixels.
One million times 2 MB is 2 TB. The level doesn't load. It isn't close to loading.
And here is the maddening part: there are only six species of tree in the whole forest. Every one of those million objects is holding its own private copy of one of six identical meshes. The data that actually differs between two oaks is three floats — a position, a scale, an angle — roughly 24 bytes. You are paying two megabytes to store twenty-four bytes of new information.
02
How the Flyweight pattern works
Split the object's state in two, along the line of "could two of these ever differ?":
Intrinsic state is what's identical between instances and never changes: the mesh, the textures, the species name. Move it into a shared, immutable flyweight object.
Extrinsic state is what differs per instance: position, scale, rotation. It stays with the caller, or is passed in as an argument.
A factory hands out flyweights by key, creating one the first time and returning that same instance forever after. Callers never use new.
A million trees now hold a pointer to one of six shared objects. Memory stops growing with the number of objects and starts growing with the number of distinct kinds.
Immutability isn't a style preference here — it's the load-bearing requirement. The moment one caller can mutate a shared flyweight, it mutates it for the other 999,999, and you get a bug that appears at a distance, under load, in a way that is almost impossible to reproduce. Make the flyweight's fields final, private and copy-free; if you ever need a mutable variant, that state is by definition extrinsic and belongs on the caller.
Participants. The Flyweight (TreeType) holds the immutable intrinsic state. The Factory owns the pool and guarantees that one key returns one instance. The Client keeps the extrinsic state and a reference to a flyweight. Note the direction: flyweights never know their clients, which is what allows one to be shared by a million of them.
03
See it: memory that stops growing
Ask for trees. Ask for the same species several times. Watch the object count and the memory — then turn the pattern off and ask for exactly the same things again.
▶ Try it — plant a forest
An interactive object pool: requesting oak, pine, birch and other species returns one shared TreeType per species — so asking twenty times still allocates six objects. Turn the pattern off and every request allocates its own 2 MB copy of an identical mesh and texture.
Click oak five times with the pattern on: five trees, one object, 2 MB. Turn it off and click oak five times again: five objects, 10 MB, holding five byte-for-byte identical meshes. Scale that to a million and the difference is the level loading or not — and notice the pattern didn't reduce the number of trees, only the number of distinct objects.
04
Flyweight pattern code examples
An immutable shared type, a factory that guarantees one per key, and the extrinsic state left with the caller.
/** The flyweight: intrinsic state only, deeply immutable, shared by everyone. */
public record TreeType(String species, Mesh mesh, Texture bark, Texture leaf) {
private static final Map<String, TreeType> POOL = new ConcurrentHashMap<>();
/** The factory. Callers never see `new`. */
public static TreeType of(String species) {
// computeIfAbsent is atomic — two threads planting the first oak at the
// same moment still end up with exactly one TreeType.
return POOL.computeIfAbsent(species, TreeType::load);
}
private static TreeType load(String species) {
return new TreeType(species, Meshes.load(species), Textures.bark(species), Textures.leaf(species));
}
/** Extrinsic state arrives as arguments — it is never stored here. */
public void draw(Canvas canvas, int x, int y, float scale) {
canvas.render(mesh, bark, leaf, x, y, scale);
}
}
/** The client keeps only what actually differs: 24 bytes, not 2 MB. */
public record Tree(int x, int y, float scale, TreeType type) {
public void draw(Canvas canvas) { type.draw(canvas, x, y, scale); }
}
// The JDK ships flyweights you use without noticing:
// Integer.valueOf(127) == Integer.valueOf(127) // true — cached -128..127
// Integer.valueOf(128) == Integer.valueOf(128) // false — outside the cache
// String literals are interned into a shared pool for exactly the same reason.
// That first pair of lines is also the classic Java interview question.
from dataclasses import dataclass
from functools import lru_cache
@dataclass(frozen=True, slots=True)
class TreeType:
"""frozen: sharing is only safe if nobody can mutate it.
slots: no per-instance __dict__ — which is itself a memory win worth
knowing about, roughly 100+ bytes saved per object.
"""
species: str
mesh: Mesh
bark: Texture
leaf: Texture
def draw(self, canvas: Canvas, x: int, y: int, scale: float) -> None:
canvas.render(self.mesh, self.bark, self.leaf, x, y, scale)
@lru_cache(maxsize=None) # the factory, in one decorator
def tree_type(species: str) -> TreeType:
return TreeType(species, load_mesh(species), bark(species), leaf(species))
@dataclass(frozen=True, slots=True)
class Tree:
x: int
y: int
scale: float
type: TreeType # a reference, not a copy
# CPython does this internally too, and the results surprise people:
# a = 256; b = 256; a is b # True — small ints are pre-allocated
# a = 257; b = 257; a is b # False — outside the cache
# sys.intern(s) # force a string into the shared pool
#
# lru_cache with maxsize=None never evicts, which is what you want for a fixed
# set of species and what you must NOT use for unbounded keys — that's a leak.
#include <memory>
#include <string>
#include <unordered_map>
class TreeType { // flyweight: immutable after construction
public:
TreeType(std::string species, Mesh mesh, Texture bark, Texture leaf)
: species_(std::move(species)), mesh_(std::move(mesh)),
bark_(std::move(bark)), leaf_(std::move(leaf)) {}
void draw(Canvas& c, int x, int y, float scale) const {
c.render(mesh_, bark_, leaf_, x, y, scale);
}
private:
const std::string species_;
const Mesh mesh_;
const Texture bark_, leaf_;
};
class TreeTypeFactory {
public:
std::shared_ptr<const TreeType> of(const std::string& species) {
std::lock_guard lock(m_);
auto& slot = pool_[species];
if (!slot) slot = std::make_shared<const TreeType>(species, loadMesh(species),
bark(species), leaf(species));
return slot;
}
private:
std::mutex m_;
std::unordered_map<std::string, std::shared_ptr<const TreeType>> pool_;
};
// `shared_ptr<const T>` states the two guarantees in the type: shared ownership,
// and nobody can mutate it. Note the cost the pattern doesn't advertise —
// following a pointer to shared data can be a cache miss per object, so a
// million tiny flyweights can be SLOWER than a packed array of plain structs.
// If the data is small, data-oriented layout beats sharing; measure both.
public sealed record TreeType(string Species, Mesh Mesh, Texture Bark, Texture Leaf)
{
private static readonly ConcurrentDictionary<string, TreeType> Pool = new();
public static TreeType Of(string species) =>
Pool.GetOrAdd(species, static s => new TreeType(s, Meshes.Load(s), Textures.Bark(s), Textures.Leaf(s)));
public void Draw(Canvas canvas, int x, int y, float scale) =>
canvas.Render(Mesh, Bark, Leaf, x, y, scale);
}
// Extrinsic state in a readonly struct: no heap allocation, no GC pressure,
// and a million of them sit contiguously in one array.
public readonly record struct Tree(int X, int Y, float Scale, TreeType Type);
// GetOrAdd's factory may run more than once under contention (only one result
// wins) — fine here because loading a mesh is idempotent, a real problem if the
// factory has side effects. Use Lazy<T> as the value when it must run once.
//
// .NET's own flyweights: string interning (string.Intern), and the cached
// boxed values behind Enum and small integers.
// The factory is a Map plus a miss check. Object.freeze buys you the
// immutability the pattern depends on — without it, one caller mutating the
// shared type mutates it for every tree on the map.
const pool = new Map()
export const treeType = species => {
let type = pool.get(species)
if (!type) {
type = Object.freeze({
species,
mesh: loadMesh(species),
bark: bark(species),
leaf: leaf(species),
draw(canvas, x, y, scale) {
canvas.render(this.mesh, this.bark, this.leaf, x, y, scale)
},
})
pool.set(species, type)
}
return type
}
const forest = positions.map(([x, y, s]) => ({ x, y, s, type: treeType(pickSpecies()) }))
// `Symbol.for('x')` is a flyweight built into the language: a global registry
// where the same key always returns the identical symbol, across every module
// in the realm. And be careful with Map as a pool — it holds strong references,
// so an unbounded key space never gets collected. Use WeakRef or an LRU when
// keys are user-supplied.
package forest
// The pool. sync.Map is tuned for exactly this access pattern: written a few
// times at startup, then read from many goroutines forever.
var pool sync.Map // species -> *TreeType
type TreeType struct {
Species string
Mesh Mesh
Bark Texture
Leaf Texture
}
func TypeOf(species string) *TreeType {
if t, ok := pool.Load(species); ok {
return t.(*TreeType)
}
t := &TreeType{species, loadMesh(species), bark(species), leaf(species)}
actual, _ := pool.LoadOrStore(species, t) // last word on the race
return actual.(*TreeType)
}
// Extrinsic state, 24 bytes, stored by value in one contiguous slice.
type Tree struct {
X, Y int32
Scale float32
Type *TreeType
}
// Do not confuse this with sync.Pool. sync.Pool RECYCLES temporary mutable
// objects to reduce GC pressure, and anything it hands you may vanish at the
// next GC. A flyweight is the opposite: permanently shared, never mutated,
// never reclaimed. Same word "pool", opposite lifecycle.
//
// time.LoadLocation is the standard library's flyweight — every call for
// "Europe/London" returns the same *time.Location.
interface TreeType {
readonly species: string
readonly mesh: Mesh
readonly bark: Texture
readonly leaf: Texture
}
const pool = new Map<string, TreeType>()
export function treeType(species: string): TreeType {
const existing = pool.get(species)
if (existing) return existing
const created: TreeType = Object.freeze({
species,
mesh: loadMesh(species),
bark: bark(species),
leaf: leaf(species),
})
pool.set(species, created)
return created
}
// `readonly` is a compile-time promise only — it disappears at runtime, so a
// value that crosses an `any` boundary or arrives from JSON.parse can still be
// mutated. For a genuinely shared object, keep BOTH: readonly for the developer
// and Object.freeze for the runtime.
interface Tree {
readonly x: number
readonly y: number
readonly scale: number
readonly type: TreeType // one pointer, not a copy
}
// A branded key type stops the classic pool bug — two different id spaces
// silently sharing one cache:
type SpeciesKey = string & { readonly __brand: 'SpeciesKey' }
Read across the tabs: most of these languages already ship flyweights you use without naming them — Java's Integer.valueOf cache and string literal pool, CPython's small-int and interned-string tables, .NET's string interning, JavaScript's Symbol.for registry, Go's time.LoadLocation. Two warnings recur across every tab, and they're the ones that bite in production: an unbounded pool is a memory leak with a friendly name, and sharing costs a pointer dereference — for small payloads a packed array of plain structs can beat a flyweight outright, so measure before you assume.
05
How to implement Flyweight
Measure first. Flyweight is an optimisation, and it costs you indirection and a factory — apply it when a profiler says object count is the problem, not on suspicion.
Split the fields: anything that could differ between two instances is extrinsic and must leave the shared object.
Make the flyweight deeply immutable — final fields, no mutable collections, no arrays handed out by reference.
Write a factory keyed on the intrinsic state, and make callers use it instead of a constructor. Hide or delete the public constructor.
Make the factory thread-safe with a concurrent map or a once-primitive, so the first concurrent request doesn't create two.
Bound the pool. A cache keyed on user input with no eviction is a leak. Fixed key space, or an LRU with a real limit.
Pass extrinsic state as arguments, and keep the caller's per-instance record as small and as contiguous in memory as your language allows.
06
When to use Flyweight — and when not to
Use it when an application creates a very large number of similar objects, when storage cost is genuinely a problem, when most of each object's state can be made extrinsic, and when the number of distinct objects after sharing is far smaller than the number of instances. If those four aren't all true, you are adding a factory and an indirection for nothing.
Where it goes wrong
A mutable flyweight. One caller sets a field and every other user of that instance changes with it, at a distance, under load. This is the pattern's signature bug and it is brutal to diagnose. Immutability is not optional.
An unbounded pool. "Cache the parsed value by string key" is a flyweight factory, and if the keys come from user input it is a memory leak that grows exactly as fast as your traffic. Bound it or key it on a closed set.
Slower, not faster. Sharing replaces inline data with a pointer, and a pointer is a potential cache miss on every access. For small intrinsic state, a packed array of plain values regularly beats a flyweight. Benchmark rather than assume.
Identity comparisons that used to work. After sharing, a == b may be true for objects that were logically distinct, and code that used object identity as a key silently merges them.
Premature application. Flyweight makes construction indirect and debugging harder for every reader of the code. Ten thousand objects do not need it; ten million might.
You want to…
Use
Because
Share identical immutable state across many objects
Flyweight
Memory scales with distinct kinds, not instance count.
Every Flyweight needs a factory; not every factory shares.
07
Quick check
🧠 Quick check
A colleague adds a timesDrawn counter to TreeType and increments it in draw(). What happens?
A counter that differs per tree is extrinsic state, and putting it on the shared object breaks the one rule the pattern depends on. Every oak in the forest now increments the same field, so the number means "all oaks" rather than "this tree", and on multiple threads the increments race and lose. Note the second-best answer is still wrong: single-threading hides the race but not the aliasing — the count is still shared by a million trees. If you need per-tree counts, the counter belongs on Tree; if you need per-species counts, it belongs in a separate metrics map, not inside the immutable object.
In the wild
JavaInteger.valueOf() caches −128 to 127, which is why Integer.valueOf(127) == Integer.valueOf(127) is true and 128 is not. String literals are interned into a shared pool for the same reason.
PythonCPython pre-allocates small integers and interns string literals, so 256 is 256 is true and 257 is 257 is not; sys.intern() forces it explicitly.
C#string.Intern() and the runtime's automatic literal interning; ArrayPool<T> for the related but distinct recycling case.
JavaScriptSymbol.for('key') — a global registry where the same key always yields the identical symbol, in every module of the realm.
Gotime.LoadLocation("Europe/London") returns the same shared *time.Location every time. Not to be confused with sync.Pool, which recycles mutable objects.
C++Qt's implicit sharing (copy-on-write QString, QPixmap), and std::string_view, which shares characters instead of copying them.
What is the difference between intrinsic and extrinsic state?
Intrinsic state is identical for every object sharing the flyweight and never changes — a tree species' mesh and textures, a character glyph's outline, a log event type's schema. Extrinsic state is what makes one instance different from another: position, scale, timestamp. Intrinsic state lives inside the shared object; extrinsic state stays with the client and is passed in as arguments. The test is simple: if two instances could ever disagree about a field, it's extrinsic.
What is the difference between Flyweight and an object pool?
They share the word "pool" and almost nothing else. A flyweight is permanently shared, immutable, and used by many clients at the same time. An object pool (Go's sync.Pool, a JDBC connection pool) recycles mutable objects one borrower at a time to avoid allocation or setup cost, and expects them back. Flyweight optimises memory through sharing; object pooling optimises allocation through reuse. Mutating a pooled object is the point; mutating a flyweight is the bug.
Is String interning an example of the Flyweight pattern?
Yes, and it's the one nearly every developer has already used. Java, C# and Python all keep a pool of string values so identical literals share one object — the intrinsic state is the character data, and there is no extrinsic state at all. Java's Integer cache for −128 to 127 is the same idea and produces the famous interview question where == is true for 127 and false for 128.
Does Flyweight always improve performance?
No, and it's worth being blunt about it. You trade inline data for a pointer, and following that pointer can cost a cache miss on every access — so for small intrinsic state a packed array of plain values often beats a flyweight comfortably. You also add a factory lookup on construction and make debugging harder. The pattern pays when the shared payload is large, the instance count is enormous, and the distinct-kind count is small. Profile before and after; if the numbers don't move, remove it.
How do I stop a flyweight pool leaking memory?
Bound it. A pool keyed on a closed set — six tree species, a fixed set of event types — can never grow and is safe forever. A pool keyed on anything user-supplied (a URL, a customer id, a parsed query) grows with traffic and is a leak with a helpful name. Use an LRU with a real limit, weak references where your language supports them, or don't cache that key space at all.
Finish the handbook, earn the certificate
All 23 Gang-of-Four patterns, each with a simulator you can click and code in seven languages. Free, no signup.