Tips for Managing Memory in JavaScript

Image by Geranimo.

It is fair to say that memory management is fairly far down the list of considerations when writing JavaScript. The language handles most of it for us with garbage collection, automatically making memory available for reuse after values become unreachable. The collection schedule is controlled by the engine.

However, this does not mean we can ignore memory entirely. Poor memory management can lead to leaks, where data that is no longer useful remains reachable and retained, causing memory to grow during repeated work. Longlived caches, event listeners and callback registries can all retain object graphs. When the code we are writing runs on our visitor's machine, we have to be considerate of the resources we use not everybody is accessing our projects using the latest offering from Apple.

To keep our applications running efficiently, we need to understand how JavaScript handles memory and how to avoid common pitfalls. In this article, I will explore practical strategies to improve memory usage, prevent leaks, and optimise performance.


How JavaScript Manages Memory

A useful working model has three parts:

  1. Memory Allocation

    – When we declare a variable or create an object, memory is assigned to store it.
  2. Reachability

    – Values reachable from roots such as the active execution stack and global object must be kept.
  3. Garbage Collection

    – An engine may reclaim unreachable values. JavaScript does not guarantee when collection will run.

This process makes JavaScript easy to use, but garbage collection is not instant. If an application keeps a path from a root to data it no longer needs, the collector must treat that data as live. A useful leak investigation therefore looks for retained paths and repeated growth, rather than assuming every temporary allocation is a leak.


Avoiding Memory Leaks in JavaScript

Even though JavaScript cleans up memory automatically, we can still introduce leaks if we are not careful. Here are some common issues and how to avoid them.

1. Limiting Global Variables

Global state remains reachable for as long as its realm remains active. An unbounded cache makes the retained growth visible:

Problem:

const cache = new Map<number, number[]>();for (let i = 0; i < 1000; i++) {  cache.set(i, new Array(1000).fill(i));}console.log(cache.size);  // 1000 retained entries

Better Approach:

Local scope helps, but the important rule for a longlived cache is to define its retention policy. This example caps the number of entries:

const MAX_CACHE_ENTRIES = 100;const cache = new Map<number, number[]>();const remember = (key: number, value: number[]): void => {  cache.set(key, value);  if (cache.size > MAX_CACHE_ENTRIES) {    const oldestKey = cache.keys().next().value;    if (oldestKey !== undefined) cache.delete(oldestKey);  }};for (let i = 0; i < 1000; i++) {  remember(i, new Array(1000).fill(i));}console.log(cache.size);  // 100 retained entries

2. Cleaning up Event Listeners

An event target retains its registered listener. If that listener closes over component state, a longlived target such as window can retain the state after the component has gone.

Problem:

const mountWidget = (): void => {  const state = new Array(1000).fill('widget state');  const handleResize = (): void => console.log(state.length);  window.addEventListener('resize', handleResize);};mountWidget();

Each call mounts a listener on window, but exposes no way to remove it. Repeated mounts therefore retain more listeners and their captured state.

Better Approach:

Keep the same handler reference and return an explicit cleanup function for the component lifecycle:

const mountWidget = (): (() => void) => {  const state = new Array(1000).fill('widget state');  const handleResize = (): void => console.log(state.length);  window.addEventListener('resize', handleResize);  return (): void => window.removeEventListener('resize', handleResize);};const unmountWidget = mountWidget();// Later, when the widget is removed:unmountWidget();

3. Managing Closures Carefully

Closures retain access to values from their outer scope. That is expected behaviour, not a leak by itself. A leak appears when a longlived registry keeps closures that the application no longer needs.

Problem:

const callbacks = new Set<() => number>();const registerCounter = (): void => {  let count = 0;  const counter = (): number => count++;  callbacks.add(counter);};for (let i = 0; i < 1000; i++) registerCounter();console.log(callbacks.size);  // 1000 retained closures

Better Approach:

Remove the closure from the retaining registry when its work is complete. Resetting the captured number would not release the closure itself:

const callbacks = new Set<() => number>();const registerCounter = (): (() => void) => {  let count = 0;  const counter = (): number => count++;  callbacks.add(counter);  return (): void => {    callbacks.delete(counter);  };};const unregisterCounter = registerCounter();unregisterCounter();console.log(callbacks.size);  // 0 retained closures

Writing Memory‑Efficient Code

Beyond avoiding leaks, we can also improve the memory we do use to keep our applications running smoothly.

1. Pool Objects Only After Measuring Allocation Pressure

Shortlived object allocation is normal and usually preferable to extra lifecycle machinery. Consider pooling only when profiling identifies allocation churn in a hot path.

Ordinary Allocation:

const createUser = (name: string): { name: string; id: number } => ({  name,  id: Math.random(),});const users: { name: string; id: number }[] = [];for (let i = 0; i < 1000; i++) {  users.push(createUser(`User ${i}`));  // Creates 1000 objects}

A Measured Pooling Approach:

A pool only reuses objects if the application returns them after use. In a measured hot path, that lifecycle could look like this:

type User = { name: string; id: number };const userPool: User[] = [];let objectsCreated = 0;const getUser = (name: string): User => {  const pooledUser = userPool.pop();  if (pooledUser) {    pooledUser.name = name;    pooledUser.id = Math.random();    return pooledUser;  }  objectsCreated++;  return { name, id: Math.random() };};const releaseUser = (user: User): void => {  user.name = '';  user.id = 0;  userPool.push(user);};for (let i = 0; i < 1000; i++) {  const user = getUser(`User ${i}`);  processUser(user);  releaseUser(user);}console.log({ objectsCreated, poolSize: userPool.length });

2. Reduce Unnecessary Object References

A local variable is not necessarily the reference that keeps data alive. Inspect the retaining path; here, the cache owns the longlived reference.

Problem:

const cache = new Map<string, unknown>();const largeData = fetchLargeDataset();cache.set('report', largeData);processData(largeData);console.log(cache.size);  // 1 retained entry

Better Approach:

Remove the entry from the object that retains it. Assigning null to a separate local variable would not remove the cache's reference:

const cache = new Map<string, unknown>();const largeData = fetchLargeDataset();cache.set('report', largeData);processData(largeData);// When the cached result is no longer required:cache.delete('report');console.log(cache.size);  // 0 retained entries

3. Reduce Allocation Churn in Measured Hot Loops

Creating temporary arrays does not by itself create retained growth; unreachable arrays are collectible. In a measured hot loop, repeated allocation can still add collection pressure.

Allocation‑Heavy Version:

for (let i = 0; i < 10000; i++) {  const tempArray = new Array(1000).fill(0);  // Short-lived allocation}

Reuse After Profiling:

Where profiling shows allocation churn is material, reuse one work buffer without retaining every result:

const tempArray = new Array(1000).fill(0);for (let i = 0; i < 10000; i++) {  tempArray.fill(0);  // Reuses one measured work buffer}

Wrapping Up

JavaScript's automatic memory management makes development easier, but inefficient memory use can still cause problems. By understanding how memory is allocated and cleaned up, we can avoid common pitfalls and write more efficient code.

Key Takeaways

  • JavaScript uses automatic memory management, but leaks can still happen.
  • Global variables, event listeners, and closures

    can retain data when longlived roots keep references after the data is no longer useful.
  • Removing the retaining path

    and cleaning up registered listeners prevents avoidable retention; manual nulling is not a general recipe.
  • Pooling objects and reusing work buffers

    are specialist optimisations to apply only after profiling shows allocation pressure.

Managing memory well helps keep our JavaScript applications running smoothly without unnecessary slowdowns. By being mindful of how we allocate and release memory, we can build more efficient, reliable software that performs well over time.


Have a complex web platform issue?

Tell me what is blocked, what has changed, and what needs to be true after the fix. I'll come back with a practical next step.