DEV Community

Cover image for Pause Your Lambda: Building a Slack Approval Workflow with AWS Durable Functions
Alexander Smirnoff
Alexander Smirnoff

Posted on

Pause Your Lambda: Building a Slack Approval Workflow with AWS Durable Functions

1. The "Stateful" problem in serverless

We all love AWS Lambda. It scales to zero, handles massive traffic spikes without breaking a sweat, and best of all, you only pay for the milliseconds your code runs. It is the perfect tool for event-driven tasks: "When this happens, do that."

But there is one scenario where standard Lambda functions struggle: Waiting.

Imagine you are building an approval workflow. A user asks for permission to deploy to production. You send a message to a Slack channel asking an admin to click "Approve" or "Deny."

Now, what does your code do?

In the traditional serverless world, you had two main options:

  1. The "Bad" Way: You keep the Lambda running with a while loop, checking a database every few seconds to see if the admin clicked the button.
    • Result: You are paying for a server to sit idle. If the admin takes an hour (or goes to lunch), your Lambda times out.
  2. The "Hard" Way: You use AWS Step Functions. You create a state machine (in JSON or YAML) that handles the wait and triggers a different Lambda when the task is done.
    • Result: It works well, but your logic is split. Half is in your TypeScript code, and half is in infrastructure configuration. It adds cognitive load and makes testing harder.

Enter AWS Lambda Durable functions.

AWS now allows us to write "Durable" execution logic directly in our code. We can tell a Lambda function to pause execution, save its state, and go to sleep. When an event happens (like a button click) hours or days later, it wakes up exactly where it left off.

No idle server costs. No complex JSON state machines. Just clean, readable TypeScript.

2. The project: A Slack approval bot

To demonstrate this power, I built a simple Email Approval Bot for Slack.

It’s a classic "Human-in-the-Loop" workflow that usually requires complex infrastructure. With Durable Functions, it becomes a single, readable story.

The Flow

Here is exactly what happens when you run the demo:

  1. Initiation: A user types a slash command in Slack: /approve user@example.com.
  2. Validation: A generic Lambda (Slack proxy) verifies the request came from Slack and kicks off our Durable Function.
  3. The Durable logic: Our core function (Approval processor) starts running:
    • It validates the email format.
    • It generates a unique correlation ID.
    • It sends a message to a secure Slack channel using the Slack API, adding "Approve" and "Deny" buttons.
  4. The "Magic" pause: The function hits a line of code that says waitForCallback. It stops running. It costs $0 while waiting.
  5. The interaction: An admin sees the message in Slack and clicks "Approve."
  6. The resume: Slack sends the click event to our API. A tiny helper Lambda receives the click and signals our paused function.
  7. Completion: The Durable Function "wakes up", remembers the original email and correlation ID, handles the approval, and sends a final confirmation message to Slack.

The beauty of this is that steps 3 through 7 are defined in one single file, reading from top to bottom.

Let’s look at how we build it.

3. Crucial concept 1: The "Durable" wrapper

The first thing you'll notice is that this isn't a complex framework with a dozen configuration files. It looks like standard TypeScript.

The only "magic" is a higher-order function that wraps your handler.

src/approval-processor.ts

import { type DurableContext, withDurableExecution } from "@aws/durable-execution-sdk-js";

const run = async (event: LambdaEvent, ctx: DurableContext) => {
    // Your logic goes here
};

// This is the key line!
export const handler = withDurableExecution(run);
Enter fullscreen mode Exit fullscreen mode

By wrapping your run function with withDurableExecution, you are telling the AWS SDK: "Hey, handle the state for me. If I pause, save my spot. if I resume, load my variables."

4. Crucial concept 2: The "Save points" (ctx.step)

In a normal Lambda, if the function stops, your variables die. disappears into the void.

In a Durable function, we use ctx.step. Think of this as a "Save point" in a video game.

// Validation logic wrapped in a step
const validationResult = await ctx.step("validate-email", async () => {
    const email = event.text?.trim() || "";
    // ... validation logic ...
    return { success: true, data: email };
});

const correlationId = await ctx.step("get-correlation-id", async () => `corr-${randomUUID()}`);
Enter fullscreen mode Exit fullscreen mode

If this function runs, pauses for 3 days, and then wakes up:

  1. It skips the code inside the async () => { ... } block because it already has the result saved.
  2. It re-assigns the saved validationResult and correlationId variables instantly.

This guarantees that your random IDs don't change and your heavy computations don't run twice.

5. Crucial concept 3: The magic pause (ctx.waitForCallback)

This is the superpower. We want to send a message to Slack and then stop existing until someone clicks a button.

const slackActionResult = await ctx.waitForCallback(
    "approval",
    async (callbackId, _ctx) => {
        // This code runs JUST BEFORE pausing.
        // We attach the 'callbackId' to the Slack button.
        await slackModule.postMessage(event.response_url, {
            blocks: [
                {
                    type: "button",
                    action_id: "approve_button",
                    value: callbackId, // <--- THE KEY!
                    text: { type: "plain_text", text: "Approve" }
                }
            ]
        });
    },
    { timeout: { minutes: 5 } } // Auto-fail if no one clicks in 5 mins
);
Enter fullscreen mode Exit fullscreen mode

What happens here?

  1. callbackId: The SDK generates a secret, unique ID for this specific pause.
  2. Send: We send that ID to Slack (hidden in the button's value property).
  3. Halt: The function exits. The Lambda stops billing. The state is saved to the backend storage.

6. Crucial concept 4: Waking up (The action handler)

So, the Lambda is asleep. How do we wake it up?

When the admin clicks "Approve" in Slack, Slack calls our API Gateway. We need a tiny, standard Lambda to receive that webhook and forward the signal.

src/approval-action-handler.ts

import { LambdaClient, SendDurableExecutionCallbackSuccessCommand } from "@aws-sdk/client-lambda";

// ... inside the handler receiving the Slack webhook ...

// 1. Extract the callback ID we sent earlier
const payload = JSON.parse(body.payload);
const action = payload.actions[0];
const callbackId = action.value; 

// 2. Tell AWS Lambda: "Resume the function waiting for this ID!"
await lambda.send(
    new SendDurableExecutionCallbackSuccessCommand({
        CallbackId: callbackId,
        Result: JSON.stringify({ action_id: "approve_button" }),
    }),
);
Enter fullscreen mode Exit fullscreen mode

That's it!

  1. The standard Lambda gets the click.
  2. It calls SendDurableExecutionCallbackSuccessCommand.
  3. Our "Durable" Lambda wakes up, the await ctx.waitForCallback line finishes, and it returns the result we passed ().

7. Why this matters to you

If you have ever built this workflow using Step Functions, you know the pain:

  • You need a CDK/SAM definition for the State Machine.
  • You need one Lambda to start the task.
  • You need another Lambda to handle the logic.
  • You need another Lambda to receive the callback.
  • "Passing data" between them involves wrestling with JSON paths.

With Durable functions:

  • It's one file.
  • It's standard TypeScript.
  • It handles state, retries, and waiting natively.

This is a massive leap forward for developer experience in the AWS ecosystem. It brings the power of "Workflow as Code" (popularized by tools like Temporal) directly into the native Lambda environment.


Ready to try it?
Clone the repo and deploy it to your own AWS account to see the magic in action.

GitHub repository

Happy Coding!

Top comments (0)