This is the first article in a series looking at how to write performant Javascript code when it matters. When you are writing code you need to think about where it will be used and what the effects are. Working with small amounts of data, you can get away with many inefficiencies, but it's not long before the beautiful code you wrote bites you because it's plain nasty on the inside. When it matters is when you are processing lots of data or scripting the inside of a frequently executed loop. This series aims to help you spot and avoid costly mistakes in those circumstances.
Let's take immutability. It's become almost a mantra. Perhaps I should feel dirty for mutating an array? Let me explain why that is not always the case.
- Creating new objects allocates memory
- Allocating memory takes time
- Garbage collection takes time when you allocate - causing glitches
- Garbage collection takes time to get rid of the things you just allocated
You typically use immutability because it makes it easier to manage state that may be shared. It's a bit like using Typescript to make it easier to ensure you have the right variables isn't it? No it isn't. Typescript is gone by the time you run your code, those memory allocations are hitting your users time and again.
Now none of this matters if your arrays are 20 entries long and infrequently changing. Maybe you have places where that isn't the case, I know I do.
React states
Let's say we have an array in React we are going to use for something in a renderer. Stick the results in a virtual list maybe. Let's say the user can add things, other users can add things. Let's say this is a chat! Ok, so we can add things and the network can add things - let's pretend there's an event emitter for that.
function Chat() {
const [messages, setMessages] = useState([])
useEffect(()=>{
someEventEmitter.on("newMessage", addMessage);
return ()=>someEventEmitter.off("newMessage", addMessage);
}, [])
return <VirtualList items={messages}>
{message=><Message details={message}/>}
</VirtualList>
function addMessage(message) {
setMessages([...messages, message]);
}
}
Beautiful immutable messages. Woo. Mind you. How expensive is that?
Let's say you become suddenly popular - or you decide to take a live feed of stock prices or something - let's say you got 10,000 messages in there over some time. Let's say each message was roughly 140 characters long. Let's say it's utf8 and that's 140 bytes.
Have a guess how much memory you allocated? The final list is a whopping 1.4mb - but how much did you allocate along the way? Have a guess... The answer is 7GB. Were you close? 7GB. Can you imagine the glitching. But hey at least you managed to keep immutability - because phew, someone could have been using that list... Except they couldn't could they. That list was local. You could have kept an array in a ref and mutated it (see I said mutate again, X rated post!)
function Chat() {
const [, refresh] = useState(0)
const messages = useRef([])
useEffect(()=>{
someEventEmitter.on("newMessage", addMessage);
return ()=>someEventEmitter.off("newMessage", addMessage);
}, [])
return <VirtualList items={messages.current}>
{message=><Message details={message}/>
</VirtualList>
function addMessage(message) {
//Don't look mum
messages.current.push(message)
//Get the whole thing to render again
refresh(performance.now())
}
}
A small saving of 99.98% of the memory immutability cost us.
Conclusion
I'm not saying immutability is always bad. It clearly isn't. But it's frighteningly easy to get into a mess by using it incorrectly.
This example focused on memory, but performance is another issue.
How fast can you add 10,000 integers to an array using immutability?
85,000 times a second if you care to do it the fastest way, 26,000 times with a push
and errr.... 20 times with the spread operator. Just sayin'
Top comments (7)
Of course naively using the JavaScript array will cause enormous memory bloat -- it is not optimized for immutability. It is not even immutable.
Languages supporting immutable by default data structures, such as Clojure and Haskell, use optimizations such as structural sharing to avoid reallocating identical contents.
For JavaScript, structural sharing is implemented by immerjs.
So:
a) You can find dozens of examples on this site that use immutability incorrectly - as I said, I don't disagree it can be useful, but don't just believe you need to keep local things immutable and that's what immer helps with.
b) Immer js is brilliant! It's not magic though. What immer's benefit is that it commits your mutable transactions at the end of a process, not in the middle alleviating a ton of thinking and potential slowness. It's still between 20 and 40 x slower than mutating an array in the example I gave. Now this makes is pretty useful in most UI circumstances, you can probably afford that kind of overhead in many circumstances.
I see. It is indeed a good thing to have one most post so people are more likely to stumble upon this information.
Thanks for the benchmark! I agree immutability is not free. Even Haskell uses internal states within high performing data structures.
Agreed, I mean my projects nearly 100% use some kind of immutability - my whole currently developed system is predicated on using transaction records and never mutating core data. I also love immer.js.
What I want to do in this series is just to point out - sometimes we should examine things in the tightest parts of our code if we are striving for 60fps. Oh yeah, and never do that stuff with [...blah, newThing] if there's a chance we will do it often! - That is just silly :)!!
Thanks for the post! Learned a lot. Just curious about how you arrived at the number 7GB when the message list is using 1.4mb in memory? Could you elaborate on that and the way you calculated it or measured it?
Each time you add a single item to the array, you allocate a whole new array. So it's a factorial sequence. Do it 10,000 times from 0 - 10k and the memory allocated along the way is 7GB. Of course each iteration the previous array is available for garbage collection and is collected, but there you go - the effort of garbage collecting and allocating new space isn't free etc! It can also easily lead to memory fragmentation which requires more complex GC.
Hi thanks for the explanation! I would love to know more about how you can profile the memory allocation to quantitatively analyze the performance problem. Are there any good resources on this?