Flyweight Pattern and Interning in JavaScript
I started reading about the release of the new unique package in Go, which implements interning to help with canonicalizing values, enable comparison by pointers, and achieve a smaller overall memory footprint.
After some reading, I clicked on the wikipedia page linked by the release blog. The first thing mentioned was the Flyweight pattern and how interning is used there. As you might know, the flyweight pattern is a structural pattern that relies on interning for storing and sharing the intrinsic state of an object.
Not long after, I found myself diving a little deeper into how the Flyweight pattern would look like in JavaScript.
The Flyweight concept and prototype chaining
For example, if we have a book object that is being kept in multiple stores, it might be defined in the following way:
type Book = {
name: string,
ISBN: string,
storeId: number,
amount: number,
}
We can restructure it so that we share the common intrinsic Book properties between different instances and allocate memory only for the extrinsic properties:
type Book = {
name: string,
ISBN: string,
}
type StoredBook = {
__proto__: Book,
storeId: number,
amount: number
}
By using the reserved __proto__
keyword here,
JavaScript is able to resolve the intrinsic state by walking down the prototype chain.
That is, if name
doesn’t exist on StoredBook
, it checks on __proto__
, which is Book
.
Posts on the internet agree with me, but …
As a matter of fact, this is what this odd page explaining the Flyweight Pattern in vanilla JS is suggesting. Nevertheless, they end up providing an example that does the exact opposite of the intent behind interning and the flyweight pattern.
And in this chaotic post, since developers have no better use of their time, we will examine the reasons why the author of this post should be banned from writing about design patterns…
Just kidding—everyone makes mistakes. Nobody deserves to be banned. I’ve written plenty of incoherent stuff myself, and this post might be one of them.
Let’s explore why their example is incorrect
const createBook = (title, author, isbn) => {
const existingBook = books.has(isbn);
if (existingBook) {
return books.get(isbn);
}
const book = new Book(title, author, isbn);
books.set(isbn, book);
return book;
};
// ...
const bookList = [];
const addBook = (title, author, isbn, availability, sales) => {
const book = {
...createBook(title, author, isbn),
sales,
availability,
isbn,
};
bookList.push(book);
return book;
};
The createBook
function creates a new book object if the ISBN hasn’t been stored before, and returns the existing object if it has. So far, this works as intended.
Not sharing intrinsic state but copying it
The issue arises in the steps after that. The Flyweight pattern is a design strategy aimed at saving memory by sharing the intrinsic state between logically similar objects. However, in this implementation, the book objects are not shared because the “interned” structure (also called the intrinsic state) is actually destructured.
When we destructure values of primitive types (such as strings and numbers) to use as keys and values in a new object, we end up creating new memory allocations. In the example, we destructure two properties — isbn
and name
— whose values represent the intrinsic state, which means that they’re expected to keep their value the same between different instances.
For example, if we have two books that are derivatives of Harry Potter, they are supposed to share the same values for isbn
and name
.
According to the example design, if we change the properties in the book object that is supposed to store both the intrinsic and extrinsic state, none of the objects which are supposedly implementing
the flyweight pattern will reflect this change. Why? Because the values are copied! Copying means using more memory!
Surprise! Copying doesn’t always increase memory usage significantly
On subsequent initializations of the same string (in our case the intrinsic state - isbn
and name
), V8 is not allocating the same space of memory for the strings; instead, it returns a reference to their
location in the string pool.
Therefore, we’re not necessarily using up memory for initializing the intrinsic state strings, as they’ve already been allocated once. We’re actually only using memory for keeping the references to said strings.
If strings weren’t interned by the engine, desctructuring would have been way worse because we would’ve had to allocate space for each string - O(n) space complexity.
Even if strings are interned and allocated just once, the desctructuring approach is still bad.
Although the author got lucky because strings are interned (which I mention because I doubt this was intentional), their approach to the Flyweight pattern is still invalid.
Every new property and its value take up space. If you look at it logically, it makes much more sense to keep one property (__proto__
) pointing to a shared object than to allocate space for two new properties pointing (isbn
and name
) to shared strings, right?
Let’s abstract the complexity and think of each property as pointing to a reference, which in turn points to a different thing in memory space. Let’s say all references are 64 bits (8 bytes) in size.
You can see how we incur a memory cost that increases N times, where N is the count of the properties.
Well, this is a simplified explanation and may not fully reflect the exact truth. Depending on the engine’s optimization, memory for the object’s properties is initially over-allocated and later reduced once the exact size needed for existing and new properties is determined.
Additionally, JavaScript engines are optimized to minimize costs of references. You wouldn’t store a pointer to a space where an interned string is stored; you would probably use some other mechanism that already knows where the pool is and resolves the string by some handle, which takes up less space than a regular pointer (8 bytes for 64 bit systems).
To be fair, I’m just trying to show you why the flyweight pattern saves up memory in regular programming terms.
The example given in the article is always creating new objects by destructuring the “main” object, whose properties should be interned as they’re the intrinsic state. It cannot even be considered a bad example of interning or the flyweight pattern because it literally does neither of the two.
Try this at home
const intern = {
name: "Harry Potter",
ISBN: "1287834834"
}
for (let i = 0; i < 10; i++) {
let obj = {
bookstoreId: i,
...intern // destructuring instead of extending the chain
}
}
// for (let i = 0; i < 10; i++) {
// let obj = {
// bookstoreId: i,
// __proto__: intern
// }
// }
console.log(process.memoryUsage().heapUsed );
The script is run on V8 and no garbage sweeps are executed in the meantime. The difference between the two approaches here is around 344 bytes in favor of the proper interning approach.
PoC comparison
class Book {
constructor(title, author, isbn) {
this.title = title;
this.author = author;
this.isbn = isbn;
}
}
const books = [];
const book = new Book("Book Title", "Author Name", "1234567890");
for (let i = 0; i < 10000; i++) {
// books.push({
// ...book,
// someArbitraryData: "Some arbitrary data",
// });
books.push({
__proto__: book,
someArbitraryData: "Some arbitrary data",
});
}
I wanted to run the above script without invoking any GC cycles. Unfortunately, we cannot disable garbage collection in V8 that easily.
Some of the suggestions on the internet are outdated, and even if you set the max old space
size, garbage collection is still triggered.
Results
Last GC sweep out of 4 for the improper interning approach:
[87242:0x160008000] 12 ms: Scavenge 6.1 (8.3) -> 5.7 (9.3) MB, 0.38 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
And the only one incurred GC sweep for the proper interning approach:
[85419:0x110008000] 13 ms: Scavenge 3.9 (4.0) -> 3.5 (5.0) MB, 0.38 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
You can see that the used memory difference is around 2 MB, and the heap allocated size is almost twice as much. Not only that, but we had 4 sweeps instead of 1 when using the improper approach.
Conclusion
Don’t rely too heavily on my metrics! JavaScript is not C — memory allocation is abstracted to such a degree that, without diving into the engine’s source code, it’s difficult to know exactly how a given operation uses memory. Generally, you can save memory by following best practices and basic memory management principles, similar to those in languages like C, Zig, Rust, or Go. However, if you need to analyze memory usage this deeply, you might be better off using a different language platform. The goal of this blog post is to explain why the example design pattern is flawed and how a proper approach leads to more efficient resource use — even though no concrete examples of how the memory is allocated were provided.