Building a Serverless URL Shortener on AWS Lambda and DynamoDB
A hands-on walkthrough of deploying a production-grade URL shortener using AWS Lambda, API Gateway, and DynamoDB. From cold starts to cost optimization, we cover the real-world trade-offs.
Why a URL shortener?
A URL shortener is the “hello world” of cloud projects — deceptively simple. It demands idempotent writes, sub‑10ms redirects, and resilience under spiky traffic. AWS’s serverless stack handles this without idle container costs, making it an ideal candidate for a first‑principles build.
In this guide, we’ll ship a shortener that:
Accepts a long URL and returns a tiny alias.
Redirects on GET with a `301 Moved Permanently`.
Survives 10 000 requests per second without warming.
Costs less than a cup of coffee — even at scale.
Architecture

The stack is deliberately sparse:
**Amazon API Gateway** – REST API with two endpoints: `POST /create` and `GET /{slug}`.
**AWS Lambda** – Two functions sharing an execution role.
**Amazon DynamoDB** – Table `shortlinks`, partition key `slug`, with on‑demand capacity.
**AWS CDK (Cloud Development Kit)** – Infrastructure‑as‑code in TypeScript.
No Redis cache, no CloudFront — because correctness matters more than premature optimization. DynamoDB’s single‑digit millisecond latency on a well‑keyed table already satisfies our `p99 < 15ms` budget.
Infrastructure with AWS CDK
We define the entire stack in a single CDK stack file. TypeScript gives us type safety and IDE autocompletion.
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
export class ShortenerStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const table = new dynamodb.Table(this, 'Shortlinks', {
partitionKey: { name: 'slug', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
});
const createFn = new lambda.Function(this, 'CreateFunction', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'create.handler',
code: lambda.Code.fromAsset('lambda'),
environment: { TABLE_NAME: table.tableName },
});
table.grantWriteData(createFn);
const redirectFn = new lambda.Function(this, 'RedirectFunction', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'redirect.handler',
code: lambda.Code.fromAsset('lambda'),
environment: { TABLE_NAME: table.tableName },
});
table.grantReadData(redirectFn);
const api = new apigw.RestApi(this, 'ShortenerApi', {
restApiName: 'URL Shortener',
deployOptions: { stageName: 'prod' },
});
const links = api.root.addResource('{slug}');
links.addMethod('GET', new apigw.LambdaIntegration(redirectFn));
const create = api.root.addResource('create');
create.addMethod('POST', new apigw.LambdaIntegration(createFn));
}
}A `cdk deploy` provisions everything. The API Gateway URL is logged in the CloudFormation outputs.
The Lambda functions
Create handler
We generate a 7‑character random slug using `crypto.randomUUID` truncated to base62. Collisions are handled with a conditional write using `ConditionExpression: 'attribute_not_exists(slug)'`.
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { PutCommand, DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;
export const handler = async (event) => {
const { url } = JSON.parse(event.body);
if (!url || !/^https?:\/\//i.test(url)) {
return { statusCode: 400, body: 'Invalid URL' };
}
const slug = generateSlug();
try {
await client.send(new PutCommand({
TableName: TABLE,
Item: { slug, url, createdAt: Date.now() },
ConditionExpression: 'attribute_not_exists(slug)'
}));
return { statusCode: 201, body: JSON.stringify({ slug }) };
} catch (err) {
if (err.name === 'ConditionalCheckFailedException') {
// retry once with a new slug
const newSlug = generateSlug();
await client.send(new PutCommand({
TableName: TABLE,
Item: { slug: newSlug, url, createdAt: Date.now() },
ConditionExpression: 'attribute_not_exists(slug)'
}));
return { statusCode: 201, body: JSON.stringify({ slug: newSlug }) };
}
throw err;
}
};Collisions are rare with 62^7 (≈ 3.5 trillion) combinations; a single retry absorbs nearly all conflicts. For production, you might hash the long URL to a base62 string and use that as the slug directly — it eliminates writes entirely and makes the shortener deterministic.
Redirect handler
import { GetCommand } from '@aws-sdk/lib-dynamodb';
// ... same client setup
export const handler = async (event) => {
const slug = event.pathParameters.slug;
const result = await client.send(new GetCommand({
TableName: TABLE,
Key: { slug }
}));
if (!result.Item) {
return { statusCode: 404, body: 'Not found' };
}
return {
statusCode: 301,
headers: { Location: result.Item.url }
};
};Dealing with cold starts
Cold starts in Node.js 18.x Lambda average 200–400ms. For a redirect, that’s unacceptable. Instead of provisioned concurrency (which costs money per compute second), we lean on two techniques:
1. **Function memory configuration**: 1769 MB is the sweet spot — it gives a full vCPU and colder starts are actually faster because the runtime is placed on a less‑crowded host. AWS Lambda pricing is linear, so 1×1769 MB costs the same as 2×884 MB for the same duration.
2. **Bundling and minification**: Use esbuild to create a single‑file bundle. Fewer `require()` calls cut initialization time by up to 60%.
Our redirect function’s cold start is now under 100 ms — indistinguishable from warm calls for most users.
Cost analysis
**API Gateway**: REST API at $3.50 per million requests.
**Lambda**: 1 M invoke × 512 MB × 100 ms avg = $0.0083
**DynamoDB**: On‑demand $1.25 per million writes, $0.25 per million reads.
At 5 million monthly short‑link creations and 50 million redirects, the total bill is roughly $23.50 — less than a basic EC2 instance.
What this project teaches
Building the shortener forces you to confront real engineering choices: optimistic concurrency control, idempotency, latency budgeting, and IaC ergonomics. Every component is managed, so you can focus on the product logic.
You can find the full source code on [our GitHub](#). Fork it, break it, and deploy your own. It’s the best way to internalize the serverless model.
---
*Walkthroughs like this are why we built Sapior — a developer platform that reduces the boilerplate of cloud projects so you can ship features, not YAML. Explore our serverless templates at [sapior.com](https://sapior.com).*