Memory leak in Vue.js: the story of an insidious bug

Memory leak in Vue.js: the story of an insidious bug

If you develop an application in Vue.js, you constantly need to control memory leaks. This is especially true for SPA applications with dynamic component loading. By architectural design, users should not refresh the browser, so component cleanup and garbage collection should be performed by the application itself.

Today we are going to analyze a real case from e-commerce practice, where an innocuous checkbox “Show promotions” caused an avalanche-like load on the server.

One of my old friends approached me with a very interesting problem. He has a rather old online store made in Vue.js + Symfony and some users complained about incorrect work of the site. From time to time, the online store stopped showing prices and responding to user requests. The problem was complicated by the fact that it was impossible to reproduce this bug, as it did not appear for everyone and only periodically.

Nevertheless, in the process of analyzing the users' behavior and personally talking to them, I managed to get valuable information that helped me to identify the problematic component and fix the bug. In particular, one of the users stated that he went through the product cards, clicked on the discount tracking checkbox, then turned it off. That's when everything became clear to me.

Bottom line: zombie timers

Usage Scenario:

A product page user enables the “Track live discounts” option:

  1. The frontend starts polling /api/live-discounts every 30 seconds
  2. When unchecked, the component is hidden, but...
  3. The timer keeps running!

Here is the component itself, PriceTracker.component.vue (parent component), where the error was found:

<template>
  <div class="product-page">
    <h2>{{ product.name }}</h2>
    <p>Base Price: {{ formatCurrency(product.basePrice) }}</p>
    
    <label class="real-time-toggle">
      <input type="checkbox" v-model="enableRealTimeUpdates" />
      Track real-time discounts
    </label>

    <RealTimeDiscounts 
      v-if="enableRealTimeUpdates" 
      :product-id="product.id"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import RealTimeDiscounts from './RealTimeDiscounts.vue';
import { useProductStore } from '@/stores/product';
import { formatCurrency } from '@/utils/currency';

const productStore = useProductStore();
const product = productStore.currentProduct;
const enableRealTimeUpdates = ref(false);
</script>

There is nothing unusual here. We have a checkbox which, when activated, loads a child component:

RealTimeDiscounts.component.vue:

<template>
  <div class="discount-widget">
    <div v-if="loading" class="loading">Updating prices...</div>
    <div v-else class="discounts">
      <p class="discount">Current Discount: {{ currentDiscount }}%</p>
      <p class="final-price">Final Price: {{ formatCurrency(finalPrice) }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useDiscountApi } from '@/api/discount';
import { formatCurrency } from '@/utils/currency';

const props = defineProps<{
  productId: string;
}>();

const { fetchLiveDiscount } = useDiscountApi();
const currentDiscount = ref(0);
const loading = ref(false);
let updateInterval: number | undefined;

const startPriceUpdates = () => {
  updateInterval = window.setInterval(async () => {
    loading.value = true;
    try {
      const discount = await fetchLiveDiscount(props.productId);
      
      currentDiscount.value = Math.max(
        currentDiscount.value,
        discount.value
      );
    } catch (error) {
      console.error('Discount update failed:', error);
    } finally {
      loading.value = false;
    }
  }, 30000); // Каждые 30 секунд
};

onMounted(startPriceUpdates);


const finalPrice = computed(() => {
  return product.basePrice * (1 - currentDiscount.value / 100);
});
</script>

In this example, we load a component that makes an API request and then use the show/hide button with the v-if directive to add and remove it from the virtual DOM. The problem in this example is that the v-if directive removes the parent element from the DOM, but we didn't clean up the additional DOM fragments created by the child component, resulting in a memory leak.

What we have as a result:

  1. When the checkbox is toggled, new intervals are created and gradually accumulate in memory
  2. Old intervals continue to send requests to `/api/live-discounts`.
  3. Slow responses may arrive in the wrong order, creating a race condition
  4. With constant checkbox switching - avalanche of requests to the backend.

