How to throttle API calls in Vue

Throttling API calls in Vue limits execution frequency for high-frequency events like scroll, resize, and mouse movement. With over 12 years of Vue.js experience since 2014 and as the creator of CoreUI, I’ve optimized performance with throttling in data-heavy dashboards. Throttling ensures a function executes at most once per specified time interval, unlike debouncing which delays execution. This approach prevents excessive API calls during continuous events while maintaining responsiveness.

Use throttle to limit API call frequency during high-frequency events like scroll, ensuring consistent execution intervals.

Basic throttle implementation:

<template>
  <div @scroll='handleScroll' class='scrollable-container'>
    <div class='content'>
      <p v-for='item in items' :key='item'>{{ item }}</p>
    </div>
    <div v-if='loading'>Loading more...</div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const items = ref(Array.from({ length: 50 }, (_, i) => `Item ${i + 1}`))
const loading = ref(false)
let lastCallTime = 0
const throttleDelay = 1000 // Execute at most once per second

const loadMoreItems = async () => {
  loading.value = true
  await new Promise(resolve => setTimeout(resolve, 500))
  const newItems = Array.from({ length: 20 }, (_, i) =>
    `Item ${items.value.length + i + 1}`
  )
  items.value.push(...newItems)
  loading.value = false
}

const handleScroll = (event) => {
  const now = Date.now()

  if (now - lastCallTime < throttleDelay) {
    return // Skip execution
  }

  lastCallTime = now

  const element = event.target
  const scrolled = element.scrollTop + element.clientHeight
  const total = element.scrollHeight

  if (scrolled >= total - 100) {
    loadMoreItems()
  }
}
</script>

<style scoped>
.scrollable-container {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #ddd;
  padding: 20px;
}
</style>

Reusable throttle composable:

// useThrottle.js
import { ref } from 'vue'

export function useThrottleFn(fn, delay = 1000) {
  let lastCallTime = 0
  let timeoutId = null

  const throttledFn = (...args) => {
    const now = Date.now()
    const timeSinceLastCall = now - lastCallTime

    clearTimeout(timeoutId)

    if (timeSinceLastCall >= delay) {
      fn(...args)
      lastCallTime = now
    } else {
      // Schedule execution at the end of the interval
      timeoutId = setTimeout(() => {
        fn(...args)
        lastCallTime = Date.now()
      }, delay - timeSinceLastCall)
    }
  }

  return throttledFn
}

// Usage
<script setup>
import { useThrottleFn } from './useThrottle'

const fetchData = async () => {
  const response = await fetch('/api/data')
  const data = await response.json()
  console.log('Data fetched:', data)
}

const throttledFetch = useThrottleFn(fetchData, 2000)

const handleScroll = () => {
  throttledFetch()
}
</script>

Infinite scroll with throttle:

<template>
  <div>
    <div ref='scrollContainer' @scroll='handleScroll' class='scroll-container'>
      <div v-for='post in posts' :key='post.id' class='post-card'>
        <h3>{{ post.title }}</h3>
        <p>{{ post.content }}</p>
      </div>
      <div v-if='loading' class='loading'>Loading...</div>
      <div v-if='!hasMore' class='end-message'>No more posts</div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const posts = ref([])
const loading = ref(false)
const hasMore = ref(true)
const page = ref(1)
let lastScrollTime = 0
const throttleDelay = 500

const fetchPosts = async () => {
  if (loading.value || !hasMore.value) return

  loading.value = true
  try {
    const response = await fetch(`/api/posts?page=${page.value}&limit=20`)
    const data = await response.json()

    if (data.length === 0) {
      hasMore.value = false
    } else {
      posts.value.push(...data)
      page.value++
    }
  } catch (error) {
    console.error('Error fetching posts:', error)
  } finally {
    loading.value = false
  }
}

const handleScroll = (event) => {
  const now = Date.now()

  if (now - lastScrollTime < throttleDelay) {
    return
  }

  lastScrollTime = now

  const container = event.target
  const scrollPosition = container.scrollTop + container.clientHeight
  const threshold = container.scrollHeight - 200

  if (scrollPosition >= threshold) {
    fetchPosts()
  }
}

onMounted(() => {
  fetchPosts()
})
</script>

<style scoped>
.scroll-container {
  height: 600px;
  overflow-y: auto;
  padding: 20px;
}

