Skip to content
LIAM RECKZIEGELEngineer / builder / curious person
Blog Post

Building a Multi-Tenant NFC Platform with AWS Lambda and DynamoDB

Serverless NFC platform for post-purchase tracking. Multi-tenant isolation, DynamoDB schema design, and scaling to thousands of concurrent users.

Author: Liam ReckziegelBlog Post · deep dive

Building a Multi-Tenant NFC Platform with AWS Lambda and DynamoDB

December 9, 2024 (2y ago)

The Problem

Brands need to track how customers interact with physical products after purchase: how often products are used, feature engagement patterns, and warranty claim data. Traditional approaches require managing dedicated infrastructure per brand, which doesn't scale.

<200ms
Response Time
15K
Concurrent Users
$0.03
Per 1K Requests
100%
Uptime

Requirements:

  • Handle NFC tag scans with <200ms response time
  • Support thousands of concurrent users during product launches
  • Strict data isolation between brands (tenants)
  • Real-time analytics dashboards
  • Cost-effective at scale
<200ms
Response Time
1000s
Concurrent Users
100%
Data Isolation
Pay-per-use
Cost Model

Architecture Overview

Physical Product (NFC Tag)
          ↓
    [Customer Phone]
          ↓
   API Gateway (REST)
          ↓
   Lambda (Request Handler)
          ↓
   DynamoDB (Event Storage)
          ↓
   EventBridge → Lambda (Analytics Processor)
          ↓
   S3 (Aggregated Analytics)
          ↓
   CloudFront (Dashboard)

Technology Decisions

Serverless architecture is ideal for unpredictable traffic patterns like product launches. You only pay for what you use, and the infrastructure automatically scales to handle traffic spikes without any manual intervention.

Why Serverless?

Traditional EC2

Manual infrastructure:

  • Provision servers in advance
  • Pay 24/7 even when idle
  • Manual scaling configuration
  • Patching and maintenance
Serverless Lambda

Zero server management:

  • Auto-scales from 0 to thousands
  • Pay per request ($0/month idle)
  • Built-in redundancy
  • No maintenance overhead

Lambda Benefits:

  • Zero server management
  • Automatic scaling for traffic spikes
  • Pay-per-request pricing (ideal for variable usage)
  • Built-in redundancy and fault tolerance

DynamoDB Benefits:

  • Single-digit millisecond latency at any scale
  • Automatic scaling for read/write capacity
  • Built-in backup and point-in-time recovery
  • Global tables for multi-region support

Trade-offs:

  • Cold start latency (mitigated with provisioned concurrency)
  • DynamoDB requires careful schema design
  • Debugging distributed systems is harder

DynamoDB Schema Design

Multi-tenant data isolation is critical for security and compliance. Use partition keys for physical data separation, validate tenantId on every request, and audit all cross-tenant queries. Schema mistakes in production are expensive to fix.

Multi-Tenant Data Model

Key Requirement: Tenant isolation with efficient queries.

// Table: nfc-events
// Primary Key: PK (Partition Key), SK (Sort Key)

interface NFCEvent {
  PK: string;        // Format: "TENANT#{tenantId}"
  SK: string;        // Format: "EVENT#{timestamp}#{eventId}"
  GSI1PK: string;    // Format: "PRODUCT#{productId}"
  GSI1SK: string;    // Format: "EVENT#{timestamp}"
  
  tenantId: string;
  productId: string;
  tagId: string;
  eventType: 'scan' | 'interaction' | 'warranty';
  userId?: string;
  metadata: Record<string, any>;
  timestamp: number;
  ttl: number;       // Auto-delete old events
}

Access Patterns:

  1. Get all events for a tenant
// Query by PK
PK = "TENANT#abc123"
  1. Get events for a specific product
// Query GSI1
GSI1PK = "PRODUCT#prod456"
GSI1SK begins_with "EVENT#"
  1. Time-range queries
// Query with SK condition
PK = "TENANT#abc123"
SK between "EVENT#2024-01-01" and "EVENT#2024-01-31"

Why This Schema Works

Tenant Isolation: Partition key includes tenantId, ensuring data is physically separated.

Efficient Queries: GSI enables querying by product without scanning entire table.

Cost Optimization: TTL automatically deletes old events, reducing storage costs.

