Skip to main content

Instagram Clone

A complete example of an Instagram clone with users, photos, likes, comments, and follow relationships using Dynatable and Single Table Design.

Entity-Relationship Diagram (ER)

Logical model. Physical partition/sort keys (e.g. USER#${username}, PHOTO#${photoId}) live in the Key Structure table below.

FOLLOW is the join entity for the N:M User ↔ User relationship: followedUsername and followingUsername are both PK+FK to USER. Its GSI1 swaps the two columns so you can query both "who follows X" (primary index) and "who X follows" (GSI1).

Single Table Design

Key Structure

Entity TypePKSKGSI1PKGSI1SK
UserUSER#{username}USER#{username}--
PhotoUP#{username}PHOTO#{photoId}--
StoryUP#{username}STORY#{storyId}--
LikePL#{photoId}LIKE#{likingUsername}PL#{photoId}LIKE#{likeId}
CommentPC#{photoId}COMMENT#{commentId}--
FollowFOLLOW#{followedUsername}FOLLOW#{followingUsername}FOLLOW#{followingUsername}FOLLOW#{followedUsername}

The GSI in this example is named gsi1, with column names GSI1PK and GSI1SK.

Access Patterns

  1. User

    • Get user by username: GetItem with PK=USER#{username}, SK=USER#{username}
    • Create unique user: PutItem with attribute_not_exists condition
  2. Photos

    • Create photo: PutItem with PK=UP#{username}, SK=PHOTO#{photoId}
    • Get photo: GetItem with PK=UP#{username}, SK=PHOTO#{photoId}
    • List user photos: Query with PK=UP#{username}, SK begins_with "PHOTO#"
  3. Stories

    • Create story: PutItem with PK=UP#{username}, SK=STORY#{storyId}
    • Get story: GetItem with PK=UP#{username}, SK=STORY#{storyId}
    • List user stories: Query with PK=UP#{username}, SK begins_with "STORY#"
  4. Likes

    • Like a photo: TransactWrite - Put Like + Update Photo.likesCount
    • Unlike photo: TransactWrite - Delete Like + Update Photo.likesCount
    • List likes (chronological): Query gsi1 with GSI1PK=PL#{photoId} (re-sorted by likeId ULID)
    • Check if user liked: GetItem with PK=PL#{photoId}, SK=LIKE#{likingUsername}
  5. Comments

    • Comment on photo: TransactWrite - Put Comment + Update Photo.commentCount
    • List comments: Query with PK=PC#{photoId}, SK begins_with "COMMENT#"
    • Delete comment: TransactWrite - Delete Comment + Update Photo.commentCount
  6. Follow

    • Follow user: TransactWrite - Put Follow + Update followerCount + Update followingCount
    • Unfollow user: TransactWrite - Delete Follow + Update followerCount + Update followingCount
    • List followers: Query with PK=FOLLOW#{followedUsername}, then BatchGetItem for user details
    • List following: Query gsi1 with GSI1PK=FOLLOW#{followingUsername}, then BatchGetItem for details

Schema Definition

import { Table, type InferModelFromSchema, type ArrayItem } from '@ftschopp/dynatable-core';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

const InstagramSchema = {
format: 'dynatable:1.0.0',
version: '1.0.0',

indexes: {
primary: { hash: 'PK', sort: 'SK' },
gsi1: { hash: 'GSI1PK', sort: 'GSI1SK' },
},

models: {
User: {
key: {
PK: { type: String, value: 'USER#${username}' },
SK: { type: String, value: 'USER#${username}' },
},
attributes: {
username: { type: String, required: true },
name: { type: String, required: true },
followerCount: { type: Number, default: 0 },
followingCount: { type: Number, default: 0 },
},
},

Photo: {
key: {
PK: { type: String, value: 'UP#${username}' },
SK: { type: String, value: 'PHOTO#${photoId}' },
},
attributes: {
username: { type: String, required: true },
photoId: { type: String, generate: 'ulid' },
url: { type: String, required: true },
caption: { type: String },
likesCount: { type: Number, default: 0 },
commentCount: { type: Number, default: 0 },
},
},

// Stories — demonstrates nested Object schema and typed Array items
Story: {
key: {
PK: { type: String, value: 'UP#${username}' },
SK: { type: String, value: 'STORY#${storyId}' },
},
attributes: {
username: { type: String, required: true },
storyId: { type: String, generate: 'ulid' },
viewCount: { type: Number, default: 0 },
// Typed array of frame objects
frames: {
type: Array,
default: [],
items: {
type: Object,
schema: {
url: { type: String, required: true },
duration: { type: Number },
mediaType: { type: String },
},
},
},
// Typed nested object
location: {
type: Object,
schema: {
city: { type: String },
country: { type: String },
lat: { type: Number },
lng: { type: Number },
},
},
},
},

Like: {
key: {
PK: { type: String, value: 'PL#${photoId}' },
SK: { type: String, value: 'LIKE#${likingUsername}' },
},
index: {
GSI1PK: { type: String, value: 'PL#${photoId}' },
GSI1SK: { type: String, value: 'LIKE#${likeId}' },
},
attributes: {
photoId: { type: String, required: true },
likingUsername: { type: String, required: true },
likeId: { type: String, generate: 'ulid', required: true },
},
},

Comment: {
key: {
PK: { type: String, value: 'PC#${photoId}' },
SK: { type: String, value: 'COMMENT#${commentId}' },
},
attributes: {
photoId: { type: String, required: true },
commentId: { type: String, generate: 'ulid', required: true },
commentingUsername: { type: String, required: true },
content: { type: String, required: true },
},
},

Follow: {
key: {
PK: { type: String, value: 'FOLLOW#${followedUsername}' },
SK: { type: String, value: 'FOLLOW#${followingUsername}' },
},
index: {
GSI1PK: { type: String, value: 'FOLLOW#${followingUsername}' },
GSI1SK: { type: String, value: 'FOLLOW#${followedUsername}' },
},
attributes: {
followedUsername: { type: String, required: true },
followingUsername: { type: String, required: true },
},
},
},

params: {
timestamps: true,
cleanInternalKeys: true, // strip PK/SK/_type from returned items
},
} as const;

export const table = new Table({
name: 'InstagramClone',
client: new DynamoDBClient({ region: 'us-east-1' }),
schema: InstagramSchema,
});

// Entity types inferred from the schema
type StoryEntity = InferModelFromSchema<typeof InstagramSchema, 'Story'>;

// Extract the frame item type using ArrayItem
type StoryFrame = ArrayItem<StoryEntity['frames']>;
// → { url: string; duration?: number; mediaType?: string }

User Operations

Create User

async function createUser(username: string, name: string) {
return await table.entities.User.put({
username,
name,
})
.ifNotExists()
.execute();
}

// Usage
await createUser('juanca', 'Juan Carlos Bondi');

Get User Profile

async function getUserProfile(username: string) {
return await table.entities.User.get({
username,
}).execute();
}

// Usage
const user = await getUserProfile('juanca');
console.log(user?.name, user?.followerCount); // user is User | undefined

Update User Profile

async function updateUserProfile(username: string, updates: { name?: string }) {
// DynamoDB rejects an UpdateItem with no UpdateExpression, so bail out
// when there's nothing to update.
if (!updates.name) return;

return await table.entities.User
.update({ username })
.set('name', updates.name)
.returning('ALL_NEW')
.execute();
}

// Usage
await updateUserProfile('juanca', {
name: 'Juan Carlos Bondi - Photographer',
});

Photo Operations

Create Photo

async function createPhoto(username: string, url: string, caption?: string) {
return await table.entities.Photo.put({
username,
url,
caption,
}).execute();
}

// Usage
const photo = await createPhoto(
'juanca',
'https://photos.app.goo.gl/abcd1234',
'Sunset at the beach!'
);

console.log(photo.photoId); // Auto-generated ULID

Get Photo

async function getPhoto(username: string, photoId: string) {
return await table.entities.Photo.get({
username,
photoId,
}).execute();
}

// Usage
const photo = await getPhoto('juanca', '01K16ZP43BRX67DG50SHGZ11DS');
console.log(photo?.url, photo?.likesCount); // photo is Photo | undefined

List User Photos

async function getUserPhotos(username: string, limit: number = 20) {
return await table.entities.Photo.query()
.where((attr, op) => op.eq(attr.username, username))
.limit(limit)
.scanIndexForward(false) // Most recent first
.execute();
}

// Usage
const photos = await getUserPhotos('juanca', 12);
photos.forEach((photo) => {
console.log(photo.url, photo.caption, photo.createdAt);
});
caution

likesCount > minLikes is not part of the key — DynamoDB applies it as a FilterExpression after reading every photo in the partition. RCUs are billed on the rows read, not the rows returned. For users with many photos you'll want to denormalize popular photos into a separate GSI or item collection.

async function getPopularUserPhotos(username: string, minLikes: number = 10) {
return await table.entities.Photo.query()
.where((attr, op) => op.and(op.eq(attr.username, username), op.gt(attr.likesCount, minLikes)))
.scanIndexForward(false)
.execute();
}

// Usage
const popularPhotos = await getPopularUserPhotos('juanca', 50);

Delete Photo

async function deletePhoto(username: string, photoId: string) {
return await table.entities.Photo.delete({
username,
photoId,
}).execute();
}

Like Operations

Like a Photo

async function likePhoto(photoId: string, photoOwner: string, likingUsername: string) {
// Use transaction for atomicity
return await table
.transactWrite()
.addPut(
table.entities.Like.put({
photoId,
likingUsername,
})
.ifNotExists()
.dbParams()
)
.addUpdate(
table.entities.Photo.update({
username: photoOwner,
photoId,
})
.add('likesCount', 1)
.dbParams()
)
.execute();
}

// Usage
await likePhoto('photo123', 'juanca', 'alice');

Unlike a Photo

async function unlikePhoto(photoId: string, photoOwner: string, likingUsername: string) {
return await table
.transactWrite()
.addDelete(
table.entities.Like.delete({
photoId,
likingUsername,
}).dbParams()
)
.addUpdate(
table.entities.Photo.update({
username: photoOwner,
photoId,
})
.add('likesCount', -1)
.dbParams()
)
.execute();
}

// Usage
await unlikePhoto('photo123', 'juanca', 'alice');

Check if User Liked Photo

async function hasUserLikedPhoto(photoId: string, username: string): Promise<boolean> {
// .get() returns undefined when the item doesn't exist (it does not throw),
// so a !!like check is enough — wrapping in try/catch would swallow real errors.
const like = await table.entities.Like.get({
photoId,
likingUsername: username,
}).execute();

return !!like;
}

// Usage
const hasLiked = await hasUserLikedPhoto('photo123', 'alice');
console.log(hasLiked ? 'Already liked' : 'Not liked yet');

List Users Who Liked (Chronological)

Like reuses its primary PK as GSI1PK, but swaps the SK from likingUsername to a generated likeId (ULID). That re-sort gives a chronological view of the likes — querying the primary index would order them alphabetically by likingUsername instead.

async function getPhotoLikers(photoId: string, limit: number = 50) {
// Use GSI1 to sort by likeId (ULID = timestamp)
const likes = await table.entities.Like.query()
.where((attr, op) => op.eq(attr.photoId, photoId))
.useIndex('gsi1')
.limit(limit)
.scanIndexForward(true) // Oldest first
.execute();

if (likes.length === 0) return [];

return await table.entities.User.batchGet(
likes.map((like) => ({ username: like.likingUsername }))
).execute();
}

// Usage
const likers = await getPhotoLikers('photo123', 20);
likers.forEach((user) => {
console.log(user.username, user.name);
});

Comment Operations

Comment on Photo

async function commentOnPhoto(
photoId: string,
photoOwner: string,
commentingUsername: string,
content: string
) {
return await table
.transactWrite()
.addPut(
table.entities.Comment.put({
photoId,
commentingUsername,
content,
}).dbParams()
)
.addUpdate(
table.entities.Photo.update({
username: photoOwner,
photoId,
})
.add('commentCount', 1)
.dbParams()
)
.execute();
}

// Usage
await commentOnPhoto('photo123', 'juanca', 'alice', 'Amazing photo! Love the colors!');

List Photo Comments

async function getPhotoComments(photoId: string, limit: number = 50) {
const comments = await table.entities.Comment.query()
.where((attr, op) => op.eq(attr.photoId, photoId))
.limit(limit)
.scanIndexForward(true) // Oldest first
.execute();

// Optionally get user details
if (comments.length > 0) {
const users = await table.entities.User.batchGet(
comments.map((c) => ({ username: c.commentingUsername }))
).execute();

// Combine comments with user data
return comments.map((comment) => ({
...comment,
user: users.find((u) => u.username === comment.commentingUsername),
}));
}

return [];
}

// Usage
const comments = await getPhotoComments('photo123', 20);
comments.forEach((comment) => {
console.log(`${comment.user?.name}: ${comment.content}`);
});

Delete Comment

async function deleteComment(photoId: string, commentId: string, photoOwner: string) {
return await table
.transactWrite()
.addDelete(
table.entities.Comment.delete({
photoId,
commentId,
}).dbParams()
)
.addUpdate(
table.entities.Photo.update({
username: photoOwner,
photoId,
})
.add('commentCount', -1)
.dbParams()
)
.execute();
}

Update Comment

note

On update() builders, .where(...) builds a ConditionExpression — the update only happens if the condition is satisfied (here: only the original author can edit). On query() builders, .where(...) defines key conditions and filters. Same name, different role.

async function updateComment(
photoId: string,
commentId: string,
commentingUsername: string,
newContent: string
) {
return await table.entities.Comment.update({
photoId,
commentId,
})
.set('content', newContent)
.where((attr, op) => op.eq(attr.commentingUsername, commentingUsername))
.returning('ALL_NEW')
.execute();
}

// Usage
await updateComment(
'photo123',
'comment456',
'alice',
'Updated: Amazing photo! Love the composition!'
);

Follow Operations

Follow User

async function followUser(followingUsername: string, followedUsername: string) {
return await table
.transactWrite()
.addPut(
table.entities.Follow.put({
followingUsername,
followedUsername,
})
.ifNotExists()
.dbParams()
)
.addUpdate(
table.entities.User.update({
username: followedUsername,
})
.add('followerCount', 1)
.dbParams()
)
.addUpdate(
table.entities.User.update({
username: followingUsername,
})
.add('followingCount', 1)
.dbParams()
)
.execute();
}

// Usage: Alice follows Juan Carlos
await followUser('alice', 'juanca');

Unfollow User

async function unfollowUser(followingUsername: string, followedUsername: string) {
return await table
.transactWrite()
.addDelete(
table.entities.Follow.delete({
followingUsername,
followedUsername,
}).dbParams()
)
.addUpdate(
table.entities.User.update({
username: followedUsername,
})
.add('followerCount', -1)
.dbParams()
)
.addUpdate(
table.entities.User.update({
username: followingUsername,
})
.add('followingCount', -1)
.dbParams()
)
.execute();
}

// Usage
await unfollowUser('alice', 'juanca');

List User Followers

async function getFollowers(username: string, limit: number = 50) {
// Query to get follow relationships
const follows = await table.entities.Follow.query()
.where((attr, op) => op.eq(attr.followedUsername, username))
.limit(limit)
.execute();

if (follows.length === 0) return [];

return await table.entities.User.batchGet(
follows.map((f) => ({ username: f.followingUsername }))
).execute();
}

// Usage
const followers = await getFollowers('juanca', 20);
console.log(`${followers.length} followers`);
followers.forEach((user) => {
console.log(user.username, user.name);
});

List Following Users

async function getFollowing(username: string, limit: number = 50) {
// Query on GSI1 to get followed users
const follows = await table.entities.Follow.query()
.where((attr, op) => op.eq(attr.followingUsername, username))
.useIndex('gsi1')
.limit(limit)
.execute();

if (follows.length === 0) return [];

return await table.entities.User.batchGet(
follows.map((f) => ({ username: f.followedUsername }))
).execute();
}

// Usage
const following = await getFollowing('alice', 20);
console.log(`Following ${following.length} users`);

Check if User Follows Another

async function isFollowing(followingUsername: string, followedUsername: string): Promise<boolean> {
// .get() returns undefined when the relation doesn't exist; no try/catch needed.
const follow = await table.entities.Follow.get({
followingUsername,
followedUsername,
}).execute();

return !!follow;
}

// Usage
const following = await isFollowing('alice', 'juanca');
console.log(following ? 'Following' : 'Not following');

Story Operations

Create a Story

async function createStory(username: string, frames: StoryFrame[], city?: string) {
return await table.entities.Story.put({
username,
frames,
...(city ? { location: { city } } : {}),
}).execute();
}

// Usage
const story = await createStory('juanca', [
{ url: 'https://cdn.example.com/clip1.mp4', duration: 5, mediaType: 'video' },
{ url: 'https://cdn.example.com/img1.jpg', duration: 3, mediaType: 'image' },
]);

console.log(story.storyId); // Auto-generated ULID
console.log(story.frames[0]?.url); // Fully typed; frames[] could be empty

List User Stories

async function getUserStories(username: string) {
return await table.entities.Story.query()
.where((attr, op) => op.eq(attr.username, username))
.scanIndexForward(false) // Most recent first
.execute();
}

// Usage
const stories = await getUserStories('juanca');
stories.forEach((story) => {
console.log(`Story ${story.storyId}: ${story.frames.length} frame(s)`);
});

Increment View Count

async function incrementStoryViews(username: string, storyId: string) {
return await table.entities.Story.update({ username, storyId })
.add('viewCount', 1)
.returning('ALL_NEW')
.execute();
}

Complete Workflows

User Feed (Photos from Followed Users)

async function getUserFeed(username: string, limit: number = 30) {
// 1. Get followed users
const following = await getFollowing(username, 100);

if (following.length === 0) return [];

// 2. Get photos from each followed user
const photoPromises = following.map((user) => getUserPhotos(user.username, 10));

const photosByUser = await Promise.all(photoPromises);

// 3. Combine and sort by date (toSorted returns a fresh array, leaving inputs untouched)
const allPhotos = photosByUser
.flat()
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());

// 4. Return first N photos
return allPhotos.slice(0, limit);
}

