Skip to content
Ayhan Sipahi Ayhan Sipahi

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

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.

Progress 2/5 posts completed

Related posts