Lambda Function Architecture

API Handler

// lambda/api-handler.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  try {
    // Extract tenant from API key or JWT
    const tenantId = extractTenantId(event);
    
    // Parse scan event
    const body = JSON.parse(event.body || '{}');
    const { tagId, productId, eventType, metadata } = body;
    
    // Validate tenant has access to this product
    await validateTenantAccess(tenantId, productId);
    
    // Create event record
    const timestamp = Date.now();
    const eventId = generateEventId();
    
    const item = {
      PK: `TENANT#${tenantId}`,
      SK: `EVENT#${timestamp}#${eventId}`,
      GSI1PK: `PRODUCT#${productId}`,
      GSI1SK: `EVENT#${timestamp}`,
      tenantId,
      productId,
      tagId,
      eventType,
      metadata,
      timestamp,
      ttl: Math.floor(Date.now() / 1000) + (365 * 24 * 60 * 60) // 1 year
    };
    
    // Write to DynamoDB
    await docClient.send(
      new PutCommand({
        TableName: process.env.TABLE_NAME,
        Item: item
      })
    );
    
    // Trigger analytics processing (async)
    await publishToEventBridge(item);
    
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      },
      body: JSON.stringify({
        success: true,
        eventId
      })
    };
    
  } catch (error) {
    console.error('Error processing NFC event:', error);
    
    return {
      statusCode: 500,
      body: JSON.stringify({
        success: false,
        error: 'Failed to process event'
      })
    };
  }
};

Cold Start Optimization

// Reuse connections outside handler
const client = new DynamoDBClient({
  maxAttempts: 3,
  requestTimeout: 3000
});

// Provisioned concurrency for critical endpoints
// terraform/lambda.tf
resource "aws_lambda_provisioned_concurrency_config" "api" {
  function_name = aws_lambda_function.api_handler.function_name
  provisioned_concurrent_executions = 10
}

Multi-Tenancy Security

1. API Key Strategy

// Each tenant gets a unique API key
interface APIKey {
  key: string;
  tenantId: string;
  permissions: string[];
  rateLimit: number;
}

// API Gateway validates key before reaching Lambda
function extractTenantId(event: APIGatewayProxyEvent): string {
  const apiKey = event.headers['x-api-key'];
  // API Gateway adds context after validation
  return event.requestContext.authorizer?.tenantId;
}

2. Resource Access Control

async function validateTenantAccess(
  tenantId: string,
  productId: string
): Promise<void> {
  // Query product ownership
  const result = await docClient.send(
    new GetCommand({
      TableName: process.env.PRODUCTS_TABLE,
      Key: {
        PK: `PRODUCT#${productId}`
      }
    })
  );
  
  if (result.Item?.tenantId !== tenantId) {
    throw new Error('Unauthorized access to product');
  }
}

3. Row-Level Security

DynamoDB partition key includes tenantId, making cross-tenant queries impossible at the database level.

Analytics Processing

Event-Driven Architecture

// lambda/analytics-processor.ts
import { EventBridgeEvent } from 'aws-lambda';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3Client = new S3Client({});

export const handler = async (event: EventBridgeEvent<string, NFCEvent>) => {
  const nfcEvent = event.detail;
  
  // Aggregate events by product and time window
  const aggregations = await aggregateEvents(nfcEvent);
  
  // Store in S3 for dashboard queries
  await s3Client.send(
    new PutObjectCommand({
      Bucket: process.env.ANALYTICS_BUCKET,
      Key: `analytics/${nfcEvent.tenantId}/${nfcEvent.productId}/${getDateKey()}.json`,
      Body: JSON.stringify(aggregations),
      ContentType: 'application/json'
    })
  );
};

function aggregateEvents(event: NFCEvent) {
  return {
    productId: event.productId,
    date: new Date(event.timestamp).toISOString().split('T')[0],
    metrics: {
      totalScans: 1,
      uniqueUsers: event.userId ? 1 : 0,
      eventTypes: {
        [event.eventType]: 1
      }
    }
  };
}

S3 Data Layout

s3://analytics-bucket/
  analytics/
    {tenantId}/
      {productId}/
        2024-01-01.json
        2024-01-02.json
        ...

