Introduction to Streaks
A streak is a consecutive count of days (or actions) a user performs a specific activity without breaking the chain. Streaks are commonly used in:
- Habit-tracking apps (e.g., Duolingo, Headspace)
- Gaming (daily login rewards, consecutive wins)
- Fitness apps (workout consistency)
- E-learning platforms (daily learning goals)
How AWS Helps Implement Streaks
AWS provides serverless and scalable solutions to track streaks efficiently:
- AWS Lambda → Runs streak logic (increment, reset, reward checks) DynamoDB → Stores user streak data (last activity, current streak count)
- API Gateway → Exposes APIs for frontend (web/mobile apps)
- Amazon Cognito (Optional) → Handles user authentication
- AWS CDK → Easy Deployment
Use Cases for Streaks & Implementation Steps
1. Daily Login Streaks (Gaming/Fitness Apps)
Goal: Reward users for logging in daily.
Implementation Steps:
1. Set Up DynamoDB Table
- Table: UserStreak
- Partition Key: userId (String)
- Sort Key: streakType
- Attributes: currentStreak, lastLogin, longestStreak
2. Create streakTrack Lambda Function
- Checks if the user logged in today → skip
- If logged in yesterday → increment streak
- If missed a day → reset streak
import { UpdateItemCommand, GetItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
import { ddbClient } from "./client";
const TABLE_NAME = process.env.STREAK_TABLE_NAME;
const MAX_FREEZE_DAYS = 2;
export const handler = async (event) => {
try {
const { userId } = JSON.parse(event.body);
if (!userId) {
return { statusCode: 400, body: JSON.stringify({ error: "userId is required" }) };
}
const today = new Date().toISOString().split("T")[0];
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split("T")[0];
// ✅ Get current streak and freeze days
const { currentStreak, lastLogin, freezeDaysRemaining } = await getUserData(userId);
// ✅ If already logged in today
if (lastLogin === today) {
return success({ message: "Already logged in today", currentStreak, freezeDaysRemaining });
}
let newStreak = 1;
let newFreeze = freezeDaysRemaining;
// ✅ Case 1: Consecutive login (yesterday)
if (lastLogin === yesterdayStr) {
newStreak = currentStreak + 1;
}
// ✅ Case 2: Missed days but has freeze days → use one
else if (freezeDaysRemaining > 0) {
newStreak = currentStreak; // keep streak intact
newFreeze = freezeDaysRemaining - 1; // use one freeze day
}
// ✅ Update DB
await updateUserData(userId, today, newStreak, newFreeze);
return success({
message: freezeDaysRemaining > 0 && lastLogin !== yesterdayStr ?
"Missed day covered by a freeze day" : "Streak updated",
currentStreak: newStreak,
freezeDaysRemaining: newFreeze
});
} catch (err) {
console.error("Error:", err);
return { statusCode: 500, body: JSON.stringify({ error: err.message }) };
}
};
// 🔹 Get user streak & freeze data
async function getUserData(userId) {
const { Item } = await ddbClient.send(new GetItemCommand({
TableName: TABLE_NAME,
Key: marshall({ userId, streakType: "daily" }), // using same PK as freeze
}));
if (!Item) return { currentStreak: 0, lastLogin: null, freezeDaysRemaining: 0 };
const data = unmarshall(Item);
return {
currentStreak: data.currentStreak || 0,
lastLogin: data.lastLogin || null,
freezeDaysRemaining: data.freezeDaysRemaining || 0
};
}
// 🔹 Update streak and freeze count
async function updateUserData(userId, today, newStreak, newFreeze) {
await ddbClient.send(new UpdateItemCommand({
TableName: TABLE_NAME,
Key: marshall({ userId, streakType: "daily" }),
UpdateExpression: "SET currentStreak = :cs, lastLogin = :dt, freezeDaysRemaining = :fd",
ExpressionAttributeValues: marshall({
":cs": newStreak,
":dt": today,
":fd": newFreeze
})
}));
}
// 🔹 Helper success response
function success(body) {
return {
statusCode: 200,
headers: { "Access-Control-Allow-Origin": "*" },
body: JSON.stringify(body)
};
}
3.Create streakFreeze Lambda Function
import { GetItemCommand, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
import { ddbClient } from "./client.js";
const STREAK_TABLE_NAME = process.env.STREAK_TABLE_NAME;
export const handler = async (event) => {
try {
const { userId } = await validateAndParseInput(event.body);
const { freezeDaysRemaining, itemExists } = await getCurrentFreezeDays(userId);
if (freezeDaysRemaining >= 2) {
return formatErrorResponse(400, "Maximum freeze days (2) already reached");
}
const updatedFreeze = await updateFreezeDays(userId, freezeDaysRemaining, itemExists);
return {
statusCode: 200,
headers: { "Access-Control-Allow-Origin": "*" },
body: JSON.stringify({
status: "success",
freezeDaysRemaining: updatedFreeze
})
};
} catch (error) {
console.error("handler: ", error);
return formatErrorResponse(400, error.message);
}
};
async function validateAndParseInput(body) {
const payload = JSON.parse(body);
const { userId } = payload;
if (!userId) {
throw new Error("Missing required field: userId");
}
return { userId };
}
async function getCurrentFreezeDays(userId) {
const { Item } = await ddbClient.send(new GetItemCommand({
TableName: STREAK_TABLE_NAME,
Key: marshall({ userId, streakType: "daily" }),
ProjectionExpression: "freezeDaysRemaining"
}));
return {
freezeDaysRemaining: Item ? unmarshall(Item).freezeDaysRemaining || 0 : 0,
itemExists: !!Item
};
}
async function updateFreezeDays(userId, currentFreezeDays, itemExists) {
const updateParams = {
TableName: STREAK_TABLE_NAME,
Key: marshall({ userId, streakType: "daily" }),
UpdateExpression: "SET freezeDaysRemaining = :newVal",
ExpressionAttributeValues: marshall({ ":newVal": currentFreezeDays + 1 }),
ReturnValues: "ALL_NEW"
};
if (!itemExists) {
// For new records, set additional default values
updateParams.UpdateExpression = "SET freezeDaysRemaining = :newVal, currentStreak = :zero, longestStreak = :zero, lastActivity = :empty";
updateParams.ExpressionAttributeValues = marshall({
":newVal": 1,
":zero": 0,
":empty": ""
});
}
const { Attributes } = await ddbClient.send(new UpdateItemCommand(updateParams));
return unmarshall(Attributes).freezeDaysRemaining;
}
function formatErrorResponse(statusCode, message) {
return {
statusCode,
headers: { "Access-Control-Allow-Origin": "*" },
body: message
};
}
4. Set Up API Gateway
- POST /streak/track → Triggers Lambda
- POST /streak/freeze
5. Frontend Integration
- Call API when user logs in
- Display streak count
Example Explanation
Initial Conditions
- currentStreak = 3
- freezeDaysRemaining = 1
- lastLogin = 2025-07-28
✅ Case 1: User logs in on 2025–07–29 (yesterday was last login)
- Lambda receives event: { "userId": "1134" }
- It checks lastLogin === yesterday (2025-07-28) → ✅ yes.
- No freeze day is used.
- currentStreak = 4, freezeDaysRemaining = 1
- Response:
{
"message": "Streak updated",
"currentStreak": 4,
"freezeDaysRemaining": 1
}
✅ Case 2: User skips 2025–07–29, logs in on 2025–07–30
- Missed one day (2025–07–29)
- Lambda checks: lastLogin = 2025-07-28, today = 2025-07-30
- lastLogin !== yesterday, so normally streak would reset.
- But freezeDaysRemaining > 0 → ✅ use one freeze.
- currentStreak stays 3, freezeDaysRemaining = 0
- Response:
{
"message": "Missed day covered by a freeze day",
"currentStreak": 3,
"freezeDaysRemaining": 0
}
✅ Case 3: User skips 2025–07–31, logs in on 2025–08–01
- Missed two consecutive days and has no freeze left
- Lambda checks: lastLogin = 2025-07-28, today = 2025-08-01
- lastLogin !== yesterday, and freezeDaysRemaining = 0
- No freeze day available → streak resets to 1
- currentStreak = 1, freezeDaysRemaining = 0
- Response:
{
"message": "Streak updated",
"currentStreak": 1,
"freezeDaysRemaining": 0
}
✅ Case 4: User later earns a freeze day (via freeze API)
- User calls /streak/freeze with { "userId": "1134", "action": "add" }
- Freeze Lambda increments freezeDaysRemaining but caps it at 2.
{
"status": "success",
"freezeDaysRemaining": 1
}
✅ Case 5: User tries to manually use a freeze
- Calls /streak/freeze with { "userId": "1134", "action": "use" }
- Lambda checks: freezeDaysRemaining > 0 → ✅ yes, decreases by 1.
- If already 0, returns error:
{ "error": "No freeze days remaining" }
🔥 How This Works Together
1. Streak Lambda
- Auto-consumes freeze only when needed (user missed a day).
- Never lets streak reset unnecessarily if freeze is available.
2. Freeze Lambda
- Adds freeze days when rewarded.
- Allows manual usage (optional) if needed.
2. Consecutive Wins Streak (Gaming Leaderboards)
Goal: Track players’ winning streaks and reward top performers.
Implementation Steps:
1. DynamoDB Table
- UserStreak
- PK: userId
- Sort Key: streakType
- Attributes: currentWinStreak, maxWinStreak, lastWinDate
2. Lambda Function
- After a game ends, check if the player won
- Increment streak if last game was a win
- Reset if lost
import { GetItemCommand, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
import { ddbClient } from "./client.js";
const TABLE_NAME = process.env.STREAK_TABLE_NAME;
export const handler = async (event) => {
try {
const { userId, won } = JSON.parse(event.body);
if (!userId || won === undefined) {
return formatResponse(400, { error: "userId and won (true/false) are required" });
}
const today = new Date().toISOString().split("T")[0];
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split("T")[0];
// Get current game streak data
const { currentWinStreak, maxWinStreak, lastWinDate } = await getGameStreak(userId);
let newWinStreak = currentWinStreak;
let newMaxWinStreak = maxWinStreak;
if (won) {
// If last game was yesterday, continue streak, else reset to 1
newWinStreak = lastWinDate === yesterdayStr ? currentWinStreak + 1 : 1;
// Update max streak
if (newWinStreak > maxWinStreak) {
newMaxWinStreak = newWinStreak;
}
// Update DynamoDB
await updateGameStreak(userId, today, newWinStreak, newMaxWinStreak);
} else {
// Player lost → reset current streak
newWinStreak = 0;
await updateGameStreak(userId, today, newWinStreak, maxWinStreak);
}
return formatResponse(200, {
message: won ? "Game won streak updated" : "Game lost, streak reset",
currentWinStreak: newWinStreak,
maxWinStreak: newMaxWinStreak
});
} catch (err) {
console.error("Error updating game streak:", err);
return formatResponse(500, { error: err.message });
}
};
// 🔹 Get current streak from DynamoDB
async function getGameStreak(userId) {
const { Item } = await ddbClient.send(new GetItemCommand({
TableName: TABLE_NAME,
Key: marshall({ userId, streakType: "game" }),
ProjectionExpression: "currentWinStreak, maxWinStreak, lastWinDate"
}));
if (!Item) {
return { currentWinStreak: 0, maxWinStreak: 0, lastWinDate: null };
}
const data = unmarshall(Item);
return {
currentWinStreak: data.currentWinStreak || 0,
maxWinStreak: data.maxWinStreak || 0,
lastWinDate: data.lastWinDate || null
};
}
// 🔹 Update streak in DynamoDB
async function updateGameStreak(userId, today, currentWinStreak, maxWinStreak) {
await ddbClient.send(new UpdateItemCommand({
TableName: TABLE_NAME,
Key: marshall({ userId, streakType: "game" }),
UpdateExpression: "SET currentWinStreak = :cws, maxWinStreak = :mws, lastWinDate = :ld",
ExpressionAttributeValues: marshall({
":cws": currentWinStreak,
":mws": maxWinStreak,
":ld": today
}),
ReturnValues: "UPDATED_NEW"
}));
}
// 🔹 Helper response formatter
function formatResponse(statusCode, body) {
return {
statusCode,
headers: { "Access-Control-Allow-Origin": "*" },
body: JSON.stringify(body)
};
}
✅ Example Flow
🟢 Case 1: User wins consecutive games
- lastWinDate: 2025–07–30
- today: 2025–07–31
- Result: currentWinStreak = 3, maxWinStreak = 3
🔴 Case 2: User loses
- won: false
- Result: currentWinStreak = 0, maxWinStreak stays as it was.
Conclusion
Streaks are a powerful engagement tool, and AWS makes implementation easy:
✅ Serverless & Scalable (Lambda + DynamoDB)
✅ Real-Time Updates (API Gateway)
✅ Reward Integration (Lambda + DynamoDB)
✅ Cost-Effective (Pay-per-use pricing)
Next Steps:
- Start with a basic daily login streak
- Expand to game win streaks and habit tracking
- Add rewards & leaderboards for higher engagement
Advance on Streaks
1. Milestone Offers (Risk/Reward)
When users hit milestones (e.g., 7 days), give them a choice:
- Option A: Continue safely (streak grows normally)
- Option B: Gamble (“Break your streak now for 3x rewards!”)
2. Smart Streak Logic
- Tracks timezone-aware daily activity
- Handles edge cases (midnight checks, server delays)
3. Leaderboard Logic
- Add reward for higher in the leaderboard
For CDK Implementation — My Reposiotry
To stay informed on the latest technical insights and tutorials, connect with me on Medium, LinkedIn and Dev.to. For professional inquiries or technical discussions, please contact me via email. I welcome the opportunity to engage with fellow professionals and address any questions you may have.
Top comments (0)