// Usage
const feed = await getUserFeed('alice', 20);
feed.forEach((photo) => {
console.log(`${photo.username}: ${photo.caption}`);
});

Get Photo with Full Details

async function getPhotoWithDetails(username: string, photoId: string) {
const [photo, comments, likers] = await Promise.all([
getPhoto(username, photoId),
getPhotoComments(photoId, 20),
getPhotoLikers(photoId, 20),
]);

if (!photo) return undefined;

return {
...photo,
comments: comments.slice(0, 3), // First 3 comments
commentCount: photo.commentCount,
recentLikers: likers.slice(0, 5), // First 5 who liked
totalLikes: photo.likesCount,
};
}

// Usage
const photoDetails = await getPhotoWithDetails('juanca', 'photo123');
if (photoDetails) {
console.log(photoDetails.caption);
console.log(`${photoDetails.totalLikes} likes, ${photoDetails.commentCount} comments`);
}

Complete User Profile

async function getCompleteUserProfile(username: string) {
const [user, photos, followers, following] = await Promise.all([
getUserProfile(username),
getUserPhotos(username, 12), // Last 12 photos
getFollowers(username, 100),
getFollowing(username, 100),
]);

if (!user) return undefined;

return {
...user,
photos,
photoCount: photos.length,
followers,
following,
};
}

// Usage
const profile = await getCompleteUserProfile('juanca');
if (profile) {
console.log(`${profile.name} (@${profile.username})`);
console.log(`${profile.followerCount} followers, ${profile.followingCount} following`);
console.log(`${profile.photoCount} photos`);
}

Search User Photos

async function searchUserPhotos(username: string, minLikes?: number, minComments?: number) {
return await table.entities.Photo.query()
.where((attr, op) => {
const conditions = [
op.eq(attr.username, username),
...(minLikes !== undefined ? [op.gt(attr.likesCount, minLikes)] : []),
...(minComments !== undefined ? [op.gt(attr.commentCount, minComments)] : []),
];
return conditions.length > 1 ? op.and(...conditions) : conditions[0]!;
})
.scanIndexForward(false)
.execute();
}

// Usage: Search photos with more than 100 likes
const popularPhotos = await searchUserPhotos('juanca', 100);

Pagination

User Photos with Pagination

async function getUserPhotosPaginated(username: string, limit: number = 20, lastKey?: any) {
const base = table.entities.Photo.query()
.where((attr, op) => op.eq(attr.username, username))
.limit(limit)
.scanIndexForward(false);

return await (lastKey ? base.startFrom(lastKey) : base).executeWithPagination();
}

// Usage - first page
const page1 = await getUserPhotosPaginated('juanca', 12);
console.log(page1.items);

