The Flyweight pattern — or why your family group chat doesn't weigh 4 GB
Five years of a family WhatsApp group is forty thousand messages from about twelve people. Your mother's profile photo is stored once, not eight thousand times, and every message she sent just points at it. That's the Flyweight pattern: split what's identical for everyone from what's yours alone, keep one copy of the first, and stop paying for it again on every row. Here's the version a real frontend needs — a chat list, a map with two thousand pins, and the price formatter quietly rebuilt on every line of an invoice.
The issue
Open your family WhatsApp group and scroll all the way up. Five years, forty thousand messages, about twelve people. Now ask a slightly strange question: how many copies of your mother's profile photo are sitting on your phone? One. Not eight thousand — one. Every message she has ever sent points at that single photo, and her name and her colour are stored once too. Nobody thinks of this as clever engineering because the alternative is obviously absurd. But the absurd version is exactly what most code does by default, and the Flyweight pattern is just the habit of noticing.
The whole pattern rests on one split. Look at any long list — messages, orders, map pins, table rows — and every item is made of two halves. There's the half that is identical for everyone: the sender's name, their photo, their colour. And there's the half that is yours alone: the text of this message, the time it was sent, whether it was read. Keep one copy of the first half. Leave the second half where it is.
The formal names for the two halves are intrinsic and extrinsic state, which is the kind of vocabulary that makes a simple idea sound like a graduate course. Shared and per-item is closer to what you'll actually say out loud.
The naive version
Here's how the duplication gets in, and it's never a bad decision — it's a reasonable one. The API sends messages with a nested user object. The component wants flat props. So somebody writes the obvious mapping function:
// one flat object per message, ready for the component
const messages = history.map((m) => ({
body: m.body,
timestamp: m.timestamp,
authorName: m.user.name,
authorAvatar: m.user.avatarUrl,
authorColor: m.user.color,
authorRole: m.user.role,
}));Six lines, no cleverness, works perfectly. It also just wrote your mother's name and photo URL out eight thousand times, and it will do it again from scratch every time the chat screen mounts.
Fix: keep one, hand it out
The fix is a Map and a function, and it's about ten lines. Ask for a sender by id; if you've built one before, get that exact object back; otherwise build it, remember it, return it.
const authorPool = new Map();
function getAuthor(user) {
const cached = authorPool.get(user.id);
if (cached) return cached;
// frozen on purpose — see the warning further down
const author = Object.freeze({
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
color: user.color,
role: user.role,
});
authorPool.set(user.id, author);
return author;
}
export function makeMessage(raw) {
return {
author: getAuthor(raw.user), // shared
body: raw.body, // yours
timestamp: raw.timestamp, // yours
};
}Forty thousand messages now hold forty thousand references to twelve objects. And you get something you didn't ask for: because every message from Ma holds the same object, React.memo comparing old props to new props sees an identical reference and skips the avatar entirely. The rewrite that was supposed to save memory quietly halves your render cost too — the same identity argument Issue 018 makes about keys.
The half that matters more: work you stop repeating
Modern JavaScript engines are good at small objects, so the megabytes you save are rarely the headline. The real win is with objects that are expensive to construct — and the most common one in any product that shows money is the currency formatter:
// the slow way: a new formatter for every row of the invoice
rows.map((r) => new Intl.NumberFormat("en-IN", {
style: "currency", currency: "INR",
}).format(r.amount));
// the pooled way
const formatters = new Map();
export function money(locale, currency) {
const key = `${locale}:${currency}`;
let f = formatters.get(key);
if (!f) {
f = new Intl.NumberFormat(locale, { style: "currency", currency });
formatters.set(key, f);
}
return f;
}
money("en-IN", "INR").format(2499); // "₹2,499.00"That constructor is not a cheap one — it loads locale data and builds a formatting pipeline. Doing it per row of a 500-line invoice is real, measurable time on the main thread, and it happens again on every re-render. Pooled, it happens twice in the app's life: once for rupees, once for dollars.
Once you've seen the shape, it turns up everywhere. A delivery app drawing two thousand restaurant pins uses four pin images, not two thousand. A game drawing ten thousand blades of grass uploads one blade to the GPU and ten thousand positions — Three.js calls it InstancedMesh. Tailwind's whole premise is that p-4 is one CSS rule shared by ten thousand elements instead of ten thousand copies of the same declaration. Different industries, same trick.
If you don't write the code
This one is worth recognising from the other side of the table, because it shows up in tickets that don't sound like memory problems. "The list is fine in testing but janky on real accounts." "The tab gets slower the longer it's open." "It's smooth on my iPhone and unusable on the demo Android." Those three are frequently the same bug: something identical is being rebuilt per row, and the difference between your test account and a real one is the number of rows.
The useful question in that meeting isn't "can we optimise it" — it's "how much of each row is actually different?" If the honest answer is "the text and the time, everything else repeats," you have a day of work in front of you, not a rewrite.
When not to reach for one
A pool is a cache, and every cache is a promise to keep something correct that you're no longer looking at. That promise has a price, and it's not always worth paying.
For the GPU-instancing and structural-sharing end of this — where the same idea gets applied to particles and immutable trees rather than chat rows — patterns.dev's Flyweight Pattern writeup is the right next stop.