Created the DynamoDB table
Created a table named visitor-counter with a partition key of id. DynamoDB works well here because the project only needs one record that stores the running count.
The visitor count on my portfolio is not hardcoded. It is a real-time serverless counter built on AWS. Every visit triggers a Lambda function, increments a DynamoDB record, and returns the live count.
Every visit triggers this serverless pipeline and returns the latest count without running a traditional backend server.
A visitor counter is a small feature, but it demonstrates a complete serverless architecture where every component is managed by AWS with no server maintenance.
With a traditional backend, I would need a server running continuously, a database to manage, and infrastructure to patch and monitor. With serverless, the Lambda function only runs when someone visits. No traffic means no compute cost. No servers means less operational overhead.
For a portfolio site with irregular traffic, serverless is a practical fit: available on demand, simple to scale, and extremely low cost at this size.
Here is the path from a static portfolio page to a working AWS-powered visitor counter.
Created a table named visitor-counter with a partition key of id. DynamoDB works well here because the project only needs one record that stores the running count.
Created a Lambda function named visitor-counter with the Python 3.12 runtime. The function uses boto3 to update DynamoDB whenever the endpoint is called.
Lambda functions run under an IAM role. I attached DynamoDB access to the execution role so the function could read and write the visitor counter table.
Created an HTTP API named visitor-counter-api with a GET /count route. API Gateway receives the browser request, invokes Lambda, and returns the response.
Attached a Lambda integration to the GET /count route so API Gateway knows which function should handle the request.
Replaced the placeholder counter URL in index.js with the API Gateway invoke URL. The portfolio now calls the API on page load and renders the live count.
The backend logic is small, but it covers the core ideas: AWS SDK access, atomic DynamoDB updates, JSON output and CORS headers.
import json
import boto3
# Connect to DynamoDB. boto3 uses the Lambda execution role credentials.
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('visitor-counter')
def lambda_handler(event, context):
# Atomic increment: thread-safe and protected from race conditions.
response = table.update_item(
Key={'id': 'visitors'},
UpdateExpression='ADD #count :increment',
ExpressionAttributeNames={'#count': 'count'},
ExpressionAttributeValues={':increment': 1},
ReturnValues='UPDATED_NEW'
)
count = int(response['Attributes']['count'])
return {
'statusCode': 200,
'headers': {
# CORS: only allow requests from the portfolio domain.
'Access-Control-Allow-Origin': 'https://pavankrishna.dev',
'Access-Control-Allow-Methods': 'GET',
'Content-Type': 'application/json'
},
'body': json.dumps({'count': count})
}
Why use ADD instead of GET then SET? DynamoDB's ADD operation increments the value
in one atomic operation. If two visitors arrive at the same time, both increments are counted correctly.
Why use ExpressionAttributeNames? The word count is reserved in DynamoDB. Using
#count as an alias avoids a syntax error.
When trying to attach a policy to the Lambda execution role, the console returned an access denied error. The deployment IAM user did not have permission to manage IAM policies.
Added the missing IAM permissions, then returned to attach DynamoDB access to the Lambda role. The key lesson was that every AWS service connection needs explicit permission.
After updating the counter URL and testing locally, the page displayed N/A instead of the real count. The browser request was blocked because local development was not an allowed origin.
Kept CORS restricted to https://pavankrishna.dev and tested the counter on the deployed site after pushing to GitHub. This prevents other websites from calling the API and inflating the count.
The API Gateway invoke URL returned {"message": "Not Found"} because the GET /count route was not yet attached to the Lambda integration.
Attached the Lambda integration to the route, waited briefly for the change to apply, and retested the full /count URL. The endpoint then returned the visitor count correctly.
Serverless pricing is based on usage. For a personal portfolio, this project is effectively free.
| Service | Free tier allowance | Monthly cost |
|---|---|---|
| AWS Lambda | 1 million requests per month plus 400,000 GB-seconds compute | ~โฌ0 |
| API Gateway HTTP API | 1 million API calls per month for the first 12 months | ~โฌ0 |
| DynamoDB | 25 GB storage plus 25 read and write capacity units | โฌ0 |
| IAM | Always free | โฌ0 |
| Total | Real-time serverless counter, available on demand | ~โฌ0 |
At typical portfolio traffic levels, the cost remains near zero even beyond the free tier.
Lambda scales from zero and only runs when called. The mental model changes from managing servers to composing managed services.
Restricting API access to the portfolio domain prevents other websites from calling the endpoint and manipulating the count.
DynamoDB's atomic update prevents race conditions. Even small projects should be correct under concurrent requests.
Lambda cannot access DynamoDB without explicit IAM permission. Permissions are the glue between AWS services.
Testing Lambda first, then API Gateway, made it easier to locate errors before connecting the full flow.
For a portfolio visitor counter, Lambda and DynamoDB stay comfortably within free-tier style usage.