// Next page
if (page1.lastEvaluatedKey) {
const page2 = await getUserPhotosPaginated('juanca', 12, page1.lastEvaluatedKey);
console.log(page2.items);
}

Comments with Pagination

async function getPhotoCommentsPaginated(photoId: string, limit: number = 20, lastKey?: any) {
const base = table.entities.Comment.query()
.where((attr, op) => op.eq(attr.photoId, photoId))
.limit(limit)
.scanIndexForward(true);

const result = await (lastKey ? base.startFrom(lastKey) : base).executeWithPagination();

// Get user details for this page
if (result.items.length > 0) {
const users = await table.entities.User.batchGet(
result.items.map((c) => ({ username: c.commentingUsername }))
).execute();

const commentsWithUsers = result.items.map((comment) => ({
...comment,
user: users.find((u) => u.username === comment.commentingUsername),
}));

return {
items: commentsWithUsers,
lastEvaluatedKey: result.lastEvaluatedKey,
};
}

return result;
}

Error Handling

async function likePhotoSafely(photoId: string, photoOwner: string, likingUsername: string) {
try {
await likePhoto(photoId, photoOwner, likingUsername);
return { success: true };
} catch (error: any) {
if (error.name === 'TransactionCanceledException') {
// Already liked before (AWS SDK v3 surfaces the error class via `name`)
return {
success: false,
error: 'Already liked this photo',
};
}

console.error('Failed to like photo:', error);
return {
success: false,
error: error.message,
};
}
}

async function createPhotoSafely(username: string, url: string, caption?: string) {
try {
const photo = await createPhoto(username, url, caption);
return { success: true, photo };
} catch (error: any) {
console.error('Failed to create photo:', error);
return {
success: false,
error: error.message,
};
}
}

Testing

import { describe, it, expect, beforeAll } from '@jest/globals';

describe('Instagram Clone', () => {
beforeAll(async () => {
// Setup test data
await createUser('testuser1', 'Test User 1');
await createUser('testuser2', 'Test User 2');
});

it('should create a photo', async () => {
const photo = await createPhoto('testuser1', 'https://example.com/photo.jpg', 'Test caption');

expect(photo.username).toBe('testuser1');
expect(photo.photoId).toBeDefined();
expect(photo.caption).toBe('Test caption');
});

it('should allow user to follow another user', async () => {
await followUser('testuser2', 'testuser1');

const user1 = await getUserProfile('testuser1');
const user2 = await getUserProfile('testuser2');

expect(user1?.followerCount).toBe(1);
expect(user2?.followingCount).toBe(1);
});

it('should like a photo', async () => {
const photo = await createPhoto('testuser1', 'https://example.com/photo2.jpg');

await likePhoto(photo.photoId, 'testuser1', 'testuser2');

const updatedPhoto = await getPhoto('testuser1', photo.photoId);
expect(updatedPhoto?.likesCount).toBe(1);
});

it('should add comment to photo', async () => {
const photo = await createPhoto('testuser1', 'https://example.com/photo3.jpg');

await commentOnPhoto(photo.photoId, 'testuser1', 'testuser2', 'Great photo!');

const comments = await getPhotoComments(photo.photoId);
expect(comments.length).toBe(1);
expect(comments[0].content).toBe('Great photo!');
});
});

Features Demonstrated

This example demonstrates:

  • Single Table Design - All entities in one table
  • 1:N Relationships - User → Photos, Photo → Comments
  • N:M Relationships - Users ↔ Followers (Follow)
  • GSI Usage - Alternative access patterns
  • Atomic Transactions - Like, Follow with counters
  • Auto-generated IDs - ULID for photos, likes, comments
  • Automatic Timestamps - createdAt, updatedAt
  • Pagination - For long lists
  • BatchGet - Efficiently fetch multiple users
  • Type Safety - TypeScript end-to-end
  • Conditions - ifNotExists, where conditions
  • Atomic Counters - followerCount, likesCount, commentCount
  • Nested Objects - Typed location object with schema
  • Typed Arrays - frames array with typed item schema
  • ArrayItem utility - Extract frame item type with ArrayItem<T>

Single Table Design Benefits

  1. Performance: Fewer round-trips to the database
  2. Transactions: Atomic operations between related entities
  3. Cost: Lower RCU/WCU consumption
  4. Scalability: Uniform load distribution
  5. Maintenance: Single table to manage

Next Steps

To enhance this example, consider:

  • Add story expiration (TTL attribute)
  • Add Direct Messages between users
  • Implement hashtags for photos
  • Add notifications
  • Implement user search
  • Add saved posts (bookmarked photos)

References