Transform any DOM event into a debounced version. Works with every framework.
import debounced from 'debounced'
debounced.initialize() // One line. Zero dependencies.
// Now any event becomes debounceable:
// input → debounced:input
// scroll → debounced:scroll
// resize → debounced:resize
// ...any of 113+ events
This library gives you:
- ✅ All 113+ DOM events - Not just input. Debounce scroll, resize, mousemove, anything...
- ✅ Dynamic elements - Event delegation means new elements automatically work
- ✅ Leading & trailing - Fire at start, end, or both (most frameworks: trailing only)
- ✅ True DOM events - They bubble, compose, and work with the platform
- ✅ One syntax everywhere - Same pattern in every framework and vanilla JS
- ✅ Per-element timers - Each element maintains independent debounce state
"My framework already has debounce. Why do I need this?"
Because framework debouncing is limited and inconsistent:
Framework | Built-in Debounce | Limitation |
---|---|---|
Alpine.js | @event.debounce |
Trailing only, no event bubbling |
HTMX | hx-trigger delay |
Only for server requests |
LiveView | phx-debounce |
Only for server events |
Livewire | wire:model.debounce |
Only for model binding |
React | None | Write your own wrappers |
Stimulus | stimulus-use addon | useDebounce broken since v0.51.2 |
Vue | None | Write your own wrappers |
Debounced works with vanilla JavaScript and every lib/framework because it uses standard DOM events. If your framework can handle click
events, it can handle debounced:click
events - no special integration required.
HTML-first frameworks: Alpine.js, HTMX, LiveView, Livewire, Stimulus, ... Component frameworks: Angular, React, SolidJS, Svelte, Vue, ... Vanilla JavaScript: Any browser, any environment... Template engines: Blade, Django, ERB, EJS, Handlebars, Jinja2, ...
Every native event automatically creates a corresponding debounced:*
event that bubbles through the DOM:
<!-- ANY click on ANY button creates a debounced:click event -->
<div id="container">
<button>Save</button>
<button>Cancel</button>
<button>Submit</button>
</div>
// Parent containers catch child events via bubbling
document.getElementById('container').addEventListener('debounced:click', e => {
// Handles clicks from ALL child buttons
})
// Listen ANYWHERE in your app
document.addEventListener('debounced:click', e => {
console.log('Debounced click from:', e.target)
})
// Global analytics see everything
window.addEventListener('debounced:input', trackUserActivity)
Most framework debouncing only delays function calls or server requests - they don't create actual DOM events. This means:
- ❌ Other components can't listen for the debounced events
- ❌ Parent elements can't catch child events via bubbling
- ❌ Analytics/logging can't observe debounced interactions
- ❌ You need per-element configuration (no event delegation)
- ✅ Any element clicking creates
debounced:click
that bubbles up - ✅ Any component can listen for debounced events from other components
- ✅ Parent containers automatically handle all child debounced events
- ✅ Third-party code can observe your app's debounced interactions
- Search as you type - Without overwhelming your server
- Auto-save forms - Save drafts without constant writes
- Infinite scroll - Load more content without scroll spam
- Resize handlers - Respond to window resize efficiently
- Double-click prevention - Avoid duplicate submissions
- Analytics tracking - Batch events instead of flooding
- Reactive UIs - Update expensive computations smoothly
- Quick Start
- Installation
- Basic Usage
- Window Events
- Nested Scrollable Elements
- Timing Configuration
- Event Management
- Leading vs Trailing Events
- Custom Events
- Performance Optimization
- API Reference
- Troubleshooting
- Frequently Asked Questions
- Contributing
npm install debounced
import debounced from 'debounced'
debounced.initialize()
Just prefix any event with debounced:
:
// Vanilla JavaScript
element.addEventListener('debounced:input', handler)
document.addEventListener('debounced:scroll', handler)
window.addEventListener('debounced:resize', handler)
// Works in HTML attributes with any framework:
// @debounced:input, hx-trigger="debounced:input", data-action="debounced:input->controller#method"
npm install debounced
<script type="importmap">
{
"imports": {
"debounced": "https://unpkg.com/debounced/dist/debounced.js"
}
}
</script>
<script type="module">
import debounced from 'debounced'
debounced.initialize()
</script>
import debounced from 'debounced'
// Easiest: Initialize all 113+ default events
debounced.initialize()
// Most efficient: Initialize only what you need
debounced.initialize(['input', 'click', 'resize'])
// Custom timing: Adjust wait period for specific events
debounced.initialize(['input'], {wait: 300})
Transform any event by adding the debounced:
prefix:
// Original event → Debounced event
// 'input' → 'debounced:input'
// 'click' → 'debounced:click'
// 'scroll' → 'debounced:scroll'
element.addEventListener('debounced:input', handler)
element.addEventListener('debounced:click', handler)
window.addEventListener('debounced:scroll', handler)
Debounced events contain all the information you need from the original event:
document.addEventListener('debounced:input', event => {
// Use event.target for element properties (most common)
console.log(event.target.value) // Input value
console.log(event.target.checked) // Checkbox state
console.log(event.target.id) // Element ID
// Use event.detail.sourceEvent for original event properties
const original = event.detail.sourceEvent
console.log(original.key) // 'Enter', 'a', etc.
console.log(original.shiftKey) // Was shift pressed?
console.log(original.timeStamp) // When original event fired
// Check when debounce fired
console.log(event.detail.type) // 'leading' or 'trailing'
})
Quick Reference
For... | Use | Example |
---|---|---|
Element properties | event.target.* |
.value , .checked , .id |
Keyboard details | event.detail.sourceEvent.* |
.key , .shiftKey , .ctrlKey |
Mouse details | event.detail.sourceEvent.* |
.clientX , .clientY |
Timing info | event.detail.type |
'leading' or 'trailing' |
Beyond regular DOM events, Debounced supports 21 window-specific events that are essential for app performance and lifecycle management.
window.addEventListener('debounced:resize', updateLayout) // Responsive design
window.addEventListener('debounced:scroll', updateParallax) // Smooth scrolling
window.addEventListener('debounced:orientationchange', reflow) // Mobile rotation
window.addEventListener('debounced:online', syncData) // Connection restored
window.addEventListener('debounced:offline', showOfflineBanner) // Connection lost
window.addEventListener('debounced:storage', syncCrossTabs) // Cross-tab sync
window.addEventListener('debounced:visibilitychange', pause) // Tab switching
window.addEventListener('debounced:devicemotion', updateUI) // Accelerometer
window.addEventListener('debounced:deviceorientation', adjust) // Device tilt
Event Type | Without Debouncing | With Debouncing |
---|---|---|
resize |
Fires 100+ times during drag | Fires once when complete |
storage |
Spams on rapid localStorage changes | Batches updates efficiently |
devicemotion |
Drains battery with constant updates | Optimizes for performance |
online /offline |
Multiple rapid-fire notifications | Single clean state change |
Debounced fully supports scroll events on individually scrollable elements like sidebars, chat windows, and nested containers. Each element maintains its own independent debounce state, making it perfect for:
- Multi-pane layouts - Independent scroll tracking for each pane
- Infinite scroll lists - Debounce scroll events in specific containers
- Chat interfaces - Track scroll position in message containers
- Code editors - Monitor scroll in editor panes separately
Simply add debounced:scroll
listeners directly to any scrollable element - they work independently from each other and from the main page scroll.
The wait time determines how long to pause after the last event before firing the debounced version:
// Default: 200ms (good for most use cases)
debounced.initialize()
// Longer waits for user input
debounced.register(['input'], {wait: 300}) // Search: let users finish typing
// Shorter waits for responsive interactions
debounced.register(['scroll'], {wait: 50}) // Scrolling: stay responsive
debounced.register(['mousemove'], {wait: 16}) // Animation: 60fps smoothness
// Add new events anytime
debounced.register(['focus', 'blur'], {wait: 100})
// Register individual event
debounced.registerEvent('keydown', {wait: 250})
// Mix with existing events - doesn't affect others
debounced.register(['resize'], {wait: 150}) // Other events unchanged
Re-registering an event completely replaces its configuration:
// Initial registration
debounced.register(['input'], {wait: 200, trailing: true})
// Change wait time
debounced.register(['input'], {wait: 500}) // New wait, defaults for other options
// Change to leading mode
debounced.register(['input'], {wait: 300, leading: true, trailing: false})
// Modify multiple events at once
debounced.register(['input', 'scroll'], {wait: 100, leading: true})
Important
Re-registration replaces the entire configuration. Any unspecified options return to defaults.
// Unregister specific events
debounced.unregister(['input', 'scroll'])
// Unregister single event
debounced.unregisterEvent('mousemove')
// Unregister everything
debounced.unregister(debounced.registeredEventNames)
// See what's registered
console.log(debounced.registeredEventNames)
// ['input', 'scroll', 'click']
// Get detailed registration info
console.log(debounced.registeredEvents)
// { input: { wait: 300, leading: false, trailing: true, handler: fn } }
Choose when your debounced events trigger based on user experience needs:
- Leading: Fires ONCE at the start of an event sequence
- Trailing (default): Fires ONCE after a pause in events
- Both: Fires at start AND end of an event sequence (max 2 events per burst)
// NATIVE: Every click fires immediately
button.addEventListener('click', save)
// Click 5 times rapidly = save() called 5 times
// LEADING: First click fires immediately (only once)
debounced.register(['click'], {leading: true, trailing: false, wait: 1000})
// Click 5 times rapidly = save() called ONCE immediately
// TRAILING: Fires once after clicking stops
debounced.register(['click'], {leading: false, trailing: true, wait: 300})
// Click 5 times rapidly = save() called ONCE after 300ms pause
// BOTH: Immediate feedback + final state
debounced.register(['click'], {leading: true, trailing: true, wait: 300})
// Click 5 times rapidly = save() called TWICE (start + end)
// Search input: Wait for user to finish typing
debounced.register(['input'], {
wait: 300,
trailing: true, // Fires once typing pauses
})
// Save button: Immediate response, prevent double-saves
debounced.register(['click'], {
wait: 1000,
leading: true, // Fires on first click
trailing: false, // Ignores subsequent clicks
})
// Scroll tracking: Know when scrolling starts and ends
debounced.register(['scroll'], {
wait: 100,
leading: true, // Fires at scroll start
trailing: true, // Fires at scroll end
})
Mode | Best For | Example Use Cases |
---|---|---|
Trailing only | Wait for completion | Search suggestions, form validation |
Leading only | Immediate response + protection | Button clicks, analytics tracking |
Both modes | Instant feedback + final state | Scroll position, drag operations |
// Register custom event for debouncing
debounced.registerEvent('my-custom-event', {wait: 200})
// Dispatch your custom event (must bubble!)
const customEvent = new CustomEvent('my-custom-event', {
bubbles: true, // Required for event delegation
detail: {someData: 'value'},
})
element.dispatchEvent(customEvent)
// Listen for debounced version
document.addEventListener('debounced:my-custom-event', handler)
Change the prefix for all debounced events:
// Must set before initialization
debounced.prefix = 'throttled'
debounced.initialize()
// Now events use your custom prefix
document.addEventListener('throttled:input', handler)
document.addEventListener('throttled:scroll', handler)
Initialize Only What You Need
// Efficient: Register specific events
debounced.initialize(['input', 'click', 'resize'])
// Wasteful: Register all 113+ events if you only use a few
debounced.initialize() // Only do this if you need most events
Tune Timing for Each Use Case
// Fast response for user interactions
debounced.register(['input'], {wait: 300}) // Typing
debounced.register(['mousemove'], {wait: 50}) // Smooth effects
debounced.register(['resize'], {wait: 200}) // Window sizing
Here's what debouncing achieves in a typical search input scenario:
Without Debouncing (300 keystrokes)
- API calls: 300 requests
- Network usage: 450KB transferred
- Response time: 2.3s average
- CPU usage: High (constant processing)
With Debouncing (300ms wait)
- API calls: 1 request
- Network usage: 1.5KB transferred
- Response time: 0.2s
- CPU usage: Minimal
Built-in Efficiency Features
- Single document listener per event type (memory efficient)
- Automatic timer cleanup (no memory leaks)
- Works with dynamic content (no manual management)
- Performance scales regardless of element count
113+ events supported (including custom events):
- 92 document events (click, input, keydown, mousemove, etc.)
- 21 window-only events (storage, online, offline, devicemotion, etc.)
- All events work consistently across frameworks
Instead of framework-specific debouncing that only delays functions or server requests, Debounced provides:
- ✅ Real DOM events that bubble and can be observed anywhere
- ✅ Leading + trailing modes (not just trailing)
- ✅ Event delegation for dynamic elements
- ✅ 113+ events (not limited to specific interactions)
- ✅ Cross-component communication without props or state
- ✅ Consistent API across all frameworks and vanilla JS
Method | Description |
---|---|
initialize(events?, options?) |
Initialize debounced events (alias for register ) |
register(events, options?) |
Register events for debouncing |
registerEvent(event, options?) |
Register a single event |
unregister(events) |
Remove event registrations |
unregisterEvent(event) |
Remove single event registration |
register(events, options?)
- The core registration method with important behaviors:
- Can be called multiple times to add new events or modify existing ones
- Re-registering an event completely replaces its configuration
- Unspecified options revert to defaults:
{wait: 200, leading: false, trailing: true}
- Does not affect other registered events
Example of re-registration:
// Initial setup
debounced.register(['input'], {wait: 300, trailing: true})
// Later: make it faster with leading
debounced.register(['input'], {wait: 100, leading: true})
// trailing reverts to true (default) since not specified
Property | Type | Description |
---|---|---|
defaultBubblingEventNames |
Array | All 80 naturally bubbling events |
defaultCapturableEventNames |
Array | All 13 capturable non-bubbling events |
defaultDelegatableEventNames |
Array | All 92 delegatable events (bubbling + capturable) |
defaultWindowEventNames |
Array | All 113 window events (including shared events) |
defaultEventNames |
Array | All 113 unique native events across all categories |
defaultOptions |
Object | { wait: 200, leading: false, trailing: true } |
registeredEvents |
Object | Currently registered events with options |
registeredEventNames |
Array | List of registered event names |
prefix |
String | Event name prefix (default: 'debounced') |
version |
String | Library version |
{
wait: 200, // Milliseconds to wait
leading: false, // Fire on leading edge
trailing: true // Fire on trailing edge (default)
}
All debounced events are CustomEvents with this structure:
{
target: Element, // The element that triggered the event
type: 'debounced:input', // The debounced event name
detail: {
sourceEvent: Event, // The original native event
type: 'leading' | 'trailing' // When the debounce fired
},
bubbles: Boolean, // Inherited from source event
cancelable: Boolean, // Inherited from source event
composed: Boolean // Inherited from source event
}
Problem: Events aren't firing
// ✗ Listening to original event instead of debounced
element.addEventListener('input', handler)
// ✓ Listen to the debounced version
element.addEventListener('debounced:input', handler)
// ✓ Make sure you initialized first
debounced.initialize()
Problem: Custom events don't work
// ✗ Custom event doesn't bubble (won't reach document listener)
element.dispatchEvent(new CustomEvent('myEvent'))
// ✓ Custom events must bubble for event delegation
element.dispatchEvent(new CustomEvent('myEvent', {bubbles: true}))
Problem: Events fire too slowly or quickly
// Too slow? Reduce wait time
debounced.register(['input'], {wait: 100})
// Need immediate response? Use leading mode
debounced.register(['click'], {leading: true, trailing: false})
// Want both immediate + final? Use both modes
debounced.register(['scroll'], {leading: true, trailing: true})
All major DOM events work with Debounced:
- Standard events: click, input, keydown, scroll, resize, focus, blur, etc.
- Mouse events: mousemove, mouseenter, mouseleave, drag events
- Touch events: touchstart, touchmove, touchend
- Window events: storage, online, offline, devicemotion
- 113+ total events - Native DOM events plus any custom events - see complete list
Yes! Re-registering an event updates its configuration:
// Start conservative
debounced.register(['input'], {wait: 500})
// Make it more responsive later
debounced.register(['input'], {wait: 100, leading: true})
Note
Unspecified options reset to defaults when re-registering.
Use event.target
for element properties (most common):
event.target.value
- input valuesevent.target.checked
- checkbox stateevent.target.id
- element ID
Use event.detail.sourceEvent
for event-specific data:
event.detail.sourceEvent.key
- keyboard keyevent.detail.sourceEvent.clientX
- mouse position
Traditional debounce utilities require wrapping each handler:
// Traditional approach - inconsistent across codebase
element.addEventListener('input', debounce(handler, 300))
button.addEventListener('click', debounce(clickHandler, 500))
Debounced provides universal consistency:
// Debounced approach - consistent everywhere
element.addEventListener('debounced:input', handler)
button.addEventListener('debounced:click', clickHandler)
See CONTRIBUTING.md for development setup and guidelines.
npm install
npx playwright install
npm test # Run 200+ comprehensive tests
npm run test:visual # Interactive visual test page
The project includes a comprehensive test suite with 200+ tests covering all event types, edge cases, and browser compatibility. The visual test page provides real-time monitoring of event debouncing behavior.
The test suite includes 204 automated tests covering all functionality across Chromium, Firefox, and WebKit, including:
- Event registration modification after initialization
- Re-registration with different options
- Edge cases and error handling
- Visual test page with real-time event monitoring
Run the interactive visual test suite to see debouncing in action:
npm run test:visual # Opens browser with visual test page
The visual test page features:
- Real-time event counters showing native vs debounced events
- Efficiency metrics (% reduction in events)
- Visual grid displaying all 105+ DOM events with color-coded status
- Interactive elements to test different event types
- Automated test runner with progress tracking
- Update version in
package.json
andsrc/version.js
- Run
npm run build
and commit changes - Create annotated tag:
git tag -a vX.X.X -m "Release vX.X.X"
- Push commits and tag:
git push REMOTE_NAME main --follow-tags
- Create GitHub release from the tag
- GitHub Actions automatically publishes to npm (requires NPM_TOKEN secret)
- Or manually publish:
npm publish --access public
- Or manually publish: