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

Optimistic UI for Adaptive Cards That Trigger Slow Backend Actions

Stop responders double-clicking a slow ChatOps card by showing immediate in-progress state, disabling the action, and threading an idempotency key through the backend.

  • #microsoft-teams
  • #ai
  • #adaptive-cards
  • #ux
  • #chatops

There’s a specific failure I’ve seen in more than one ChatOps setup: someone clicks “Run rollback” on an Adaptive Card, the rollback takes twenty-five seconds, and during those twenty-five seconds the card looks exactly as it did before the click. So they click it again. And maybe a third time. Now there are three rollbacks queued, and the calm incident just got more interesting. The card was technically working the whole time — it just never told anyone. This post is about closing that latency gap with optimistic UI, and doing it in a way that’s actually safe rather than just reassuring.

The latency window is a design problem, not an edge case

Any card that triggers a backend operation slower than a human’s patience — call it more than a second or two — has a window where the user has acted but sees no change. Humans abhor that window. They re-click. The job is to fill the window with honest, immediate feedback: disable the action so a second click is impossible, show that the request was accepted, and later confirm the actual result.

The crucial distinction is between two states people often conflate:

  • Optimistic / accepted — shown instantly: “Rollback requested, in progress.” The backend hasn’t finished; you’re acknowledging the click.
  • Confirmed — shown when the backend actually finishes: “Rollback complete” or “Rollback failed.” This comes later, via a card update.

Collapsing those two into one “Done” that you show optimistically is how you end up reporting success for an operation that later failed. Keep them separate.

Action.Execute gives you a synchronous reply window

If your card uses Action.Execute (the Universal Action Model), Teams sends an invoke and waits synchronously for your InvokeResponse. That response is your chance to instantly hand back a refreshed card — one with the triggering action removed and an in-progress state shown — before the slow backend work even starts.

async function onInvoke(context) {
  const data = context.activity.value.action.data;
  // kick off the slow work async — do NOT await it here
  startRollbackAsync(data.release, data.idempotencyKey);
  // immediately return an in-progress card (action removed)
  return invokeResponseCard(inProgressCard(data.release));
}

Notice the slow work is fired and not awaited inside the invoke handler — you respond fast with the in-progress card, and the actual completion arrives later as a proactive card update. For Action.Submit cards, you don’t get the synchronous reply, so you updateActivity the original message by id immediately after receiving the message, achieving the same effect a beat later.

Disable the action, don’t just discourage it

The in-progress card must make a second click impossible, not merely unappealing. That means rendering the post-click card with the triggering action removed entirely (or replaced by a non-interactive text block), so there’s no button left to click:

{
  "type": "AdaptiveCard",
  "version": "1.4",
  "body": [
    { "type": "TextBlock", "text": "Rolling back api-gateway v2.9.1...", "wrap": true },
    { "type": "TextBlock", "text": "⏳ In progress — started 14:31", "isSubtle": true }
  ]
}

No actions array means no button. A greyed-out-but-present button still invites a frustrated double-click; removing it removes the temptation entirely.

The idempotency key is the real safety net

Here’s the part that separates safe optimistic UI from a comforting lie: disabling the button in the UI does not prevent duplicate backend execution. A redelivered invoke activity, a network retry, or a fast double-click that races the card update can all hit your backend twice. The UI is necessary but not sufficient.

So you thread a client-generated idempotency key through the action data, and your backend dedupes on it:

"actions": [
  {
    "type": "Action.Execute",
    "verb": "rollback",
    "title": "Run rollback",
    "data": { "release": "api-gateway-v2.9.1", "idempotencyKey": "rb-INC0142-7f3a9c" }
  }
]

The backend checks: have I already processed rb-INC0142-7f3a9c? If yes, return the existing result instead of running a second rollback. Now even if the optimistic UI fails to prevent a duplicate request, the operation still happens exactly once. Belt and suspenders: the UI prevents the common case, the idempotency key guarantees the invariant.

Honest timeout copy beats a frozen card

What does the card show if the backend exceeds even its worst-case latency — the P99 hangs, or something genuinely stalls? The wrong answer is a permanent in-progress state that lies by omission. The right answer is an honest fallback that points the user to authoritative status:

{
  "type": "TextBlock",
  "text": "Still running longer than expected. Check live status: [rollback dashboard](https://deploys.internal/INC-0142)",
  "wrap": true
}

Wire a timeout — if the backend hasn’t confirmed within your P99 budget, update the card to this message rather than leaving the spinner spinning forever. A card that says “this is taking a while, here’s where to look” keeps trust; a card frozen in “in progress” for ten minutes destroys it.

Refresh for multi-viewer cards

If several responders see the same card, use the card’s refresh model with userIds so each viewer gets the current state when they open the surface, rather than a snapshot frozen at the moment it was posted. Without it, responder B opens the channel after responder A’s rollback finished and still sees a live “Run rollback” button — exactly the stale-state bug optimistic UI is supposed to kill.

Drafting with AI, verifying the idempotency

I draft these card variants and the handler with an AI assistant, then verify the one thing that matters most — that the idempotency key is genuinely generated client-side and enforced server-side, not just present in the JSON as decoration:

Prompt: “Design an Action.Execute Adaptive Card flow for a slow rollback operation. Return an in-progress card with the action removed in the InvokeResponse, generate a client-side idempotency key in the action data, dedupe on it server-side, and add a timeout path that points to a status dashboard. Include the in-progress, confirmed, and failed card variants.”

Output (excerpt): An invoke handler that fires the work async and returns an action-less in-progress card, an idempotency key in the action data, a server-side dedupe check keyed on it, and three card variants plus a timeout update pointing to the dashboard.

The model produces all three card states and the handler cleanly. What I verify is that the dedupe actually consults the key before executing — because an idempotency key that’s passed but never checked is just a UUID along for the ride.

The takeaway

Disable the action immediately, separate the optimistic “accepted” state from the confirmed result, thread an idempotency key the backend enforces, and give every slow card an honest timeout path to authoritative status. That combination makes a ChatOps card people trust under pressure instead of one they spam-click during an incident. For the related Action.Execute and card-refresh patterns, the Microsoft Teams category has the surrounding guides, and the prompts library includes an optimistic-UI prompt that bakes in the idempotency key so your safety net isn’t only skin-deep.

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.