I had a CloudFormation custom resource that looked simple at first. It had to start some external work, wait for that work to finish, and then send the final response back to CloudFormation. The problem was that the work could take much more than 15 minutes. That is where a normal Lambda handler stops being a good fit.
I had seen this problem before, so I knew the usual answers. I could put SNS, SQS, worker Lambdas, polling loops, or Step Functions around it. These tools are all valid, and I have used them before. But for one custom resource, they can make the solution much bigger than the problem.
The limits were clear. Lambda gives me at most 15 minutes for one invocation. CloudFormation custom resources wait for a response for up to 1 hour by default. If I need more than that, I need another way to solve the custom resource, such as WaitCondition.
So the problem was very specific. I did not need to solve every long-running workflow. I only needed to solve the case where the work runs past 15 minutes, but still finishes before CloudFormation times out. That is a narrow case, but it comes up often enough to matter.
At that point, I started to think about using Lambda durable functions for this custom resource. It felt like a good fit very quickly. I still had one Lambda function and one custom resource event, but now I also had a way to pause, keep state, and continue later without building a lot of extra plumbing around the function.
The part that mattered most to me was waitForCondition. Cost was a big part of that. With a standard Lambda function, if I poll for status in a loop, I pay for the whole wait time in Lambda GB-seconds. It does not matter that the function is mostly waiting. I still pay for the full time until the process finishes.
With durable functions, that is different. waitForCondition suspends the execution between checks. The function wakes up, polls for status, saves progress, and goes back to sleep. I only use compute when the function is actually doing work. For this kind of custom resource, that is exactly the behavior I want.
This is the flow I wanted to express:
await processCloudFormationEvent(event);
await waitForConditionUntilJobCompletes(jobId);
await sendCloudFormationResponse(event, "SUCCESS");
That is the real shape of the problem. I process the event, I poll until the work is done, and then I notify CloudFormation. I do not need a queue, a worker, and a second status loop just to describe that.
In the Lambda function, the important part looked like this:
const job = await context.waitForCondition(
"wait-for-job-completion",
async (state) => ({
...state,
status: await checkJobStatus(state.jobId)
}),
{
initialState: { jobId, status: "IN_PROGRESS" },
waitStrategy: (state) =>
state.status === "COMPLETED"
? { shouldContinue: false }
: { shouldContinue: true, delay: { seconds: 30 } }
}
);
await sendCloudFormationResponse({
event,
physicalResourceId,
status: "SUCCESS",
data: { JobId: job.jobId }
});
I also wanted the CloudFormation side to stay small. I did not want a complex template for a simple idea. I wanted a normal custom resource that points to one provider function, and I wanted the important limits to stay visible in the template.
In SAM, the important parts looked like this:
ProviderFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: nodejs22.x
Handler: src/index.handler
Timeout: 900
Policies:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy
DurableConfig:
ExecutionTimeout: 3600
RetentionPeriodInDays: 7
AutoPublishAlias: live
LongRunningResource:
Type: Custom::DurableLongRunningResource
Properties:
ServiceToken: !Ref ProviderFunction.Alias
ServiceTimeout: 3600
Name: example
What I like about this design is that the control flow stays in one place. The custom resource stays easy to read. I do not need extra resources only to handle the wait.
I also found it much easier to understand what the function was doing. With a durable execution, the steps are checkpointed, so I can see the flow much more clearly. That is a lot better than trying to reconstruct the whole story from CloudWatch logs alone.
This does not remove the CloudFormation limits, and I do not want to pretend it does. Lambda durable functions help me get past the 15 minute Lambda limit in a clean way, but they do not remove the custom resource response limit. I still have to design with that 1 hour boundary in mind.
That tradeoff is important. If I already have a larger workflow, Step Functions can still be the right tool. If the work can run for more than 1 hour, I should move to a WaitCondition-based solution for the custom resource. But if I need a long-running custom resource provider and I want the smallest practical design, durable functions feel like a much better fit to me.
I built a public example in us-west-2 to test the idea. I kept it small on purpose. The provider function waits in steps, checks state, and then sends the final response to the CloudFormation pre-signed URL. I wanted to test the control flow, not hide the point inside fake business logic.
I tested the cases that matter for this pattern. I checked that Create returns SUCCESS after a long wait. I checked that Update also works. I kept Delete fast, because I do not want stack cleanup to wait for work that is no longer useful. I also checked that bad input returns FAILED, and that the final response body matches what CloudFormation expects.
My takeaway is simple. I needed a long-running custom resource provider, but I did not want to build a small workflow system around it. Lambda durable functions gave me a much better fit for that exact problem. The real lightbulb moment for me was that waitForCondition matched the control flow I wanted to write, and it let me do it in a way that still felt properly serverless.