PeteScript logo PeteScript

AWS Emulator - Years in the Making

— 6 minutes read

PeteScript - AWS Emulator - Years in the Making

In my last blog on Lambda Web Functions, I took a small Express service from my laptop to AWS without adding a Lambda-specific adapter. What excited me most was the developer experience (DX) - local development has been a pain point in the cloud-native space for years.

Yesterday, AWS announced AWS Emulator. Given I’ve been thinking about DX recently, particularly with agentic development, I wanted to give it a try.

The emulator provides a local endpoint for AWS API calls. So let’s build on the previous example and add some persistence without deploying anything to AWS. I’ll refer back to the Express service from that post, so it’s worth reading first if you haven’t already!

Hold on, what is actually running locally?

We’ll use the same Node.js Express service that we previously deployed as a Lambda Web Function. This time, it’s running locally and handling the HTTP API requests.

I’m going to add a few endpoints that call DynamoDB and S3. Those calls will hit the emulator’s local HTTPS endpoint rather than the services in AWS.

curl → local Express app → AWS SDK → AWS Emulator

Let’s save a recipe to a local table and read it back. We’ll also use a conditional write to see what happens when someone submits the same recipe twice.

Give the recipes API a DynamoDB table

Start the emulator using the setup in the announcement - I’m not going to walk through that here as it’s pretty straightforward! You’ll need Docker installed on your machine.

Once you have it up and running, run the AWS CLI and Express from a shell configured with its local endpoint and credentials. Before we can spin up the Node.js service, set the NODE_EXTRA_CA_CERTS to the emulator’s ca.pem so the app trusts its HTTPS certificate.

With the CLI pointing at the emulator, create a recipes table with a string partition key called id:

aws dynamodb create-table --table-name recipes \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

The Express service can then use its normal DynamoDB client with the emulator environment configured. The only part of the write endpoint that I want to highlight is the actual request/condition to DynamoDB:

await db.send(new PutCommand({
  TableName: 'recipes',
  Item: { id, name },
  ConditionExpression: 'attribute_not_exists(id)',
}));

Here, db is the AWS SDK DynamoDB document client, and id and name come from the request body. I’ll use that in the POST /recipes endpoint, returning a 201 on success and a 409 if the conditional check fails.

In terms of the read, a straightforward GET /recipes/:id endpoint that maps to a GetCommand with that ID is enough.

Here’s the table creation in the emulator logs:

{"timestamp":"2026-11-12T21:13:26.104925Z","level":"INFO","fields":{"message":"table created","operation":"CreateTable","akid":"EMULATOREB187D1E9E61","table_name":"recipes","billing_mode":"PAY_PER_REQUEST","global_secondary_indexes":0,"local_secondary_indexes":0,"vector_indexes":0,"outcome":"created"},"target":"service_com_amazonaws_dynamodb_dynamodb_20120810::ops::create_table"}

Invoke it through the Express Service

Let’s go ahead and actually hit the endpoints to save a recipe, attempt to overwrite it with the same ID, before finally reading it back out of the DDB table:

curl -i localhost:3000/recipes -H 'Content-Type: application/json' \
  -d '{"id":"carbonara","name":"Carbonara"}'

curl -i localhost:3000/recipes -H 'Content-Type: application/json' \
  -d '{"id":"carbonara","name":"Carbonara v2"}'

curl -i localhost:3000/recipes/carbonara

The subsequent result that we’re expecting is a 201, 409 and then 200 with the original name Carbonara:

❯ curl -i http://127.0.0.1:3000/recipes -H 'Content-Type: application/json' \
  -d '{"id":"carbonara","name":"Carbonara"}'
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 37
ETag: W/"25-v1pXWnH47c3qhefyrMWowuNbyyo"
Date: Thu, 12 Nov 2026 21:14:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":"carbonara","name":"Carbonara"}%

~
❯ curl -i http://127.0.0.1:3000/recipes -H 'Content-Type: application/json' \
  -d '{"id":"carbonara","name":"Carbonara v2"}'
HTTP/1.1 409 Conflict
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 34
ETag: W/"22-Fj6ZebV4xtQlmF746S1w7W9afL0"
Date: Thu, 12 Nov 2026 21:14:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"Recipe already exists."}%

~
❯ curl -i http://127.0.0.1:3000/recipes/carbonara
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 37
ETag: W/"25-v1pXWnH47c3qhefyrMWowuNbyyo"
Date: Thu, 12 Nov 2026 21:14:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":"carbonara","name":"Carbonara"}%

S3 - Testing another resource

Now, let’s add a POST /recipes/:id/export endpoint. We’ll build this route to read the saved recipe from DynamoDB, and subsequently write it to S3 as JSON. Nothing exciting, but just a proving ground for another service of the emulator.

With the CLI still pointing at the emulator, create a local bucket with:

aws s3 mb s3://recipe-exports

Configure the app to use the S3 client in the same fashion as we did before with the DDB client. Once the route has loaded the recipe, the write looks something like:

await s3.send(new PutObjectCommand({
  Bucket: 'recipe-exports',
  Key: `recipes/${recipe.id}.json`,
  Body: JSON.stringify(recipe),
  ContentType: 'application/json',
}));

And run the following after creating the recipe:

curl -i -X POST localhost:3000/recipes/carbonara/export

aws s3 cp s3://recipe-exports/recipes/carbonara.json -

The route should return success, and the second command should print the original recipe as JSON. Pretty neat.

How much of AWS can I use locally?

This was one of the first questions I had when reading the announcement. Support varies by operation, so seeing a service listed doesn’t necessarily mean every call you need is implemented. An unimplemented operation returns a 501. The AWS Emulator reference covers fidelity and how to check what’s supported in your release.

Before relying on it for a feature, I’d check the specific operations I need - like the conditional write and S3 read-back in this example.

Giving the coding agent a feedback loop

AWS’s announcement showcases its coding agent Kiro using the emulator to create a table, write an item and read it back - pretty much the example that we have just walked through.

We could give a coding agent the same local setup and ask it to work through a bug in the running app.

Imagine we prompt the agent:

Fix the export route so a missing recipe returns a 404, and writes nothing to S3.

I’d give the agent the app’s start command and a fresh bucket in the emulator. It could reproduce the issue with curl, inspect the bucket, fix the route and try again. It could then export a recipe that does exist and read the JSON back to check the successful path still works.

That’s the part I find interesting: the agent can check both the response and the stored data before handing the change back. For calls directed to the local emulator, it can iterate without creating AWS resources or charges. I’d still want to review the change and test against AWS before relying on it in production.

Where I’d use it

For me, this is most useful whilst I’m working through a feature. I can change the application logic, send another request and inspect the data without waiting for a CloudFormation deployment.

It’s another step towards closing the local DX gap I talked about in the Web Functions post. I’ll be interested to see how much of my usual development workflow I can run against it as support grows.