Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 2 - DynamoDB URL Shortener Data Layer)
If you've already worked through Part 1 - S3 Photo Uploader with Presigned URLs, this is where the app gets a data layer. By the end of this article you'll have a DynamoDB table on LocalStack, a small Node CLI that handles shortening, resolving, and atomic click counting, plus a fixture loader you can reuse later. Part 3 detours into Lambda + S3 events, and Part 4 will wrap this shortener in an HTTP API.
Why DynamoDB fits this job
A URL shortener is one of those workloads DynamoDB fits neatly:
- The hot path is simple: look up
code → long_url, then bump a click counter. That's still a tiny, key-based access pattern - exactly the sort of thing DynamoDB is good at. - Click counting wants atomic increments. DynamoDB supports
SET attr = attr + :nnatively - no read-modify-write race conditions. - Writes are point inserts with a uniqueness check. DynamoDB's
ConditionExpressionmakes "fail if this code already exists" a one-line addition.
In practice, Bitly, TinyURL, and plenty of internal "go links" tools use this same shape: short code in, long URL out, plus a counter on the side.
Step 1: Create the shortlinks table
cd ~/projects/localstack-series
mkdir part2-dynamodb
cd part2-dynamodb
Create the table:
awslocal dynamodb create-table \
--table-name shortlinks \
--attribute-definitions AttributeName=code,AttributeType=S \
--key-schema AttributeName=code,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
A few things worth understanding:
AttributeName=code,KeyType=HASH-codeis the partition key. The CLI still uses the legacyHASH/RANGEterminology. For a URL shortener it's a 6-character random string likeabc123.PAY_PER_REQUEST- billing mode. The other option isPROVISIONEDwhere you pre-allocate read/write capacity.PAY_PER_REQUEST(also called "on-demand") is what you want for unpredictable workloads, and what most new tables use.- Why is
long_urlnot in--attribute-definitions? DynamoDB only requires you to declare attributes used in keys (primary or secondary indexes). Everything else is freeform.
Confirm it's there:
awslocal dynamodb describe-table \
--table-name shortlinks \
--query 'Table.{Name:TableName,Status:TableStatus,Keys:KeySchema}'
{
"Name": "shortlinks",
"Status": "ACTIVE",
"Keys": [
{ "AttributeName": "code", "KeyType": "HASH" }
]
}
Step 2: Set up the Node project
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
Set "type": "module" in package.json:
{
"name": "part2-dynamodb",
"type": "module",
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.600.0",
"@aws-sdk/lib-dynamodb": "^3.600.0"
}
}
A note on the two packages: @aws-sdk/client-dynamodb is the low-level client that speaks the raw DynamoDB JSON wire format (where strings are wrapped as { S: "..." } and numbers as { N: "..." }). @aws-sdk/lib-dynamodb is a small wrapper that lets you work with plain JavaScript objects. Use the wrapper for application code; you almost never want the raw form.
The exact minor version will move over time. Any current AWS SDK v3 release is fine here, the pattern is the thing that matters.
Step 3: Write the shortlinks module
Create shortlinks.js:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
PutCommand,
ScanCommand,
UpdateCommand,
DeleteCommand,
BatchWriteCommand,
} from '@aws-sdk/lib-dynamodb';
import { setTimeout as sleep } from 'node:timers/promises';
const client = new DynamoDBClient({
endpoint: 'http://localhost:4566',
region: 'us-east-1',
credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
});
const ddb = DynamoDBDocumentClient.from(client);
const TABLE = 'shortlinks';
function randomCode(len = 6) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let out = '';
for (let i = 0; i < len; i++) out += chars[Math.floor(Math.random() * chars.length)];
return out;
}
Now the operations, one at a time.
Shorten - write a new entry, fail if the code is taken
export async function shorten(longUrl, code = randomCode()) {
await ddb.send(new PutCommand({
TableName: TABLE,
Item: {
code,
long_url: longUrl,
created_at: new Date().toISOString(),
click_count: 0,
},
ConditionExpression: 'attribute_not_exists(code)',
}));
return code;
}
The line that's worth understanding: ConditionExpression: 'attribute_not_exists(code)' makes the write conditional on the code not already being in the table. Two requests racing to claim the same code - only one wins. The other gets ConditionalCheckFailedException, which the caller can catch and retry with a new code.
This is genuinely how production URL shorteners avoid double-claiming. In a real app, that failure path usually triggers "generate a new code and try again" rather than surfacing the exception directly.
Resolve - increment the counter and return the updated item
export async function resolve(code) {
try {
const updated = await ddb.send(new UpdateCommand({
TableName: TABLE,
Key: { code },
ConditionExpression: 'attribute_exists(code)',
UpdateExpression: 'SET click_count = if_not_exists(click_count, :zero) + :one',
ExpressionAttributeValues: {
':zero': 0,
':one': 1,
},
ReturnValues: 'ALL_NEW',
}));
return updated.Attributes;
} catch (err) {
if (err.name === 'ConditionalCheckFailedException') return null;
throw err;
}
}
This version is closer to what you'd actually ship:
attribute_exists(code)stops DynamoDB from creating a brand-new row when the short code doesn't exist.if_not_exists(click_count, :zero) + :onekeeps the counter safe even if an older row is missingclick_count.ReturnValues: 'ALL_NEW'gives you the updated item back in the same call, so the read path stays compact.
The key point is the atomic increment. Even with lots of concurrent resolves, every click is counted exactly once. No read-modify-write loop, no transaction needed. The attribute_exists(code) guard matters because UpdateItem can create an item if you let it.
List, remove
export async function list(limit = 25) {
const out = await ddb.send(new ScanCommand({
TableName: TABLE,
Limit: limit,
}));
return (out.Items || []).sort((a, b) => a.code.localeCompare(b.code));
}
export async function remove(code) {
await ddb.send(new DeleteCommand({
TableName: TABLE,
Key: { code },
}));
}
ScanCommand is fine for tens or hundreds of items but reads every item in the table. It also does not return a meaningful order, so the small in-memory sort is the only reason the CLI output looks stable here. We'll talk about the bigger tradeoffs in a moment.
Fixture loading with BatchWrite
export async function loadFixtures(items) {
// BatchWrite is capped at 25 items per request.
for (let i = 0; i < items.length; i += 25) {
let pending = items.slice(i, i + 25).map(item => ({
PutRequest: {
Item: {
code: item.code,
long_url: item.long_url,
created_at: item.created_at || new Date().toISOString(),
click_count: item.click_count || 0,
},
},
}));
while (pending.length > 0) {
const out = await ddb.send(new BatchWriteCommand({
RequestItems: {
[TABLE]: pending,
},
}));
pending = out.UnprocessedItems?.[TABLE] || [];
if (pending.length > 0) await sleep(200);
}
}
}
The 25-per-request cap is a real DynamoDB limit, not a LocalStack one. The other detail that matters is UnprocessedItems: DynamoDB can accept part of a batch and ask you to retry the rest. That's why the loop keeps retrying until the chunk drains. The fixed 200ms pause is perfectly fine for a local fixture script like this; for production retry loops, exponential backoff is the usual guidance. One more wrinkle: BatchWrite only supports puts and deletes, not conditional writes or partial updates, which is why fixtures use it but the live shorten/resolve path doesn't.
CLI wrapper at the bottom
if (import.meta.url === `file://${process.argv[1]}`) {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case 'shorten':
console.log(`Shortened to: ${await shorten(args[0], args[1])}`);
break;
case 'resolve': {
const item = await resolve(args[0]);
console.log(item ? JSON.stringify(item, null, 2) : 'Not found');
break;
}
case 'list': {
const items = await list();
console.table(items.map(({ code, long_url, click_count }) =>
({ code, long_url, click_count })));
break;
}
case 'remove':
await remove(args[0]);
console.log(`Removed: ${args[0]}`);
break;
case 'fixtures': {
await loadFixtures([
{ code: 'gh', long_url: 'https://github.com' },
{ code: 'np', long_url: 'https://npmjs.com' },
{ code: 'mdn', long_url: 'https://developer.mozilla.org' },
{ code: 'aws', long_url: 'https://docs.aws.amazon.com' },
]);
console.log('Loaded 4 fixtures');
break;
}
default:
console.error('Usage: node shortlinks.js <shorten|resolve|list|remove|fixtures> [args...]');
process.exit(1);
}
}
Step 4: Try it out
$ node shortlinks.js fixtures
Loaded 4 fixtures
$ node shortlinks.js list
┌─────────┬───────┬─────────────────────────────────┬─────────────┐
│ (index) │ code │ long_url │ click_count │
├─────────┼───────┼─────────────────────────────────┼─────────────┤
│ 0 │ 'aws' │ 'https://docs.aws.amazon.com' │ 0 │
│ 1 │ 'gh' │ 'https://github.com' │ 0 │
│ 2 │ 'mdn' │ 'https://developer.mozilla.org' │ 0 │
│ 3 │ 'np' │ 'https://npmjs.com' │ 0 │
└─────────┴───────┴─────────────────────────────────┴─────────────┘
$ node shortlinks.js resolve gh
{
"click_count": 1,
"created_at": "2026-05-11T17:15:33.184Z",
"code": "gh",
"long_url": "https://github.com"
}
$ node shortlinks.js resolve gh
{
"click_count": 2,
"created_at": "2026-05-11T17:15:33.184Z",
"code": "gh",
"long_url": "https://github.com"
}
$ node shortlinks.js shorten "https://anthropic.com" claude
Shortened to: claude
$ node shortlinks.js shorten "https://example.com" claude
... ConditionalCheckFailedException
The atomic counter ticked from 1 to 2 across two separate processes. The second shorten for claude failed cleanly because the code was already taken. In a production shortener, that's where you'd generate another code and retry.
Scan vs Query, before this grows up
Scan walks the table rather than doing a key-targeted lookup - fine for tens of items, painful at thousands, ruinous at millions. The two patterns you'll actually want in production:
- Query - point reads by partition key, plus sorted reads when you have a sort key. The thing DynamoDB is fastest at.
- Global Secondary Index (GSI) - a second key on a different attribute. For instance, if you wanted to look up shortlinks by
long_url("has anyone shortened this before?"), you'd add a GSI withlong_urlas its partition key.
For Part 4 of this series we won't need a GSI - the API only ever looks up by code. If you later want a "my shortlinks" listing per user, add a GSI on owner_id and Query it. We'll wire that up properly when we hit the auth article.
Single-table design - when you'd actually need it
You'll see "single-table design" mentioned in DynamoDB tutorials a lot. The idea is simple enough: instead of one table per entity (users, posts, comments), you put everything in one table with composed keys (USER#123, POST#456, COMMENT#789#REPLY#1). It's powerful and saves money at scale.
For a URL shortener with one entity, it's overkill. The moment you start adding owners, tags, click events, or shared lists, the single-table approach starts paying off. We're keeping it simple here. If this app grows up in your own homelab, that's the point where you'd revisit the table design. Alex DeBrie's DynamoDB Book is the canonical reference.
Common pitfalls
ResourceNotFoundExceptionwhen running the script. Forgot to create the table, or you typed the table name wrong.ConditionalCheckFailedExceptiononresolvefor a code you expected to exist. The item is missing, or you wrote it into a different table or endpoint.- Empty
Itemsarray on Scan even after writes succeeded. You're scanning a different table, or pointing at a different LocalStack instance. Runawslocal dynamodb list-tablesto confirm. UnrecognizedClientExceptionwhen using the SDK. You forgot to setendpointandcredentialson the client - it's trying to hit real AWS.
Cleanup commands worth knowing
# Drop the table entirely
awslocal dynamodb delete-table --table-name shortlinks
# Recreate it when you want a clean slate again
awslocal dynamodb create-table \
--table-name shortlinks \
--attribute-definitions AttributeName=code,AttributeType=S \
--key-schema AttributeName=code,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
Drop-and-recreate is what you'll usually reach for during development. Fewer moving parts, less shell glue, less chance of deleting the wrong thing.
Save this as a checkpoint
Add this to your init/ready.d/ folder so the table comes back automatically next time you docker compose up. (Pattern set up in Part 0.)
Save as init/ready.d/02-part2-dynamodb.sh:
#!/usr/bin/env bash
# Part 2 checkpoint - DynamoDB shortlinks table
awslocal dynamodb create-table \
--table-name shortlinks \
--attribute-definitions AttributeName=code,AttributeType=S \
--key-schema AttributeName=code,KeyType=HASH \
--billing-mode PAY_PER_REQUEST 2>/dev/null || true
awslocal dynamodb wait table-exists --table-name shortlinks 2>/dev/null || true
echo "[bootstrap] part 2 - shortlinks table ready"
chmod +x init/ready.d/02-part2-dynamodb.sh
Jumping in at Part 2 from scratch? Drop in 01-part1-s3.sh from Part 1 alongside this one. Strictly speaking, Part 2 doesn't depend on Part 1 yet. Still, keeping the scripts in numeric order now makes the later parts much less messy.
What we'll wire up next
You've got a working data layer with atomic counters and collision-safe writes. The next part brings Lambda into the mix: a Python function that listens for S3 upload events from Part 1's photo bucket and writes a thumbnail to a second bucket. Then Part 4 will wrap our shortener (this article's table) and the photo flow (Part 1 + 3) into a real HTTP API with JWT auth.
The full series
- Part 0 - Start here: series intro and installing LocalStack
- Part 1 - S3 locally: buckets, presigned URLs, and a tiny photo uploader
- Part 2 - DynamoDB locally: building a URL shortener data layer (this article)
- Part 3 - Lambda + S3 events: an image thumbnailer pipeline (next)
- Part 4 - API Gateway + Lambda + JWT auth: a real HTTP API
- Part 5 - SQS + SNS: a background job queue with a dead-letter queue
- Part 6 - EventBridge + Step Functions: orchestrating a photo-processing workflow
- Part 7 - Secrets Manager + KMS: handling secrets and encryption locally
- Part 8 - Terraform (tflocal) + GitHub Actions: integration tests against LocalStack
Sources
- DynamoDB UpdateItem documentation
- @aws-sdk/lib-dynamodb (Document Client)
- The DynamoDB Book - Alex DeBrie
Related on alishaikh.me
Member discussion