Wiring DynamoDB Into Code: Safe Writes and Atomic Counter
The past four articles built separate pieces: the API has routes but returns fake data, the DynamoDB table has a design but nobody writes to it. This article assembles them. The two handlers from Article 03 will talk for real to the table from Article 04, and along the way we solve two problems that are easy to get wrong: writing so you don't overwrite a duplicate, and counting so you're not wrong when many opens happen at once.
Goal
Wire create-link and resolve-link into DynamoDB via the DocumentClient, use a conditional write so short codes never overwrite each other, and an atomic counter to count clicks safely under concurrent load. We deploy for real, then fire many concurrent requests to test, and read the real result even when it's not what we expected. On-demand table from the previous article, so the cost is negligible.
Client at the module level
By the principle from Article 02, the client is expensive so create it once in static code to reuse across warm invokes. A shared file handles that:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({});
export const ddb = DynamoDBDocumentClient.from(client, {
marshallOptions: { removeUndefinedValues: true },
});
export const TABLE = process.env.TABLE_NAME ?? "url-shortener";
export const linkKey = (code: string) => ({ PK: `LINK#${code}`, SK: "META" });
DynamoDBDocumentClient lets you work with plain JavaScript objects instead of the raw {"S": "..."} form we saw in Article 04. The table name is read from the TABLE_NAME environment variable, passed in by SAM, so the code doesn't hard-code resource names.
Create a link: conditional write
The short code is generated randomly in base62, and although the space is very large, there's still a chance two generations collide. If you just overwrite, a new link could erase an existing link with the same code. A conditional write blocks exactly that case: write only if PK doesn't already exist.
await ddb.send(
new PutCommand({
TableName: TABLE,
Item: {
...linkKey(code),
target, ownerId: OWNER, createdAt, clicks: 0,
GSI1PK: `USER#${OWNER}`, GSI1SK: `LINK#${createdAt}`,
},
ConditionExpression: "attribute_not_exists(PK)",
})
);
ConditionExpression: "attribute_not_exists(PK)" tells DynamoDB to perform the write only when no item with the same key exists. On a collision, the call throws ConditionalCheckFailedException, and the handler catches that to generate a different code and retry in a short loop:
for (let attempt = 0; attempt < 5; attempt++) {
const code = generateShortCode();
try {
await ddb.send(new PutCommand({ /* ...as above... */ }));
return json(201, { code, shortUrl: `${base}/${code}`, target });
} catch (err) {
if ((err as { name?: string }).name === "ConditionalCheckFailedException") continue;
throw err;
}
}
The condition is checked right at the server within the same write operation, so there's no gap between "check existence" and "write" as there is when doing two separate steps. This is how DynamoDB guarantees key uniqueness without pessimistic locking.
ownerId is temporarily hard-coded to user-001; real authentication with Cognito is Article 07.
Open a link: atomic counter
The resolve handler looks up the link, returns 301, and increments the click counter. The counting part is where it's easiest to go wrong. The naive way is to read the current clicks, add one, then write it back. But when two opens happen almost simultaneously, both read the same old value, both add one, both write back, and one count is lost. DynamoDB offers a safe way to count with an UpdateExpression using ADD, adding right at the server:
const res = await ddb.send(new GetCommand({ TableName: TABLE, Key: linkKey(code) }));
if (!res.Item) {
return { statusCode: 404, headers: { "content-type": "text/plain" }, body: "khong tim thay link" };
}
await ddb.send(
new UpdateCommand({
TableName: TABLE,
Key: linkKey(code),
UpdateExpression: "ADD clicks :one",
ExpressionAttributeValues: { ":one": 1 },
})
);
return { statusCode: 301, headers: { location: res.Item.target as string }, body: "" };
ADD clicks :one is an atomic read-add-write that DynamoDB performs in a single step, so two simultaneous opens accumulate correctly instead of overwriting each other.
Naive count (read-modify-write) Atomic counter (ADD)
req A reads clicks=5 ─┐ req A: ADD :1 ─┐
req B reads clicks=5 ─┤ both see 5 req B: ADD :1 ─┤ server adds sequentially
A writes 6 ─┤ ────────────────┤
B writes 6 ─┘ lost 1 count! clicks: 5 -> 6 -> 7 correct
Access to the table is granted to the function via a SAM policy template:
Environment:
Variables:
TABLE_NAME: !Ref Table
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref Table
DynamoDBCrudPolicy grants read/write permission on exactly that table. This isn't the tightest least-privilege yet (resolve only needs read and update, not delete), and Article 16 will tighten it; here we prioritize getting it running first.
Test for real, and a surprise
Create a link then open it:
$ curl -X POST "$API/links" -H 'content-type: application/json' -d '{"url":"https://kubernetes.io/docs/home/"}'
{"code":"CDSP0We","shortUrl":".../CDSP0We","target":"https://kubernetes.io/docs/home/"}
$ curl -i "$API/CDSP0We"
HTTP/2 301
location: https://kubernetes.io/docs/home/
$ curl -s -o /dev/null -w "%{http_code}\n" "$API/zzzNOPE"
404
Create writes for real, open looks up for real, a non-existent code returns 404. Now test the atomic counter under load: create a new link then fire 50 open requests in parallel, log each request's status, then read clicks:
$ for i in $(seq 1 50); do (curl -s -o /dev/null -w "%{http_code}\n" "$API/$CODE" >> codes.txt) & done; wait
$ grep -c 301 codes.txt
10
$ sort codes.txt | uniq -c
10 301
40 503
$ aws dynamodb get-item --table-name url-shortener \
--key '{"PK":{"S":"LINK#JeXP3vg"},"SK":{"S":"META"}}' --query 'Item.clicks.N'
"10"
Two things are worth noting here. First, the atomic counter is absolutely exact: clicks equals exactly 10, exactly the number of requests that got a 301. No count was lost despite running in parallel, because ADD adds at the server. Second, 40 of the 50 requests returned 503. That's not a code bug but a real account limit:
$ aws lambda get-account-settings --query 'AccountLimit.ConcurrentExecutions'
10
This account has a Lambda concurrent executions limit of 10. When 50 requests arrive almost at once, Lambda can only run 10 concurrent environments, the rest are throttled, and API Gateway returns 503 for them. This is a reduced quota commonly seen on new accounts (the default for many accounts is 1000). The 10 successful requests match that limit exactly.
This is worth remembering because it shapes expectations for later. The atomic counter gives us correct data, but "correct" only counts over requests that actually ran. Raising the concurrency quota, measuring behavior under load, and handling throttling are the subject of Article 19 (load test) and Article 16 (throttling). Here we note the limit and move on.
🧹 Cleanup
for c in CDSP0We JeXP3vg; do
aws dynamodb delete-item --table-name url-shortener \
--key "{\"PK\":{\"S\":\"LINK#$c\"},\"SK\":{\"S\":\"META\"}}"
done
Keep the stack and table for later articles.
Wrap-up
The two handlers now talk for real to DynamoDB. The conditional write attribute_not_exists(PK) keeps short codes from ever overwriting a duplicate, checked right in the write operation so there's no gap. The atomic counter ADD counts clicks exactly even when many opens run in parallel, and the parallel test also incidentally exposed the account's Lambda concurrency limit of 10, a number we'll come back to in the load article.
The core of the product now runs end to end, but everyone creates links under the name user-001. The next part adds real users: the next article builds Cognito to log in and issue JWTs, attaches the HTTP API's JWT authorizer so only logged-in users can create links, and lays the groundwork for each person seeing only their own links.