Skip to content

Slack adapter: unfurl wait blocks webhook handling up to 2s per link (4s for edits) #1132

Open

Description

@crowd-cow-wrangler

Summary

@chat-adapter/slack 4.39.0 waits up to 2 seconds for link-preview (unfurl) data on every incoming message that contains a link with no cached preview. The wait runs inside webhook handling, before the event reaches the app. Links Slack never unfurls (internal admin URLs, bot posts sent with unfurl_links: false) always wait the full 2 seconds. A message_changed event parses both the old and the new text, so an edit waits twice.

Impact

Hosts that must answer Slack's webhook within a fixed window can't accept these events. We run Paperclip, which gives the SDK 2.5 seconds, so every message with an unpreviewed link and every edit gets a 503 on each retry and is eventually dropped.

Measurements

Live adapter, real bot token, one bot post with a link:

  • as sent: 2,137 ms in the adapter (14 state reads of slack:unfurls:<ts>, 150 ms apart)
  • same post with the link removed: 33 ms
  • an edit of that post: 4,245 ms

Where

enrichLinks with UNFURL_WAIT_MS = 2e3, polling chat state every 150 ms. It looks like this arrived with the link-preview work in #395.

Suggested fix

Don't block webhook handling on unfurls: read the cached unfurl once and move on, or make the wait configurable (0 to disable). Setting UNFURL_WAIT_MS to 0 fixed it for us; one read takes about 0 ms.

Activity

  1. shpetimhaxhiu commented on Nov 11, 2026

    @shpetimhaxhiu

    I reproduced the delay on published @chat-adapter/slack, @chat-adapter/state-memory, and chat 4.44.0 with Node 24.18.0. The adapter returned HTTP 200 before the queued processing finished. A host that waits for the waitUntil tasks before replying would still include the polling delay in its response time.

    Synthetic event Time to HTTP 200 Time through completion of all queued tasks
    Message without a link 6 ms 13 ms
    Message with an uncached link 2 ms 2,071 ms
    Edit with an uncached link in both snapshots 3 ms 4,117 ms
    Message with a cached link 3 ms 6 ms

    Each case invoked its handler once. The cache-hit control also confirmed that the handler received the cached title. These are local measurements with in-memory state and synthetic signed requests. The probe measures the adapter boundary and leaves Paperclip's integration untested.

    The edit result is consistent with processMessageUpdated resolving the new and previous snapshots sequentially. Both use the same Slack parser and unfurl polling.

    Offline reproduction

    In an empty directory with Node 24, install the three pinned packages, save the script as repro.mjs, and run node repro.mjs.

    npm install --ignore-scripts --no-audit --no-fund @chat-adapter/slack@4.44.0 @chat-adapter/state-memory@4.44.0 chat@4.44.0
    import assert from "node:assert/strict";
    import { createHmac } from "node:crypto";
    import { performance } from "node:perf_hooks";
    import { createSlackAdapter } from "@chat-adapter/slack";
    import { createMemoryState } from "@chat-adapter/state-memory";
    import { Chat } from "chat";
    
    const secret = "synthetic-local-signing-secret";
    const adapter = createSlackAdapter({
      botToken: "synthetic-local-token",
      signingSecret: secret,
      botUserId: "UBOT",
      userName: "testbot",
      apiUrl: "http://127.0.0.1:1/",
      webClientOptions: { retryConfig: { retries: 0 } },
    });
    const state = createMemoryState();
    const chat = new Chat({ userName: "testbot", adapters: { slack: adapter }, state });
    await chat.initialize();
    
    const observed = [];
    chat.onNewMessage(async (_thread, message) => observed.push(message));
    chat.onMessageUpdated(async () => observed.push("edit"));
    
    const base = {
      type: "message", user: "UTEST", username: "test-user",
      channel: "CTEST", ts: "1700000000.000001", channel_type: "channel",
    };
    const url = "https://example.com/article";
    const withLink = { ...base, text: "Example <" + url + ">" };
    
    let seq = 0;
    async function run(name, event, setup = async () => {}) {
      const ts = "1700000000." + String(++seq).padStart(6, "0");
      event = { ...event, ts };
      if (event.message) event.message = { ...event.message, ts };
      if (event.previous_message) event.previous_message = { ...event.previous_message, ts };
      const tasks = [];
      const startCount = observed.length;
      const body = JSON.stringify({ type: "event_callback", team_id: "TTEST", event });
      const timestamp = String(Math.floor(Date.now() / 1000));
      const signature = createHmac("sha256", secret)
        .update("v0:" + timestamp + ":" + body).digest("hex");
      await setup(ts);
      const started = performance.now();
      const response = await adapter.handleWebhook(new Request("https://example.invalid/webhook", {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "x-slack-request-timestamp": timestamp,
          "x-slack-signature": "v0=" + signature,
        },
        body,
      }), { waitUntil: (task) => tasks.push(task) });
      const ackMs = Math.round(performance.now() - started);
      const eventsAtAck = observed.length - startCount;
      const settled = await Promise.allSettled(tasks);
      const totalMs = Math.round(performance.now() - started);
      assert.equal(response.status, 200);
      assert.ok(tasks.length > 0, name + " queued no processing");
      assert.ok(settled.every((task) => task.status === "fulfilled"));
      const events = observed.slice(startCount);
      assert.equal(events.length, 1, name + " dispatched event count");
      const result = { name, status: response.status, ackMs, totalMs, eventsAtAck,
        eventsAfterDrain: events.length, linkTitle: events[0]?.links?.[0]?.title ?? null };
      console.log(JSON.stringify(result));
      return result;
    }
    
    try {
      const noLink = await run("message without link", { ...base, text: "hello" });
      const uncached = await run("message with uncached link", withLink);
      const edit = await run("edit with uncached link in both snapshots", {
        ...base, subtype: "message_changed", previous_message: withLink,
        message: { ...withLink, text: withLink.text + " revised", edited: { user: "UTEST", ts: "1700000001.000001" } },
      });
      const cached = await run("message with cached link", withLink, async (ts) => {
        await state.set("slack:unfurls:CTEST:" + ts, { [url]: { title: "Cached title" } }, 10_000);
      });
      assert.ok(uncached.totalMs >= 1950, "uncached message should exhibit the reported poll delay");
      assert.ok(edit.totalMs >= 1950, "uncached edit should exhibit a poll delay");
      assert.equal(cached.linkTitle, "Cached title");
      assert.equal(uncached.eventsAtAck, 0);
      assert.equal(edit.eventsAtAck, 0);
      assert.ok(uncached.ackMs < uncached.totalMs);
      assert.ok(edit.ackMs < edit.totalMs);
      assert.ok(noLink.totalMs < uncached.totalMs);
    } finally {
      await chat.shutdown();
    }

    I used Codex to prepare and run this reproduction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions