Building an Event-Driven Health Tracker with Three Lambda Functions
How we built a health tracking platform with event-driven notifications, scheduled jobs, and auto-completing goals, and why decoupling what happens from when it happens made everything simpler.
The brief was a health and fitness tracker. Log exercises, record meals, track weight, set goals. Standard full-stack coursework.
We could have built it as a monolith: one Express server that handles requests, sends emails, and checks for overdue goals all in the same process. It would have worked. But monoliths that send emails in the request path are fragile. If the email service is slow, the user waits. If it fails, the request fails. The user's goal achievement notification should not be coupled to whether AWS SES responded in time.
So we split the system into three independently deployable Lambda functions, connected by events. The API handles requests. A notification service sends emails asynchronously. A scheduled function checks for overdue goals every morning. Each one does its job without knowing how the others work.
This post is about that architecture: why we split it, how the pieces connect, and what the separation made possible.
The Architecture
The system has two paths. The synchronous path handles user requests: the Angular frontend calls the API Gateway, which invokes the API Lambda, which reads and writes to MongoDB. This is straightforward.
The asynchronous path is where it gets interesting. When something notable happens in the API (a user registers, a goal is achieved, a group invitation is sent), the API publishes a message to an SNS topic and moves on. It does not send an email. It does not even know that emails exist.
The SNS topic delivers messages to an SQS queue. The queue triggers a second Lambda function that reads the message, renders an HTML email from a Liquid template, and sends it through SES. If this Lambda fails, the message stays in the queue and gets retried. The API never knows, and the user's request was never blocked.
A third Lambda runs on a CloudWatch Events schedule: once per day at 9 AM. It queries the API for goals past their target date, then publishes overdue notifications to the same SNS topic. The notification Lambda picks them up like any other event.
Three functions. One SNS topic. One queue. The event types are distinguished by their subject line, not by separate infrastructure.
Separating What Happens from When It Happens
The key design decision is that the API's job is to record what happened, not to decide what to do about it.
When a user logs a weight measurement and it happens to hit a goal target, the API does two things: save the measurement and mark the goal complete. Then it publishes a GoalAchieved event and returns the response. The API is done.
The notification Lambda, independently, picks up that event and decides what to do about it. It checks whether the user has verified their email. If they have, it renders a congratulations email with a suggested next goal (target incremented by a sensible amount). If they have not verified, it does nothing.
if (subj === goalAchievedSnsSbj) {
const {email, htmlContent, emailVerified} = await render_goal_achieved(message, baseWebsiteUrl, engine);
if (emailVerified) {
await sendEmail(sender, email, "Goal achieved! Way to go!", htmlContent);
}
}
This separation means the API controller code stays clean. The registration endpoint publishes a Registered event; it does not contain email rendering logic. The goal completion code publishes GoalAchieved; it does not know about suggested next goals. Each concern lives in exactly one place.
It also means the notification logic can change without touching the API. We added the "suggest a new goal" feature entirely within the notification Lambda. The API never had to be redeployed.
Auto-Completing Goals
The most interesting behaviour in the system is reactive: goals that complete themselves when the user logs data.
When a user creates a health metric (a weigh-in), the database service does not just save the record. It also checks every open weight goal for that user:
async createHealthMetricAsync(healthMetric: HealthMetricDocument) {
let healthMetricDocument = await HealthMetrics.create(healthMetric);
const weightGoals = await Goal.find({
userId: healthMetric.userId,
type: GoalType.WEIGHT,
completed: false
});
const goals = await Promise.all(weightGoals.map(async goal => {
goal.currentValue = lastHealthMetric.weight;
if (goal.currentValue <= goal.targetValue) {
goal.completed = true;
goal.completedDate = new Date();
}
return await goal.save();
}));
return {healthMetric: healthMetricDocument, completedGoals: goals.filter(g => g.completed)};
}
The same pattern applies to exercise goals. Logging a run updates every open distance goal for that exercise type. Logging a workout updates every open duration goal. The controller then publishes GoalAchieved events for any goals that were completed, which flow through SNS to the notification Lambda.
From the user's perspective, they log a run and a few seconds later get an email saying they hit their 100km goal. From the system's perspective, five things happened in sequence: the exercise was saved, matching goals were queried, progress was updated, the controller published events, and the notification Lambda (asynchronously, separately) rendered and sent an email. Each step knows only about itself and the next.
The calorie calculation is a nice detail too. When an exercise is logged, the system pulls the exercise type's MET value and the user's most recent weight to calculate calories burned automatically:
const caloriesBurned = exerciseType.mET * 3.5 * lastHealthMetric.weight * exerciseDocument.duration / 200;
The user logs "30 minutes of running." The system returns the exercise record with calories already calculated, using physiologically accurate constants. No manual entry needed.
The Scheduled Lambda
The third Lambda runs on a cron schedule. Every day at 9 AM UTC, CloudWatch Events triggers it:
const overdueGoalsRule = new cdk.aws_events.Rule(this, `OverdueGoalsCheckRule-${stage}`, {
schedule: cdk.aws_events.Schedule.expression('cron(0 9 * * ? *)'),
description: `Trigger overdue goals check daily at 9 AM UTC`
});
overdueGoalsRule.addTarget(new cdk.aws_events_targets.LambdaFunction(checkOverdueGoalsLambda));
The Lambda itself is small. It calls the API's internal endpoint to find overdue goals and publishes notifications for each one. The notification Lambda handles the rest.
What makes this work is the internal API key. The three Lambdas share a secret generated by CDK and stored in Secrets Manager. The API authenticates requests in two ways: JWT tokens for user requests, and the raw API key for Lambda-to-Lambda communication. The overdue goals Lambda uses the API key to call the same API that the frontend calls, but with elevated access.
This keeps the overdue-checking logic in the API where it belongs. The scheduled Lambda is just a trigger. If the business rules for "overdue" change, only the API needs updating.
Group Goals
Groups add a social dimension. Users create groups, invite members via join codes, and set shared goals. The interesting part is how group goals work at the data level.
When someone sets a group goal, the system creates a separate goal record for every member:
async createGroupGoal(creatorId: string, groupId: string, goalData: {...}) {
const group = await Group.findById(groupId);
const groupGoalLink = new Types.ObjectId().toHexString();
return await Promise.all(group.members.map(async memberId => {
let currentValue = await this.calculateCurrentProgress(memberId, goalData);
return await Goal.create({
...goalData,
userId: memberId,
isGroupGoal: true,
groupId,
groupGoalLink,
currentValue,
completed: currentValue >= goalData.targetValue
});
}));
}
Each member gets their own goal document with the same target but independent progress. A groupGoalLink ties them together so the UI can show group-wide progress. This means group goals work identically to personal goals from the database service's perspective. The auto-completion logic does not need a special case for groups. When a group member logs an exercise that completes their goal, the same GoalAchieved event fires, and a GroupGoalCompleted notification goes out.
The alternative would have been a single shared goal document that tracks multiple users' progress. That design sounds simpler until you need to handle a member leaving the group, or partial completion, or showing individual progress in the UI. Denormalising into one-goal-per-member made every downstream query simpler.
Composing the Infrastructure
The CDK stack defines all three Lambdas, the SNS topic, the SQS queue, and their permissions in a single TypeScript file. The pipeline builds all three services in parallel:
const buildApi = new pipelines.ShellStep(`BuildApi-${stage}`, {
commands: ['cd HealthTrackerAPI', 'npm install', 'npm run build', 'npm run zip']
});
const buildNotificationLambda = new pipelines.ShellStep(`BuildNotificationLambda-${stage}`, {
commands: ['cd HealthTrackerAPI.NotificationsLambda', 'npm install', 'npm run build', 'npm run zip']
});
const buildGoalOverdueLambda = new pipelines.ShellStep(`BuildGoalOverdueLambda-${stage}`, {
commands: ['cd HealthTrackerAPI.OverdueGoalsLambda', 'npm install', 'npm run build', 'npm run zip']
});
Three independent builds feed into a single CDK synth step that composes them into one CloudFormation stack. The infrastructure references between them (API Gateway URL passed to the overdue Lambda, SNS ARN passed to the API) are wired through CDK constructs, not hardcoded strings.
The system deploys to two stages (dev and prod) with isolated resources. Each stage gets its own SNS topic, its own SQS queue, its own set of secrets, and its own MongoDB database. A bug in the notification template in dev cannot send emails to prod users. The stages share nothing except the pipeline that deploys them.
The Takeaway
The three-Lambda split was not about scalability. A monolith would have handled the load of a university project. It was about keeping each concern in its own box.
The API does not know how emails are sent. The notification Lambda does not know how goals are tracked. The scheduled Lambda does not know how overdue goals are identified. Each function has a single reason to change, and when it does change, the blast radius is limited to itself.
The event bus (SNS + SQS) is the contract between them. As long as the message format stays stable, any Lambda can be rewritten, redeployed, or replaced independently. That is the practical benefit of event-driven architecture: not performance, not scale, but the ability to change one part of the system without coordinating with every other part.