.post-card {
  background: white;
  padding: 20px;
  margin-bottom: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.loading,
.end-message {
  text-align: center;
  padding: 20px;
  color: #666;
}
</style>

Throttle window resize:

<template>
  <div>
    <div class='viewport-info'>
      <p>Width: {{ windowWidth }}px</p>
      <p>Height: {{ windowHeight }}px</p>
      <p>Breakpoint: {{ breakpoint }}</p>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const windowWidth = ref(window.innerWidth)
const windowHeight = ref(window.innerHeight)
let resizeTimeout = null
const throttleDelay = 250

const breakpoint = computed(() => {
  if (windowWidth.value < 768) return 'mobile'
  if (windowWidth.value < 1024) return 'tablet'
  return 'desktop'
})

let lastResizeTime = 0

const updateDimensions = () => {
  windowWidth.value = window.innerWidth
  windowHeight.value = window.innerHeight

  // Optional: Make API call on resize
  // fetchResponsiveData()
}

const handleResize = () => {
  const now = Date.now()

  if (now - lastResizeTime < throttleDelay) {
    return
  }

  lastResizeTime = now
  updateDimensions()
}

onMounted(() => {
  window.addEventListener('resize', handleResize)
})

onUnmounted(() => {
  window.removeEventListener('resize', handleResize)
})
</script>

Throttle with VueUse:

npm install @vueuse/core
<script setup>
import { ref } from 'vue'
import { useThrottleFn, useScroll } from '@vueuse/core'

const scrollContainer = ref(null)
const { y: scrollY } = useScroll(scrollContainer)

const fetchData = async () => {
  const response = await fetch('/api/data')
  const data = await response.json()
  console.log('Fetched data')
}

const throttledFetch = useThrottleFn(() => {
  if (scrollY.value > 500) {
    fetchData()
  }
}, 1000)

watch(scrollY, () => {
  throttledFetch()
})
</script>

Throttle mouse movement:

<template>
  <div
    @mousemove='handleMouseMove'
    class='tracking-area'
  >
    <div class='cursor-position'>
      X: {{ mouseX }}, Y: {{ mouseY }}
    </div>
    <div class='api-calls'>API Calls: {{ apiCallCount }}</div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const mouseX = ref(0)
const mouseY = ref(0)
const apiCallCount = ref(0)
let lastCallTime = 0
const throttleDelay = 100 // 100ms = 10 calls per second max

const sendMousePosition = async (x, y) => {
  apiCallCount.value++
  await fetch('/api/track-mouse', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ x, y })
  })
}

const handleMouseMove = (event) => {
  mouseX.value = event.clientX
  mouseY.value = event.clientY

  const now = Date.now()

  if (now - lastCallTime < throttleDelay) {
    return
  }

  lastCallTime = now
  sendMousePosition(mouseX.value, mouseY.value)
}
</script>

<style scoped>
.tracking-area {
  height: 400px;
  background: #f5f5f5;
  padding: 20px;
  cursor: crosshair;
}

.cursor-position,
.api-calls {
  font-family: monospace;
  font-size: 14px;
  margin: 5px 0;
}
</style>

Throttle with lodash:

npm install lodash-es
<script setup>
import { throttle } from 'lodash-es'

const fetchData = async () => {
  const response = await fetch('/api/data')
  const data = await response.json()
}

// Leading: execute immediately, then throttle
const throttledFetch = throttle(fetchData, 1000, {
  leading: true,
  trailing: false
})

// Trailing: wait for interval before executing
const throttledFetch2 = throttle(fetchData, 1000, {
  leading: false,
  trailing: true
})
</script>

Debounce vs Throttle comparison:

<template>
  <div>
    <h3>Debounce (waits until user stops)</h3>
    <input @input='debouncedSearch' placeholder='Debounce example' />
    <p>Debounce calls: {{ debounceCount }}</p>

    <h3>Throttle (executes at regular intervals)</h3>
    <input @input='throttledSearch' placeholder='Throttle example' />
    <p>Throttle calls: {{ throttleCount }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const debounceCount = ref(0)
const throttleCount = ref(0)
let debounceTimer = null
let lastThrottleTime = 0

const debouncedSearch = () => {
  clearTimeout(debounceTimer)
  debounceTimer = setTimeout(() => {
    debounceCount.value++
  }, 500)
}

const throttledSearch = () => {
  const now = Date.now()
  if (now - lastThrottleTime >= 500) {
    throttleCount.value++
    lastThrottleTime = now
  }
}
</script>

Best Practice Note

Use throttling for continuous events (scroll, resize, mouse move) where you need consistent execution intervals. Use debouncing for discrete events (search input, form validation) where you want to wait until activity stops. Throttle delay of 100-250ms works well for scroll events. Use requestAnimationFrame for visual updates instead of throttling. Cancel throttled functions in component cleanup to prevent memory leaks. VueUse provides production-ready throttle implementations. This is how we implement throttling in CoreUI for Vue—optimizing high-frequency event handlers, reducing unnecessary API calls, and maintaining responsive interfaces in data-intensive dashboards.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author