How it all looked from the user's point of view:

  1. User enables “Track real-time discounts”
  2. Component starts interval #1
  3. After 25 seconds the user turns off the checkbox.
  4. Interval #1 continues to run
  5. After 30 sec interval #1 makes a request
  6. User turns the checkbox back on
  7. Interval #2 starts
  8. Now two parallel intervals are sending requests
  9. Eventually, with constant requests to the server, the user periodically receives an error from the server, as a result of which, some components stop receiving the necessary information for display.
  10. The site gradually begins to produce more and more errors until the user completely reloads the page.

The simplest variant of how you can fix such a bug:

 

// RealTimeDiscounts.component.vue
import { onUnmounted } from 'vue';

const abortController = new AbortController();

const startPriceUpdates = () => {
  updateInterval = window.setInterval(async () => {
    if (abortController.signal.aborted) return;

    try {
      const discount = await fetchLiveDiscount(
        props.productId, 
        { signal: abortController.signal }
      );
      
      currentDiscount.value = discount.value;
    } catch (error) {
      if (error.name !== 'AbortError') {
        console.error('Discount update failed:', error);
      }
    }
  }, 30000);
};

onUnmounted(() => {
  clearInterval(updateInterval);
  abortController.abort();
});

import { debounce } from 'lodash-es';

const updateDiscount = debounce((newValue: number) => {
  currentDiscount.value = newValue;
}, 1000);

Additionally, we will need to do some refactoring to not only fix the bug, but also to improve performance overall.

1. exponential bekoff on errors:

let retryCount = 0;
const baseDelay = 30000;

const startPriceUpdates = () => {
  updateInterval = window.setInterval(async () => {
    try {
      await fetchDiscount();
      retryCount = 0;
    } catch (error) {
      retryCount++;
      const delay = baseDelay * Math.pow(2, retryCount);
      setTimeout(startPriceUpdates, delay);
      return;
    }
  }, baseDelay);
};

2. Why not keep statistics of requests:

const metrics = {
  requests: 0,
  errors: 0,
  lastUpdate: null as Date | null
};

setInterval(() => {
  if (metrics.requests > 0) {
    sendMetricsToServer(metrics);
  }
}, 300000);

3. we will also need to refactor the server API:

  • Implement result caching
  • Use WebSockets instead of polling
  • Add rate-limiting for `/api/live-discounts`.
  • Implement price versioning using ETag validation

How to detect this bug:

  1. Monitor the number of active connections
  2. Analyze server logs, fix repeated requests from one client
  3. Perform load testing of the component
  4. To get on the trail of the problem itself, use developer tools:
  • Network tab - there you can quickly detect duplicate requests
  • Performance monitor - to monitor the growth of memory utilization
  • Vue DevTools - to detect multiple instances of the component.

Be careful about memory leaks on the client side. Such a bug can lead to:

  • 20-40% increase in cloud infrastructure cost
  • API response speed degradation by 300-500 ms
  • False alarms of monitoring systems
  • Incorrect display of prices when competing requests are made

Conclusion

The “forgotten” timers bug is a classic example of a problem that:

  • Easy to create with rapid development
  • Hard to detect without proper monitoring
  • Expensive to scale

General rule: Any resource created in onMounted must have an explicit cleanup handler in onUnmounted.

Popular Posts

My most popular posts

Maximum productivity on remote job
Business

Maximum productivity on remote job

I started my own business and intentionally did my best to work from anywhere in the world. Sometimes I sit with my office with a large 27-inch monitor in my apartment in Cheboksary. Sometimes I’m in the office or in some cafe in another city.

Hello! I am Sergey Emelyanov and I am hardworker
Business PHP

Hello! I am Sergey Emelyanov and I am hardworker

I am a programmer. I am an entrepreneur in my heart. I started making money from the age of 11, in the harsh 90s, handing over glassware to a local store and exchanging it for sweets. I earned so much that was enough for various snacks.

Hire Professional CRM developer for $25 per hour

I will make time for your project. Knowledge of Vtiger CRM, SuiteCRM, Laravel, Vue.js, Wordpress. I offer cooperation options that will help you take advantage of external experience, optimize costs and reduce risks. Full transparency of all stages of work and accounting for time costs. Pay only development working hours after accepting the task. Accept PayPal and Payoneer payment systems. How to hire professional developer? Just fill in the form

Telegram
@sergeyem
Telephone
+4915211100235
Email