How to Debounce Webhook Events with Redis and Node.js

by Fahim

Your webhook provider just fired five identical events in three seconds because some user mashed the ‘Save’ button. Now your database is struggling, APIs are rate-limiting you, and your server is crying. We’ve all been there. Let’s fix this by building a distributed webhook debouncer with Node.js and Redis to collapse those noisy bursts into a single, clean execution.

Close-up of a server rack with glowing red accents and connected cables, symbolizing a high-speed Redis system.
Close-up of a server rack with glowing red accents and connected cables, symbolizing a high-speed Redis system.

The Double-Trigger Webhook Problem

When you integrate with Shopify, Stripe, or GoHighLevel, you hope for clean, orderly events. What you actually get is a firehose. If a customer updates their cart three times in five seconds, you get three separate webhooks. If your handler does anything heavy—like rendering a PDF, hitting an external API, or running a database sync—processing all three is a massive waste of resources and cash.

Deduplication just drops duplicates. Debouncing is different: it waits for a quiet window. If Event A hits, we set a 5-second timer. If Event B arrives 2 seconds later, we reset that timer. We only execute when we get a full 5 seconds of silence. This is exactly what you want when you only care about the final state of a resource after a flurry of rapid edits.

I previously wrote about how to prevent duplicate webhook processing with Redis and Express. That’s great for immediate execution locks. Debouncing is a different beast: we are actively delaying execution to see if more updates are on the way.

Why In-Memory Debouncing Fails in Production

If you’ve used Lodash’s _.debounce(), you might think you can just wrap your Express route handler and call it a day. That works beautifully on localhost, but it breaks the second you deploy to production.

Production apps run on multiple instances behind a load balancer. If Webhook 1 hits Instance A, and Webhook 2 hits Instance B, they can’t share memory. Both instances will execute their events, completely bypassing your debounce logic. You can read more about managing distributed state on the official Redis website.

To make this work, we need a fast, shared, atomic store to coordinate state across all instances. Redis is the perfect tool for the job.

The Redis Debounce Architecture

We need to track when an event arrived, hold onto its latest payload, and schedule the execution. We’ll use two Redis structures to handle this:

  • A Redis Hash: Stores the actual payload. The key looks like webhook:payload:{resourceId}. Even if we get 10 rapid updates, we only keep the absolute latest data.
  • A Redis Sorted Set (ZSET): Acts as our scheduler queue. The key is webhook:queue. The member is the resourceId, and the score is the Unix timestamp (in milliseconds) of when the event should execute.

When a webhook hits, we do two quick things in Redis. We update the payload hash, and we add or update the resource ID in the Sorted Set with a score of Date.now() + DEBOUNCE_DELAY_MS. If the ID is already in the set, Redis just updates its score, pushing the execution time further out. That’s classic debouncing, but distributed.

Setting Up the Project

Let’s spin up a clean Node.js project. We’ll need Express to handle the incoming webhooks and ioredis to talk to Redis.

Run this in your terminal to get started:

mkdir redis-webhook-debounce
cd redis-webhook-debounce
npm init -y
npm install express ioredis dotenv

Next, create a .env file in your root folder for the Redis connection details. If you’re running Redis locally on the default port, it’ll look like this:

PORT=3000
REDIS_URL=redis://127.0.0.1:6379
DEBOUNCE_DELAY_MS=5000

I’m using a 5-second delay here for easy testing. In production, you’ll probably want 10 to 30 seconds depending on how chatty your webhook source is. Check out the ioredis documentation if you need to configure complex connection strings.

Implementing the Webhook Debouncer

Create a file named debouncer.js. This class handles queuing new webhooks and scheduling their execution. We’ll use a pipeline in ioredis to bundle the hash update and the sorted set update into a single round-trip to Redis. This keeps things incredibly fast.

Here’s the code:

const Redis = require('ioredis');
require('dotenv').config(); const redis = new Redis(process.env.REDIS_URL);
const QUEUE_KEY = 'webhook:queue';
const PAYLOAD_PREFIX = 'webhook:payload:'; class WebhookDebouncer { async queueEvent(resourceId, payload) { const delay = parseInt(process.env.DEBOUNCE_DELAY_MS, 10) || 5000; const executeAt = Date.now() + delay; const payloadKey = `${PAYLOAD_PREFIX}${resourceId}`; const pipeline = redis.pipeline(); // Store the latest payload pipeline.set(payloadKey, JSON.stringify(payload)); // Add or update the execution time in the sorted set pipeline.zadd(QUEUE_KEY, executeAt, resourceId); await pipeline.exec(); console.log(`[Queued] Resource ${resourceId} scheduled to run in ${delay}ms`); }
} module.exports = new WebhookDebouncer();

Every time queueEvent is called with the same resourceId, the payload gets overwritten with the newest data, and the score in our sorted set gets pushed further out. The old timestamp is forgotten, and we only keep the latest state.

Building the Worker Loop

Now we need a background worker to poll our Sorted Set for events that are ready to go. If an event’s score (the execution timestamp) is less than or equal to the current time, the debounce window has closed without new updates. It’s time to process it.

To prevent race conditions where multiple worker instances try to process the same matured event, we need to fetch and remove the item atomically. We’ll use a transaction block (multi/exec) to safely claim the job. If you need absolute lock guarantees under extreme concurrency, you might want to look into a Redis distributed lock setup.

Create worker.js and add this code:

const Redis = require('ioredis');
require('dotenv').config(); const redis = new Redis(process.env.REDIS_URL);
const QUEUE_KEY = 'webhook:queue';
const PAYLOAD_PREFIX = 'webhook:payload:'; async function processWebhook(resourceId, payload) { // This is where your actual business logic goes console.log(`[Executing] Processing final state for ${resourceId}:`, payload); // Simulate work await new Promise(resolve => setTimeout(resolve, 500));
} async function pollQueue() { try { const now = Date.now(); // Find all items that have matured (score <= current time) const jobs = await redis.zrangebyscore(QUEUE_KEY, 0, now, 'LIMIT', 0, 10); for (const resourceId of jobs) { // Atomically remove the item from the queue to ensure only this worker runs it const removed = await redis.zrem(QUEUE_KEY, resourceId); if (removed === 1) { const payloadKey = `${PAYLOAD_PREFIX}${resourceId}`; const payloadRaw = await redis.get(payloadKey); if (payloadRaw) { const payload = JSON.parse(payloadRaw); await processWebhook(resourceId, payload); // Clean up payload hash after successful execution await redis.del(payloadKey); } } } } catch (error) { console.error('Error in polling worker:', error); } // Poll again after a short delay setTimeout(pollQueue, 1000);
} console.log('Webhook worker loop started...');
pollQueue();

Using ZREM as an atomic lock is a fantastic pattern. Since ZREM returns the number of elements actually removed, only the worker instance that successfully deletes the element gets to process it. If two workers query the queue at the exact same millisecond, only one gets a return value of 1. The other gets 0 and gracefully skips it.

Creating the Express Receiver

Let’s tie it all together with an Express server to receive incoming webhooks and hand them off to our debouncer. We’ll set up a mock route that expects a JSON payload with an id and some data.

Create server.js with this code:

const express = require('express');
const debouncer = require('./debouncer');
require('dotenv').config(); const app = express();
app.use(express.json()); app.post('/webhook', async (req, res) => { const { id, status, updatedBy } = req.body; if (!id) { return res.status(400).json({ error: 'Missing resource id' }); } try { await debouncer.queueEvent(id, { id, status, updatedBy, timestamp: Date.now() }); return res.status(202).json({ message: 'Event accepted and queued' }); } catch (error) { console.error('Failed to queue webhook:', error); return res.status(500).json({ error: 'Internal server error' }); }
}); const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { console.log(`Webhook receiver listening on port ${PORT}`);
});

Notice we return a 202 Accepted status code immediately. Never make the sender wait while you run heavy business logic. Accept the payload, write it to Redis, and close the connection. If you’re dealing with massive traffic, you might also want to look into a Redis sliding window rate limiter to protect your endpoint from getting flooded.

Testing the Setup

Let’s test this to make sure it actually debounces incoming requests. Open two terminal windows.

In the first terminal, spin up the worker:

node worker.js

In the second terminal, start the Express server:

node server.js

Now, let’s simulate a user mashing the save button. Run this curl command to fire three POST requests to your local server in less than a second:

curl -X POST http://localhost:3000/webhook -H "Content-Type: application/json" -d '{"id": "user-123", "status": "draft", "updatedBy": "Fahim"}' && 
curl -X POST http://localhost:3000/webhook -H "Content-Type: application/json" -d '{"id": "user-123", "status": "pending", "updatedBy": "Fahim"}' && 
curl -X POST http://localhost:3000/webhook -H "Content-Type: application/json" -d '{"id": "user-123", "status": "published", "updatedBy": "Fahim"}'

Check your logs. You’ll see three queued messages in your server console. But in your worker console, you’ll see only one execution block showing the final "published" state. The first two states were overwritten in Redis and never triggered your expensive background logic. It works!

Handling Edge Cases and Node Crashes

What happens if your Node server crashes with items still in the queue? Since the state lives in Redis, you don’t lose a thing. When your worker restarts, it’ll query the Sorted Set, find any matured events that missed their window, and process them immediately.

Watch out for payload sizes. If your webhooks carry massive JSON payloads (hundreds of kilobytes), storing them directly in Redis will eat up your RAM fast. Instead, just store the resource ID. When the worker fires, have it fetch the fresh data from your database or via an API call to the source platform. This keeps your Redis footprint tiny and guarantees your worker operates on the absolute latest source of truth.

If you need to handle long-term retries when downstream services go down, you should pair this setup with a robust webhook retry system with exponential backoff to handle network failures gracefully.

Frequently Asked Questions

Can I use Redis Pub/Sub for this instead of a Sorted Set?

No. Redis Pub/Sub is fire-and-forget. It doesn’t have scheduling or persistence. If your worker is offline or busy when a message is published, that message is gone forever. A Sorted Set acts as a reliable, persistent queue.

How does this compare to BullMQ?

BullMQ is an amazing, production-grade library built on Redis that handles delayed jobs out of the box. If you already use BullMQ in your stack, use its job ID deduplication or delay features. But if you want a lightweight solution without the overhead of a massive library, building your own Sorted Set scheduler gives you full control.

What happens if the worker fails mid-execution?

In our basic worker implementation, the job is removed from the queue using ZREM before processing. If the worker crashes mid-execution, that job is lost. To make this bulletproof for production, you should look into a two-step process (like a “processing” set) or implement an acknowledgment pattern where jobs are only removed after successful execution.

Next Steps

Now that your distributed webhook debouncer is up and running, check out our guide on how to prevent race conditions using Redis distributed locks to make your multi-server setup completely bulletproof.

Official resources

all_in_one_marketing_tool