AWS CDK Link Shortener Part 2: Core Functionality & API Development
Build the redirect engine, analytics collection, and API Gateway config: performance optimizations and debugging strategies for millions of daily redirects.
A link shortener is mostly a redirect engine. The short-code lookup and the HTTP 301 response are the only operations on the critical latency budget, and both have to stay under the user-perceived-instant threshold (around 200ms) even at high concurrency. The business logic around that hot path (analytics, rate limiting, link expiration, custom slugs) must not block the redirect; every feature added to the redirect handler directly costs latency at the edge.
Part 1 of this series set up the foundation (DynamoDB table, API Gateway, base Lambda). The core functionality goes on top of it: the redirect Lambda with DynamoDB caching, the API for creating and managing short codes, analytics event emission through a side channel, and the error-handling patterns that keep the redirect fast when upstream services degrade.
The Redirect Engine: Hot Path
From the user’s side the redirect handler is the whole product: one lookup, one response. The implementation below keeps it to a single DynamoDB read and hands everything else to a side channel:
// lambda/redirect.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { unmarshall } from '@aws-sdk/util-dynamodb';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { trackAnalytics } from './analytics';
const dynamodb = new DynamoDBClient({
region: process.env.AWS_REGION,
// Created once per execution environment so warm invocations reuse the socket
maxAttempts: 3,
requestHandler: new NodeHttpHandler({
connectionTimeout: 1000,
requestTimeout: 2000,
})
});
export interface AnalyticsEvent {
shortCode: string;
timestamp: number;
userAgent?: string;
referer?: string;
ip?: string;
country?: string;
}
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
const startTime = Date.now();
const shortCode = event.pathParameters?.shortCode;
if (!shortCode) {
return createErrorResponse(400, 'Short code is required');
}
try {
// Get the URL from DynamoDB
const result = await dynamodb.send(new GetItemCommand({
TableName: process.env.LINKS_TABLE_NAME!,
Key: { shortCode: { S: shortCode } },
ProjectionExpression: 'originalUrl, expiresAt, clickCount',
}));
if (!result.Item) {
// Track 404s for analytics
await trackAnalytics({
shortCode,
timestamp: Date.now(),
userAgent: event.headers['User-Agent'],
referer: event.headers['Referer'],
ip: event.requestContext.identity?.sourceIp,
}, 'NOT_FOUND');
return createErrorResponse(404, 'Link not found');
}
const item = unmarshall(result.Item);
// Check expiration
if (item.expiresAt && Date.now() > item.expiresAt) {
return createErrorResponse(410, 'Link has expired');
}
// Track analytics asynchronously (don't block redirect)
trackAnalytics({
shortCode,
timestamp: Date.now(),
userAgent: event.headers['User-Agent'],
referer: event.headers['Referer'],
ip: event.requestContext.identity?.sourceIp,
}, 'SUCCESS').catch(error => {
console.error('Analytics tracking failed:', error);
// Don't fail the redirect if analytics fail
});
// Structured log so CloudWatch Insights can aggregate on responseTime
const responseTime = Date.now() - startTime;
console.log(JSON.stringify({ event: 'RedirectProcessed', shortCode, responseTime }));
return {
statusCode: 301,
headers: {
Location: item.originalUrl,
'Cache-Control': 'public, max-age=300', // 5 minutes
'X-Response-Time': `${responseTime}ms`,
},
body: '',
};
} catch (error) {
console.error(JSON.stringify({
event: 'RedirectError',
shortCode,
message: error instanceof Error ? error.message : String(error),
}));
return createErrorResponse(500, 'Internal server error');
}
};
function createErrorResponse(statusCode: number, message: string): APIGatewayProxyResult {
return {
statusCode,
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache',
},
body: `
<!DOCTYPE html>
<html>
<head><title>Link Error</title></head>
<body>
<h1>${statusCode === 404 ? 'Link Not Found' : 'Error'}</h1>
<p>${message}</p>
</body>
</html>
`,
};
}
Analytics: The Business Intelligence Layer
Analytics is what makes a shortener useful past the redirect itself: which codes get traffic, where the clicks come from, and when they arrive. Collection writes to a separate table so the redirect never waits on it:
// lambda/analytics.ts
import { DynamoDBClient, PutItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
import crypto from 'crypto';
import type { AnalyticsEvent } from './redirect';
const dynamodb = new DynamoDBClient({ region: process.env.AWS_REGION });
export async function trackAnalytics(
event: AnalyticsEvent,
eventType: 'SUCCESS' | 'NOT_FOUND' = 'SUCCESS'
): Promise<void> {
const timestamp = Date.now();
const analyticsItem = {
shortCode: event.shortCode,
timestamp,
eventType,
userAgent: event.userAgent || 'unknown',
referer: event.referer || 'direct',
ip: hashIP(event.ip || ''), // Privacy-first approach
country: await getCountryFromIP(event.ip),
// Partition by hour for efficient queries
hourPartition: `${event.shortCode}#${Math.floor(timestamp / (1000 * 60 * 60))}`,
};
// Store in analytics table
await dynamodb.send(new PutItemCommand({
TableName: process.env.ANALYTICS_TABLE_NAME!,
Item: marshall(analyticsItem),
}));
// Update click count on main record (only for successful clicks)
if (eventType === 'SUCCESS') {
await dynamodb.send(new UpdateItemCommand({
TableName: process.env.LINKS_TABLE_NAME!,
Key: { shortCode: { S: event.shortCode } },
UpdateExpression: 'ADD clickCount :inc SET lastClickAt = :timestamp',
ExpressionAttributeValues: {
':inc': { N: '1' },
':timestamp': { N: timestamp.toString() },
},
}));
}
}
function hashIP(ip: string): string {
// Simple privacy-preserving hash
return crypto.createHash('sha256').update(ip + process.env.IP_SALT).digest('hex').substring(0, 16);
}
async function getCountryFromIP(ip?: string): Promise<string> {
if (!ip) return 'unknown';
// Placeholder: swap in a geolocation lookup (MaxMind, or a country header
// your CDN adds) and handle its failures here.
return 'US';
}
API Gateway: The Front Door
The CDK definition wires three routes to three handlers and sets the stage-level throttles that protect them:
// lib/api-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
interface ApiStackProps extends cdk.StackProps {
redirectHandler: lambda.IFunction;
createHandler: lambda.IFunction;
analyticsHandler: lambda.IFunction;
}
export class ApiStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: ApiStackProps) {
super(scope, id, props);
// REST API for the redirect and management routes
const api = new apigateway.RestApi(this, 'LinkShortenerApi', {
restApiName: 'Link Shortener Service',
description: 'Production link shortener API',
// Compress responses above 1 KB. Leave binaryMediaTypes unset: '*/*'
// would base64-encode the JSON bodies the creation API receives.
minimumCompressionSize: 1024,
// CORS configuration
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: [
'Content-Type',
'X-Amz-Date',
'Authorization',
'X-Api-Key',
'X-Amz-Security-Token',
],
maxAge: cdk.Duration.hours(1),
},
// Stage-level metrics and throttling
deployOptions: {
metricsEnabled: true,
loggingLevel: apigateway.MethodLoggingLevel.INFO,
dataTraceEnabled: false, // Off in prod: it logs full request payloads
throttlingBurstLimit: 2000,
throttlingRateLimit: 1000,
},
});
// The validator belongs to an existing API, so it is created after it
const requestValidator = api.addRequestValidator('RequestValidator', {
validateRequestBody: true,
validateRequestParameters: true,
});
// Add redirect route: GET /{shortCode}
const redirectIntegration = new apigateway.LambdaIntegration(props.redirectHandler, {
proxy: true,
allowTestInvoke: false, // Disable test invoke for performance
});
api.root.addResource('{shortCode}').addMethod('GET', redirectIntegration, {
requestParameters: {
'method.request.path.shortCode': true,
},
});
// Add creation API: POST /api/shorten
const apiResource = api.root.addResource('api');
const shortenResource = apiResource.addResource('shorten');
const createIntegration = new apigateway.LambdaIntegration(props.createHandler, {
proxy: true,
});
shortenResource.addMethod('POST', createIntegration, {
requestModels: {
'application/json': this.createRequestModel(api),
},
requestValidator,
});
// Add analytics API: GET /api/analytics/{shortCode}
const analyticsResource = apiResource.addResource('analytics');
const analyticsCodeResource = analyticsResource.addResource('{shortCode}');
analyticsCodeResource.addMethod('GET', new apigateway.LambdaIntegration(props.analyticsHandler));
}
private createRequestModel(api: apigateway.RestApi): apigateway.Model {
return new apigateway.Model(this, 'ShortenRequestModel', {
restApi: api,
contentType: 'application/json',
schema: {
type: apigateway.JsonSchemaType.OBJECT,
properties: {
url: {
type: apigateway.JsonSchemaType.STRING,
pattern: '^https?://.+',
minLength: 10,
maxLength: 2048,
},
customCode: {
type: apigateway.JsonSchemaType.STRING,
pattern: '^[a-zA-Z0-9-_]{3,20}$',
},
expiresIn: {
type: apigateway.JsonSchemaType.NUMBER,
minimum: 3600, // 1 hour minimum
maximum: 31536000, // 1 year maximum
},
},
required: ['url'],
additionalProperties: false,
},
});
}
}
Performance Patterns That Matter
Three decisions in the code above carry most of the redirect latency budget.
1. Connection Reuse Across Invocations
The DynamoDB client is created outside the handler, so the same instance and its HTTP agent survive between invocations of a warm execution environment. The first request from a fresh environment still pays for DNS resolution and the TLS handshake; every request after it reuses the open connection. Moving the client construction inside the handler throws that reuse away and puts a handshake on every redirect.
2. Analytics Off the Critical Path
The redirect returns as soon as DynamoDB answers, and the analytics write is left running. The fire-and-forget pattern hides one caveat: Lambda freezes the execution environment once the response is written, so a promise you never await can be suspended until that environment is invoked again, or lost if it is reclaimed first. If dropping the occasional click is unacceptable, await the write or push the event to an SQS queue and let a separate consumer persist it.
3. Projections and Read Cost
ProjectionExpression keeps the response to the three attributes the redirect needs: originalUrl, expiresAt, clickCount. It shrinks the payload on the wire, not the read capacity. DynamoDB computes consumed read capacity from the full item size before the projection is applied, so wide items stay expensive to read. Analytics queries use a separate GSI rather than widening this one.
Debugging Production Issues
CloudWatch Insights Queries
Both queries below read the JSON fields the handler logs; Insights discovers them automatically for structured log lines.
fields @timestamp, responseTime
| filter event = "RedirectProcessed"
| stats avg(responseTime) as avgMs, pct(responseTime, 95) as p95Ms by bin(5m)
fields @timestamp, shortCode
| filter event = "RedirectError"
| stats count(*) as errorCount by shortCode
| sort errorCount desc
| limit 20
Lambda Performance Monitoring
// lambda/redirect.ts, module scope: set once per execution environment
let isColdStart = true;
// ...inside the handler, right before returning the 301:
console.log(JSON.stringify({
event: 'RedirectProcessed',
coldStart: isColdStart,
responseTime: Date.now() - startTime,
shortCode,
}));
isColdStart = false;
Testing Your Redirect Engine
The handler talks to DynamoDB directly, so these tests need aws-sdk-client-mock or a local table seeded with the abc123 and expired records:
// tests/redirect.test.ts
import { APIGatewayProxyEvent } from 'aws-lambda';
import { handler } from '../lambda/redirect';
function createAPIGatewayEvent(shortCode: string): APIGatewayProxyEvent {
return {
pathParameters: { shortCode },
headers: {},
requestContext: { identity: {} },
} as unknown as APIGatewayProxyEvent;
}
describe('Redirect Handler', () => {
beforeEach(() => {
process.env.LINKS_TABLE_NAME = 'test-links';
process.env.ANALYTICS_TABLE_NAME = 'test-analytics';
});
test('should redirect to original URL', async () => {
const event = createAPIGatewayEvent('abc123');
const result = await handler(event);
expect(result.statusCode).toBe(301);
expect(result.headers?.Location).toBe('https://example.com');
expect(result.headers?.['Cache-Control']).toBe('public, max-age=300');
});
test('should handle expired links gracefully', async () => {
const event = createAPIGatewayEvent('expired');
const result = await handler(event);
expect(result.statusCode).toBe(410);
expect(result.body).toContain('expired');
});
});
Next Steps
The single-read redirect above holds as long as the hot path stays a GetItem on the partition key. A lookup by original URL, a per-user link list, or a second table read on the redirect breaks the latency budget; those belong behind the creation API or on a GSI that only analytics queries.
Part 3 adds the layers that keep the service from becoming a spam vector: input validation, rate limiting, WAF rules, and custom domain setup with certificates.
References
- Tutorial: Create a CRUD HTTP API with Lambda and DynamoDB - Official guide for building the API Gateway and Lambda redirect handler with DynamoDB lookups.
- Best practices for querying and scanning data in DynamoDB - DynamoDB guidance on using Query over Scan for efficient short-code lookups and avoiding full-table reads.
- Best practices for using secondary indexes in DynamoDB - When and how to use Global Secondary Indexes to support analytics access patterns alongside the primary redirect path.
- Configuring provisioned concurrency for a function - Official Lambda docs on provisioned concurrency for eliminating cold starts on the critical redirect path.
- Visualize Lambda function invocations using AWS X-Ray - X-Ray tracing for Lambda to instrument redirect latency and identify bottlenecks.
- Using CloudWatch metrics with Lambda - Available Lambda metrics and how to set alarms for error rates and redirect latency thresholds.
AWS CDK Link Shortener: From Zero to Production
A comprehensive 5-part series on building a production-grade link shortener service with AWS CDK, Node.js Lambda, and DynamoDB. Real war stories, performance optimization, and cost management included.
All Posts in This Series
Related posts
Set up a production-grade link shortener with AWS CDK, DynamoDB, and Lambda: architecture decisions, project layout, and the schema choices that hold up at scale.
Keep a Lambda API and its OpenAPI contract in sync by generating the spec from Zod schemas and wiring that generation into CDK deployment.
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.
A private REST API structurally cannot carry gRPC, and every AWS surface that speaks gRPC excludes Lambda targets. What to keep from gRPC, and what to drop.
The private REST API, the resource policy that switches it on, per-route AWS_IAM grants, the two CDK stacks, and signing the call from a Node 22 Lambda.