Skip to content

Lambda Web Functions as queue consumers

Lambda Web Functions bill for the CPU you actually use, and queue consumers spend most of their time waiting on something else. So is an SQS consumer cheaper as a Web Function than as a regular Lambda function?

This is the last of three posts about Lambda Web Functions, which AWS launched a few hours ago. I got too excited and wrote far more than I normally would, so the first post covers the platform and the second covers running Go on it via execve().

Setup

The launch post says Event Functions are "ideal for event processing, queue consumers, and invocations triggered by other AWS services", and Web Functions are for HTTP. That's a statement about the programming model, not about billing. A queue consumer that spends 480ms of every 500ms waiting on a downstream API is exactly the workload that concurrent requests per environment and active-CPU billing would help.

Nothing stops you from (indirectly) pointing a queue at an HTTP endpoint. EventBridge Pipes will poll SQS, Kinesis, DynamoDB Streams or Kafka for you and deliver each record to an API destination, which is just an HTTPS URL plus a connection that holds credentials. So I tried it.

For these tests, the consumer is just a dummy: it does some CPU work for 15ms and then sleeps for 480ms. The CloudFormation looks something like:

Connection:
  Type: AWS::Events::Connection
  Properties:
    AuthorizationType: API_KEY
    AuthParameters:
      ApiKeyAuthParameters:
        ApiKeyName: x-api-key
        ApiKeyValue: !Ref ApiKey

ApiDestination:
  Type: AWS::Events::ApiDestination
  Properties:
    ConnectionArn: !GetAtt Connection.Arn
    HttpMethod: POST
    InvocationEndpoint: !Sub "https://${ConsumerEndpoint.DomainName}/event"
    InvocationRateLimitPerSecond: 300

Pipe:
  Type: AWS::Pipes::Pipe
  Properties:
    RoleArn: !GetAtt PipeRole.Arn
    Source: !GetAtt Queue.Arn
    SourceParameters:
      SqsQueueParameters:
        BatchSize: 1
    Target: !GetAtt ApiDestination.Arn
    TargetParameters:
      InputTemplate: '{"messageId": <$.messageId>, "body": <$.body>}'
      HttpParameters:
        HeaderParameters:
          x-sqs-message-id: $.messageId

Some notes:

  • Connections only do API key, basic and OAuth, not IAM, so the endpoint has to be ApplicationManaged and your app has to check the shared secret header itself. That's admittedly not great.
  • API destinations aren't a batchable target, so BatchSize must be 1 and every message is its own HTTP request.
  • If you write InputTemplate: <$.body> to pass the message through untouched, you get a mangled body: $.body is a string, and EventBridge substitutes string values with their JSON quoting removed, so {"n":42} arrives as the unparseable {n:42}. Wrapping it in an object avoids that.

Then I put 2,000 messages each on queues being consumed by a Web Function and two Event Functions: one at 128MB and one at 1,769MB (one full vCPU, so the CPU work runs at full speed). All three drained.

The event functions each spun up ~160 environments, hit 164 concurrent executions and kept pace with my send-message-batch loop, finishing in 31 seconds.

The Web Function, with no ScalingConfig at all, kept up: it served all 2000 requests in 35 seconds across ten environments each at 14-15 concurrent requests with no issues. However, when I set maxEnvironments: 4 it returned 92 429 errors and the EventBridge Pipe had to retry those messages. The requests were shed with 429 Platform.EndpointMaxEnvThrottle while the per-environment limit dynamically adjusts its concurrency. I guess the platform decides to err on the side of over-provisioning when there's a sudden burst of traffic only if there's no max environment count set. I think for my purposes I will rarely set a limit.

Costs

The data comes from the platform's own metering: REPORT lines for the event functions, and platform.invokeSummary CPU and memory for the Web Function, which meters about 20ms of CPU per message (my code measures 15ms; the rest is HTTP and logging) and about 200MB per environment.

For the Web Function I've assumed an environment handling 14 concurrent 500ms requests (because this is what the platform settled on during my tests) so 28 messages per second share that 200MB. Prices are us-east-1 arm64. I've ignored free tier and the cost of SQS, because that applies equally to both.

per million messages 128MB event function 1,769MB event function Web Function via Pipes
duration 749ms (CPU-starved) 505ms ~500ms, 20ms of CPU
compute $1.26 $11.81 $0.55 CPU + $0.03 memory
requests $0.20 $0.20 $0.30
Pipes + API destination $0.40 + $0.20
total $1.46 $12.01 $1.48

So against the smallest possible event function it's a dead heat, except that the 128MB function takes 50% longer per message because it's getting 7% of a vCPU. Against one with a full vCPU the Web Function is 8× cheaper. A more realistic 512MB event function lands at about $3.80 per million (estimated). And the gap widens with I/O time: make the downstream call take 2 seconds instead of 480ms and the 128MB event function goes to about $4, 512MB to $14 and one vCPU to $48, while the Web Function goes to about $1.56, because the extra time is just more requests sharing the same 200MB. $0.60 of the Web Function's $1.48 is plumbing; the compute itself is $0.58.

Whether this is a good idea is a different question. You lose the 15-minute timeout (API destinations give up after 5 seconds), you lose batching, you lose partial-batch failure reporting, you're capped at 300 invocations per second per API destination unless you raise the quota, and you have to rely on bearer tokens instead of Sigv4. But for a high-volume consumer doing short, I/O-bound work, it's a lot cheaper. I suspect AWS is never going to launch event-source mappings for Web Functions (because then what would Event Functions be for?), so this will never be a good fit, and that feels like a shame to me.