← Writing
The Editorial · Engineering

The Flyweight pattern — or why your family group chat doesn't weigh 4 GB

Issue 023Aug 5, 20267 min read

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.

shared once · the rest belongs to the messageMa × 1namephotocolour"khana khaya?"9:14 pm · seenmsg 1"call me when free"9:31 pm · seenmsg 2"photo bhej diya"10:02 pm · sentmsg 3forty thousand messages, one Ma — stored once, pointed at often
Fig 1One sender object, shared by every message she ever sent. Only the text and the timestamp actually belong to the row.AI-generated figure

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:

chat.jsJavaScript
// 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.

every row carrying its own copy of the sendermsg 1"khana khaya?"name: Maphoto: /ma.jpgcolour: #e0533drole: adminmsg 2"call me"name: Maphoto: /ma.jpgcolour: #e0533drole: adminmsg 3"bhej diya"name: Maphoto: /ma.jpgcolour: #e0533drole: adminthe four lines below the divider are identical in all threenow write them out forty thousand more times
Fig 2The four lines below the divider are byte-for-byte identical in every row. The list is mostly a photocopy of a dozen people.AI-generated figure

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.

author-pool.jsJavaScript
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
  };
}
one pool, looked up by idmessage 1message 2message 3message 4message 5getAuthor(user.id)authorPoolmapapadidifrozen · reuseda dozen sender objects serve the entire history
Fig 3Forty thousand messages, one lookup each, twelve objects at the end of it. The pool is the only place a sender is ever constructed.AI-generated figure

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:

money.jsJavaScript
// 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.

the expensive thing, built once instead of once per rowno pool500 rows → 500 builtpooled500 rows → 2 built, 498 reusedfilled = a new Intl.NumberFormat actually constructedthe work does not disappear — it stops repeating
Fig 4The work doesn't disappear — it stops repeating. Two constructions instead of five hundred, and every row after that is a Map lookup.AI-generated figure

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.

All issues