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.
AWS CDK Link Shortener Part 2: Core Functionality & API Development
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 typical 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). This post covers the core functionality: 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: Performance Priorities
The redirect handler is the heart of your link shortener. Every millisecond counts because users expect instant redirects. Here’s our production-tested implementation:
// 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';
const dynamodb = new DynamoDBClient({
region: process.env.AWS_REGION,
// Connection pooling for better performance
maxAttempts: 3,
requestHandler: new NodeHttpHandler({
connectionTimeout: 1000,
requestTimeout: 2000,
})
});
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
});
// Log performance metrics
const responseTime = Date.now() - startTime;
console.log(`Redirect processed in ${responseTime}ms for ${shortCode}`);
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('Redirect error:', 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 made our link shortener valuable beyond just convenience. Here’s how we collect and store click data:
// lambda/analytics.ts
import { DynamoDBClient, PutItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
import crypto from 'crypto';
const dynamodb = new DynamoDBClient({ region: process.env.AWS_REGION });
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';
try {
// In production, use a service like MaxMind or AWS's IP geolocation
// For demo, we'll use a simple mock
return 'US'; // Placeholder
} catch (error) {
return 'unknown';
}
}
API Gateway: The Front Door
Here’s our CDK configuration that handles high traffic loads efficiently:
// 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 * as logs from 'aws-cdk-lib/aws-logs';
export class ApiStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: ApiStackProps) {
super(scope, id, props);
// API Gateway with custom domain
const api = new apigateway.RestApi(this, 'LinkShortenerApi', {
restApiName: 'Link Shortener Service',
description: 'Production link shortener API',
// Performance optimizations
minimumCompressionSize: 1024,
binaryMediaTypes: ['*/*'],
// 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),
},
// Request validation
requestValidator: new apigateway.RequestValidator(this, 'RequestValidator', {
restApi: api,
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: 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));
// Enable detailed CloudWatch metrics
api.deploymentStage.addMethodStage('*/*', {
metricsEnabled: true,
loggingLevel: apigateway.MethodLoggingLevel.INFO,
dataTraceEnabled: false, // Disable in prod for performance
throttlingBurstLimit: 2000,
throttlingRateLimit: 1000,
});
}
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 Lessons from Production
After handling production traffic at scale, here are the performance patterns that actually matter:
1. Connection Pooling Saves 50ms Per Request
The DynamoDB client configuration above includes connection pooling. Without it, each Lambda cold start creates new connections, adding 50-100ms latency. With proper pooling:
- Cold start redirect: ~200ms
- Warm redirect: ~15ms
- Connection reuse rate: 85%
2. Async Analytics Don’t Block Users
Initially, we tracked analytics synchronously. Bad idea. Users don’t care if analytics fail, but they definitely care if redirects are slow. Fire-and-forget analytics collection reduced our P95 response time from 300ms to 45ms.
3. DynamoDB Projections Matter
Using ProjectionExpression in our GetItem calls reduced response sizes by 60%. We only fetch what we need for redirects: originalUrl, expiresAt, clickCount. Analytics queries use a separate GSI.
Debugging Production Issues
CloudWatch Insights Queries That Save Your Day
fields @timestamp, @message
| filter @message like /Redirect processed/
| stats avg(responseTime) by bin(5m)
| sort @timestamp desc
fields @timestamp, @message
| filter @message like /error/
| stats count() by shortCode
| sort count desc
| limit 20
Lambda Performance Monitoring
// Add to your handler
const COLD_START = !global.isWarm;
global.isWarm = true;
console.log(JSON.stringify({
coldStart: COLD_START,
responseTime: Date.now() - startTime,
shortCode,
success: statusCode < 400,
}));
Testing Your Redirect Engine
// tests/redirect.test.ts
import { handler } from '../lambda/redirect';
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
In Part 3, we’ll add the security features that keep your service from becoming a spam vector: rate limiting, click fraud detection, and custom domain setup with SSL certificates.
We’ve built a solid redirect engine, but production taught us that security isn’t optional - it’s what separates a hobby project from a business-critical service. See you in the next part where we’ll implement the anti-abuse measures that kept our service running during attempted spam attacks.
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, initial setup, and lessons from URL shorteners at scale.
A simple API change broke an enterprise integration; here is a practical system that generates OpenAPI specs from Zod schemas automatically.
Before building an internal service layer, decide whether to. What the layer 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.