How AbortController Can Replace removeEventListener and Prevent Memory Leaks

Discover how using the AbortController API to manage event listeners can eliminate memory leaks, simplify cleanup code, and improve maintainability across vanilla JavaScript, drag‑and‑drop, and React projects, replacing the cumbersome removeEventListener pattern with a single abort call.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
How AbortController Can Replace removeEventListener and Prevent Memory Leaks

Stop Using removeEventListener – AbortController Saves My Job

Yesterday a product manager complained that our admin panel consumed 2 GB of RAM, caused by forgotten event listeners. The author realized that using AbortController can centralize listener registration and cleanup.

Original Messy Implementation

export default class DataGrid {
  constructor(container, options) {
    this.container = container;
    this.options = options;
    this.handleResize = this.handleResize.bind(this);
    this.handleScroll = this.handleScroll.bind(this);
    this.handleClick = this.handleClick.bind(this);
    this.handleKeydown = this.handleKeydown.bind(this);
    this.handleContextMenu = this.handleContextMenu.bind(this);
    this.init();
  }

  init() {
    // register listeners
    window.addEventListener('resize', this.handleResize);
    this.container.addEventListener('scroll', this.handleScroll);
    this.container.addEventListener('click', this.handleClick);
    document.addEventListener('keydown', this.handleKeydown);
    this.container.addEventListener('contextmenu', this.handleContextMenu);
    // timers
    this.resizeTimer = null;
    this.scrollTimer = null;
  }

  destroy() {
    // often forget to remove some listeners
    window.removeEventListener('resize', this.handleResize);
    this.container.removeEventListener('scroll', this.handleScroll);
    this.container.removeEventListener('click', this.handleClick);
    document.removeEventListener('keydown', this.handleKeydown);
    // contextmenu removal missed
    if (this.resizeTimer) clearTimeout(this.resizeTimer);
    if (this.scrollTimer) clearTimeout(this.scrollTimer);
  }
}

The code suffers from repetitive binding, easy omission of cleanup, and hard maintenance.

Using AbortController for Unified Cleanup

export default class DataGrid {
  constructor(container, options) {
    this.container = container;
    this.options = options;
    this.controller = new AbortController();
    this.init();
  }

  init() {
    const { signal } = this.controller;
    window.addEventListener('resize', e => {
      clearTimeout(this.resizeTimer);
      this.resizeTimer = setTimeout(() => this.handleResize(e), 200);
    }, { signal });

    this.container.addEventListener('scroll', e => this.handleScroll(e), { signal, passive: true });
    this.container.addEventListener('click', e => this.handleClick(e), { signal });
    document.addEventListener('keydown', e => {
      if (e.key === 'Delete' && this.selectedRows.length > 0) {
        this.deleteSelectedRows();
      }
    }, { signal });
    this.container.addEventListener('contextmenu', e => {
      e.preventDefault();
      this.showContextMenu(e);
    }, { signal });
  }

  destroy() {
    // one line cleans everything
    this.controller.abort();
  }
}

Calling controller.abort() removes all listeners registered with the same signal.

Common Pitfalls

AbortController cannot be reused after abort; a new instance must be created for subsequent uses. The article shows a wrong modal example and the corrected version.

Drag‑and‑Drop Sorting Example

class DragSort {
  constructor(container) {
    this.container = container;
    this.isDragging = false;
    this.dragElement = null;
    this.initDrag();
  }

  initDrag() {
    const dragController = new AbortController();
    this.dragController = dragController;
    const { signal } = dragController;

    this.container.addEventListener('mousedown', e => {
      const card = e.target.closest('.card');
      if (!card) return;
      this.startDrag(card, e);
    }, { signal });
  }

  startDrag(card, startEvent) {
    const moveController = new AbortController();
    const { signal } = moveController;
    this.isDragging = true;
    this.dragElement = card;
    const startX = startEvent.clientX;
    const startY = startEvent.clientY;
    const rect = card.getBoundingClientRect();
    const ghost = card.cloneNode(true);
    ghost.style.position = 'fixed';
    ghost.style.left = rect.left + 'px';
    ghost.style.top = rect.top + 'px';
    ghost.style.pointerEvents = 'none';
    ghost.style.opacity = '0.8';
    document.body.appendChild(ghost);

    document.addEventListener('mousemove', e => {
      const deltaX = e.clientX - startX;
      const deltaY = e.clientY - startY;
      ghost.style.left = rect.left + deltaX + 'px';
      ghost.style.top = rect.top + deltaY + 'px';
      this.updateDropIndicator(e);
    }, { signal });

    document.addEventListener('mouseup', () => {
      this.endDrag(ghost);
      moveController.abort();
    }, { signal, once: true });

    document.addEventListener('selectstart', e => e.preventDefault(), { signal });
    document.addEventListener('contextmenu', e => e.preventDefault(), { signal });
  }

  destroy() {
    this.dragController?.abort();
  }
}

Each drag creates its own controller, ensuring automatic cleanup.

React Hook Wrapper

import { useEffect, useRef } from 'react';

function useEventController() {
  const controllerRef = useRef();

  useEffect(() => {
    controllerRef.current = new AbortController();
    return () => {
      controllerRef.current?.abort();
    };
  }, []);

  const addEventListener = (target, event, handler, options = {}) => {
    if (!controllerRef.current) return;
    const element = target?.current || target;
    if (!element) return;
    element.addEventListener(event, handler, {
      signal: controllerRef.current.signal,
      ...options,
    });
  };

  return { addEventListener };
}

// usage
function MyComponent() {
  const { addEventListener } = useEventController();
  const buttonRef = useRef();

  useEffect(() => {
    addEventListener(window, 'resize', () => console.log('window resized'));
    addEventListener(buttonRef, 'click', () => console.log('button clicked'));
  }, []);

  return <button ref={buttonRef}>Click me</button>;
}

The hook creates a single AbortController for the component and aborts it on unmount.

Browser Compatibility

AbortController is supported in Chrome 66+, Firefox 57+, Safari 11.1+. For older browsers you can fall back to manual listener tracking.

class EventManager {
  constructor() {
    this.useAbortController = 'AbortController' in window;
    if (this.useAbortController) {
      this.controller = new AbortController();
    } else {
      this.handlers = [];
    }
  }

  on(target, event, handler, options = {}) {
    if (this.useAbortController) {
      target.addEventListener(event, handler, { signal: this.controller.signal, ...options });
    } else {
      this.handlers.push({ target, event, handler, options });
      target.addEventListener(event, handler, options);
    }
  }

  destroy() {
    if (this.useAbortController) {
      this.controller.abort();
    } else {
      this.handlers.forEach(({ target, event, handler, options }) => {
        target.removeEventListener(event, handler, options);
      });
      this.handlers = [];
    }
  }
}

In the author’s project the modern browsers made the AbortController approach safe to adopt.

Conclusion

AbortController not only cancels fetch requests; it can also manage any event listener, turning a scattered removeEventListener cleanup into a single abort() call, reducing bugs and maintenance effort.

frontendJavaScriptReActMemory Leakevent listenersAbortController
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.