Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Slack By James Joyner IV · · 10 min read

The Slack Bolt 3-Second Ack Trap: Why Your Handler Fires Twice Under Load

dispatch_failed errors and double-firing handlers almost always trace to one mistake: doing work before ack(). Learn the ack-first pattern for commands, actions, and modals.

  • #slack
  • #ai
  • #bolt
  • #async
  • #reliability

The bug report said the deploy bot was “double-deploying.” Someone clicked Approve once, and the pipeline kicked off twice. It only happened during busy periods, never in testing, and it made no sense — until I looked at the handler and saw it calling our deploy API before calling ack(). Under load, that API call sometimes took more than three seconds, Slack gave up waiting, retried the interaction, and our handler ran the deploy a second time. The fix was moving one line up. Moving ack() to the top of the handler made the double-deploys disappear entirely.

This is a guide to the most common mistake in Slack Bolt apps: doing work before you acknowledge. The symptom shows up as dispatch_failed, operation_timeout, a “this app responded with an error” banner, or — most insidiously — handlers that fire twice. The root cause is almost always the same, and so is the fix. Get this right and a whole category of intermittent, load-dependent weirdness simply stops.

The rule: three seconds, and ack means “received”

Every Slack interaction — slash commands, button clicks, modal submissions, and Socket Mode event envelopes — must be acknowledged within three seconds. Bolt exposes this as ack(). The critical misunderstanding is what ack() means. It does not mean “I finished handling this.” It means “I received your request.” Those are different moments, and the gap between them is where everything goes wrong.

If you do slow work — a database write, an HTTP call to a deploy system, an LLM request — before acking, you’re betting that work finishes in under three seconds every time. It won’t. Under load, behind a slow backend, on a bad network day, it’ll cross the deadline, Slack will assume failure, and depending on the interaction type you’ll get a user-facing error or a retry that re-runs your handler. If you’re building ChatOps tooling, this is the reliability bug that erodes trust fastest, because it strikes exactly when the bot is busiest.

The ack-first pattern

The fix is mechanical: ack immediately, then do the work, then respond out-of-band. Here’s the before and after for the deploy-approval handler that started this story:

// BEFORE — wrong: work happens before ack
app.action('approve_deploy', async ({ ack, body, client }) => {
  await triggerDeploy(body);          // slow; may exceed 3s
  await ack();                        // too late under load → retry → double deploy
});

// AFTER — right: ack first, work after
app.action('approve_deploy', async ({ ack, body, client }) => {
  await ack();                        // immediate, within 3s
  await triggerDeploy(body);          // slow work, now off the clock
  await client.chat.postMessage({
    channel: body.channel.id,
    thread_ts: body.message.ts,
    text: `Deploy approved by <@${body.user.id}> — pipeline started.`,
  });
});

The user gets instant acknowledgment, the slow work runs without a deadline hanging over it, and the result comes back via a follow-up message or a chat.update/respond call. No timeout, no retry, no double-fire.

Pro Tip: For slash commands you can ack with a message in one call — await ack('Working on it...') — then post the real result when it’s ready. The user sees an immediate reply and a follow-up.

Modals are the exception that trips everyone

View submissions are where this gets genuinely tricky, because you get exactly one ack() and it carries the outcome. You cannot ack once to show a loading state and again to deliver the result. People try this constantly and get confused when the second ack does nothing.

The correct pattern when a submission triggers slow work is: ack the submission to close or update the modal immediately, then do the work, then update something out-of-band:

app.view('run_migration', async ({ ack, body, view, client }) => {
  // Validate fast and ack once. To show progress, update the modal to a
  // "running" state via response_action, then do the slow work afterward.
  await ack({
    response_action: 'update',
    view: loadingView('Running migration… this may take a minute.'),
  });

  const result = await runMigration(view.state.values); // slow, post-ack

  // Report out-of-band — DM, channel post, or App Home update
  await client.chat.postMessage({
    channel: body.user.id,
    text: result.ok ? '✅ Migration complete.' : `❌ Migration failed: ${result.error}`,
  });
});

The single ack updates the modal to a loading view; the slow work happens after; the outcome arrives through a separate message. Trying to “ack twice” — once for loading, once for done — simply doesn’t work, and recognizing that is half the battle.

Background work still needs error handling

Moving work after the ack solves the timeout, but it introduces a new responsibility: that work now runs detached from the request. If triggerDeploy throws after you’ve already acked, Bolt’s normal error path doesn’t catch it the same way, and the failure can vanish silently — the user saw “approved,” but nothing happened. Wrap post-ack work and surface failures explicitly:

app.action('approve_deploy', async ({ ack, body, client }) => {
  await ack();
  try {
    await triggerDeploy(body);
    await client.chat.postMessage({ channel: body.channel.id, text: 'Deploy started.' });
  } catch (err) {
    await client.chat.postMessage({
      channel: body.channel.id,
      text: `⚠️ Deploy approval recorded but the pipeline failed to start: ${err.message}`,
    });
    logger.error({ err, action: 'approve_deploy' });
  }
});

A swallowed post-ack error is worse than a timeout, because the user believes the action succeeded. Make every detached failure loud.

Using AI without inheriting its bad habit

I lean on an LLM to write Bolt handlers, but this is one area where the model needs supervision, because a lot of its training data does work before ack. If you prompt naively, you’ll often get the wrong order back. So prompt for the discipline explicitly:

Write a Bolt action handler for approving a deploy. Call ack() first, before any I/O. Then trigger the deploy and post a threaded confirmation. Wrap the post-ack work in try/catch and post a visible failure message on error.

With that framing, the AI reliably produces the ack-first shape. Without it, half the time you get the timeout bug baked in. The AI drafts, the human verifies the ack is the first line — every time. Keep the corrected pattern in a prompt library so you’re not re-teaching it on every handler, and pair it with the related Bolt error-handling prompt for the detached-failure path.

Wrapping Up

Nearly every dispatch_failed, every mysterious timeout banner, and every “why did it fire twice” bug in a Bolt app comes down to one thing: work happening before ack(). The fix is to treat ack() as “received, not finished” — call it first, within three seconds, then do the slow work and report back out-of-band. Modals get one ack that carries the outcome, so use a loading view rather than trying to ack twice. And remember that detached post-ack work needs its own error handling, because a silent failure after a successful-looking ack is the worst outcome of all. Move the ack to the top of every handler, and the load-dependent gremlins go with it.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.