Sapior LogoSapior

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

![URL Shortener Architecture](./url-shortener-arch.png)

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).*

Build a Serverless URL Shortener with AWS – Sapior Blog