Benefits:

  • Partition by tenant for isolation
  • Partition by date for time-range queries
  • Cheap storage for cold data
  • Can use Athena for ad-hoc SQL queries

Analytics Dashboard API

// lambda/dashboard-api.ts
export async function getProductAnalytics(
  tenantId: string,
  productId: string,
  startDate: string,
  endDate: string
) {
  const s3Keys = generateDateRange(startDate, endDate).map(
    date => `analytics/${tenantId}/${productId}/${date}.json`
  );
  
  // Fetch all daily aggregations in parallel
  const results = await Promise.all(
    s3Keys.map(key =>
      s3Client.send(
        new GetObjectCommand({
          Bucket: process.env.ANALYTICS_BUCKET,
          Key: key
        })
      ).catch(() => null) // Date might not exist
    )
  );
  
  // Merge and return
  return mergeAnalytics(results.filter(Boolean));
}

Scaling Performance

Achieved Metrics

Response Times:

  • P50: 45ms
  • P95: 120ms
  • P99: 180ms

Throughput:

  • 10,000 requests/second sustained
  • 50,000 requests/second peak (during product launch)

Concurrent Users:

  • Tested up to 15,000 concurrent users
  • Zero downtime during traffic spikes

Cost Breakdown (10M requests/month)

  • API Gateway: $35/month
  • Lambda: $120/month (including provisioned concurrency)
  • DynamoDB: $80/month (on-demand pricing)
  • S3: $15/month
  • Data Transfer: $45/month
  • Total: ~$295/month

Cost per 1000 requests: $0.0295

Monitoring & Observability

CloudWatch Metrics

// Custom metrics
import { CloudWatch } from '@aws-sdk/client-cloudwatch';

const cloudwatch = new CloudWatch({});

async function trackCustomMetric(metricName: string, value: number) {
  await cloudwatch.putMetricData({
    Namespace: 'NFCPlatform',
    MetricData: [{
      MetricName: metricName,
      Value: value,
      Unit: 'Count',
      Timestamp: new Date()
    }]
  });
}

Alarms

// terraform/cloudwatch.tf
resource "aws_cloudwatch_metric_alarm" "high_error_rate" {
  alarm_name          = "nfc-platform-high-errors"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = "2"
  metric_name         = "Errors"
  namespace           = "AWS/Lambda"
  period              = "60"
  statistic           = "Sum"
  threshold           = "10"
  alarm_description   = "This metric monitors lambda errors"
  alarm_actions       = [aws_sns_topic.alerts.arn]
}

Lessons Learned

1. DynamoDB Schema Design is Critical

Mistake: Initially used productId as partition key. Problem: Hot partitions when a single product went viral. Fix: Use tenantId as partition key, productId in GSI.

2. Lambda Cold Starts Impact UX

Solution:

  • Use provisioned concurrency for critical paths
  • Optimize bundle size (removed unused AWS SDK modules)
  • Keep functions small and focused

3. Multi-Tenant Isolation Requires Discipline

Best Practices:

  • Validate tenantId on every request
  • Use partition keys for physical isolation
  • Audit logs for all cross-tenant queries (should be zero)

4. Event-Driven Architecture Simplifies Scaling

Separating write path (Lambda → DynamoDB) from analytics (EventBridge → S3) allowed independent scaling.

5. Cost Monitoring from Day One

Set up AWS Budgets and cost alerts early. DynamoDB on-demand pricing can surprise you.

Future Improvements

  1. Global Distribution: DynamoDB Global Tables for multi-region support
  2. Real-time Analytics: Kinesis Data Streams for live dashboards
  3. ML Integration: SageMaker for predictive analytics
  4. GraphQL API: AppSync for more flexible querying

💡Key Takeaways

  • 1Serverless (Lambda + DynamoDB) scales effortlessly for unpredictable traffic spikes
  • 2Multi-tenancy requires careful schema design - use partition keys for data isolation
  • 3Event-driven architecture with EventBridge separates concerns and enables independent scaling
  • 4DynamoDB delivers sub-200ms response times but schema mistakes are expensive to fix
  • 5Monitoring and cost tracking from day one - AWS costs can surprise you
  • 6Provisioned concurrency eliminates Lambda cold starts for critical paths
  • 7TTL (Time To Live) automatically cleans up old data and reduces costs