From Webhook to WebSocket: Optimizing Slack Event Handling with Bolt
Learn how to reliably process Slack events with Bolt, handle the retry header, and decide between the HTTP Event API and Socket Mode for your bot.
09 Sept 2025, 07:13 UTC

The Problem
When you create a Slack bot that reacts to user messages, channel joins, or file uploads, the bot must receive those events reliably. A common pitfall is missing or mis‑handling the X‑Slack‑Retry‑Num header that Slack sends when an event delivery fails. Without a proper strategy, you can end up processing duplicate events or, worse, dropping them altogether.
Why Bolt Matters
Slack’s Bolt framework abstracts the low‑level HTTP handling into a middleware‑style router. It lets you map event types to handlers, automatically verifies the challenge during subscription, and provides a convenient ack() callback that you can call as soon as you’ve queued the event for processing. This reduces boilerplate and lowers the risk of timing out Slack’s 3‑second window.
Handshake & Retry: The Core of Event Delivery
When you first enable Event Subscriptions in your app, Slack POSTs a JSON payload containing a challenge string to the endpoint you specify. Your server must respond with that exact string in the body and a 200 status. Example:
app.post('/slack/events', (req, res) => {
if (req.body.type === 'url_verification') {
return res.send(req.body.challenge);
}
// normal event handling...
});For every subsequent event, Slack includes two headers:
X-Slack-Retry-Num– an integer starting at 1.X-Slack-Retry-Reason– a short string (e.g.,timeout,unknown).
If your server takes longer than 3 seconds to respond, Slack will retry the same event, incrementing X-slack-retry-num. A naive implementation that processes the event synchronously can easily exceed this window, causing duplicate handling.
Handling Retries with Bolt
Bolt’s ack() function allows you to return a 200 immediately, then offload heavy work to a background queue. Example in Node.js:
app.event('message', async ({ event, context, ack }) => {
// Acknowledge Slack immediately
await ack();
// Offload to a job queue (e.g., Bull, SQS)
await queue.add('processMessage', { event, context });
});When the same event arrives again, your handler can check the X-Slack-Retry-Num header and decide whether to skip processing if you’ve already queued it. Bolt exposes the raw request via context.rawRequest:
app.use(async ({ context }) => {
const retryNum = context.rawRequest.headers['x-slack-retry-num'];
if (retryNum && retryNum > 0) {
// idempotent logic or skip
}
});HTTP Event API vs Socket Mode
The Event API requires a publicly reachable HTTPS endpoint. If your bot runs behind a corporate firewall, you’ll need a reverse proxy or a tunneling service.
Socket Mode establishes a WebSocket connection to Slack, allowing events to flow without exposing an HTTP endpoint. Bolt supports Socket Mode out of the box; you just provide a socketMode: true flag and a SIGNING_SECRET.
Trade‑offs:
- Throughput – The HTTP Event API can handle higher event rates; Socket Mode may throttle under heavy load.
- Latency – Socket Mode can reduce latency because events arrive over an open connection.
- Complexity – Socket Mode requires a WebSocket library and a stable connection; HTTP is simpler if you already host a web server.
Hands‑On Example: A Bolt App that Handles Retries
Below is a minimal Node.js Bolt app that:
- Registers for
app_mentionevents. - Responds to the challenge during subscription.
- Acknowledges events immediately and queues them.
- Skips duplicate processing based on the retry header.
// app.js
const { App } = require('@slack/bolt');
const Queue = require('bull');
const slackApp = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
// Uncomment for Socket Mode:
// socketMode: true,
// appToken: process.env.SLACK_APP_TOKEN,
});
const messageQueue = new Queue('messages');
// Middleware to check retry header
slackApp.use(async ({ context, next }) => {
const retryNum = context.rawRequest.headers['x-slack-retry-num'];
if (retryNum && retryNum > 0) {
// Optionally log or skip
console.warn(`Duplicate event received (retry #${retryNum})`);
}
await next();
});
slackApp.event('app_mention', async ({ event, context, ack }) => {
await ack(); // Slack requires this within 3s
// Add to queue for background processing
await messageQueue.add('processMention', { event, context });
});
slackApp.start(process.env.PORT || 3000).then(() => {
console.log('⚡️ Bolt app is running!');
});Queue worker (worker.js):
const Queue = require('bull');
const { WebClient } = require('@slack/web-api');
const client = new WebClient(process.env.SLACK_BOT_TOKEN);
const messageQueue = new Queue('messages');
messageQueue.process('processMention', async (job) => {
const { event } = job.data;
// Your business logic here
await client.chat.postMessage({
channel: event.channel,
text: `You mentioned me, <@${event.user}>!`,
});
});Trade‑offs & Limitations
• Memory overhead – Parsing large payloads can spike memory usage; stream the request body if possible.
• Throughput – Socket Mode is not recommended for apps expecting >10k events per minute.
• Idempotency – Relying solely on X‑Slack‑Retry‑Num is not foolproof; consider generating a unique event ID from the payload and storing it in a cache.
Actionable Take‑aways
- Enable
Event Subscriptionsand implement the challenge handshake exactly as shown. - Use Bolt’s
ack()to respond within 3 seconds; offload heavy work to a queue. - Inspect
X‑Slack‑Retry‑Numto avoid duplicate processing. - Choose Socket Mode if you cannot expose an HTTPS endpoint, but monitor event rates to stay within Slack’s limits.
- Test retry behavior by simulating network latency (e.g., using
tcor a proxy) and confirm that duplicate events are detected.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.