Skip to content

← Archive

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.


13 April 2026 8 min read

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 handling requests and sending emails and checking for overdue goals in the same process, and it would have worked. Monoliths that send emails in the request path are fragile, though. If the email service is slow the user waits, and if it fails the request fails, and a user's goal achievement notification shouldn't 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, and 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

Health Tracker architecture diagram
Health Tracker architecture diagram

The system has two paths. The synchronous path handles user requests, where the Angular frontend calls the API Gateway, which invokes the API Lambda, which reads and writes to MongoDB. That part is straightforward.

The asynchronous path is where it gets interesting. When something notable happens in the API, a user registering or a goal being achieved or a group invitation going out, the API publishes a message to an SNS topic and moves on. It doesn't send an email. It doesn't know that emails exist.

The SNS topic delivers messages to an SQS queue, and the queue triggers a second Lambda that reads the message, renders an HTML email from a Liquid template and sends it through SES. If that Lambda fails, the message stays in the queue and gets retried, the API never knows, and the user's request was never blocked in the first place.

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 and publishes overdue notifications to the same SNS topic, where the notification Lambda picks them up like any other event.

Three functions, one SNS topic, one queue, and the event types distinguished by their subject line rather than 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 and not to decide what to do about it.

When a user logs a weight measurement that happens to hit a goal target, the API saves the measurement and marks the goal complete, then publishes a GoalAchieved event and returns the response. The API is done.

The notification Lambda picks up that event independently and decides what to do about it. It checks whether the user has verified their email, and if they have it renders a congratulations email with a suggested next goal, the target incremented by a sensible amount. If they haven't 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 is what keeps the API controller code clean. The registration endpoint publishes a Registered event and contains no email rendering logic. The goal completion code publishes GoalAchieved and knows nothing 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, and 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, meaning a weigh-in, the database service saves the record and then 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, and 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 rendered and sent an email asynchronously and separately. 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 and calculates the calories burned automatically:

const caloriesBurned = exerciseType.mET * 3.5 * lastHealthMetric.weight * exerciseDocument.duration / 200;

The user logs "30 minutes of running" and the system returns the exercise record with calories already worked out, nothing entered by hand. It's the standard MET formula, which is a population-average estimate rather than a measurement of what this person actually burned, but it's the same estimate every fitness tracker is quoting you.

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, and the notification Lambda handles the rest.

What makes that work is the internal API key. The three Lambdas share a secret generated by CDK and stored in Secrets Manager, and the API authenticates requests 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 the frontend calls, with elevated access.

This keeps the overdue-checking logic in the API where it belongs and leaves the scheduled Lambda as nothing more than 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, and 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 and independent progress, and a groupGoalLink ties them together so the UI can show group-wide progress. From the database service's perspective group goals then work identically to personal goals. The auto-completion logic needs no special case for them. 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 tracking multiple users' progress, which 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, and 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, and the infrastructure references between them, the API Gateway URL passed to the overdue Lambda and the SNS ARN passed to the API, are wired through CDK constructs rather than 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, so a bug in the notification template in dev can't send emails to prod users. The stages share nothing except the pipeline that deploys them.

The three-Lambda split was never about scalability, since a monolith would have handled the load of a university project comfortably. It was about keeping each concern in its own box.

The API doesn't know how emails are sent. The notification Lambda doesn't know how goals are tracked. The scheduled Lambda doesn't 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 plus SQS, is the contract between them, and as long as the message format stays stable any of the three can be rewritten, redeployed or replaced independently. That's the practical benefit here, being able to change one part of the system without coordinating with every other part.