Mobile System Design Interview (Desktop / Print Edition) Switch to Mobile PDF View → Word (.docx) ← Course Dashboard

Mobile System Design Interview

Offline-First & Client Architecture
ByteByteGo

Mobile System Design Interview

Offline-First & Client Architecture
By ByteByteGo
CHAPTER 01

Introduction

01

Mobile System Design (MSD) interviews have become a key part of the hiring process for mobile engineering roles at many companies. These interviews are typically brief, lasting no more than an hour, yet packed with broad, intentionally open-ended questions such as "Design Facebook", "Design a pagination library," or "Design YouTube". With such limited time and high expectations, it's crucial to ask the right questions to clarify requirements and focus on the areas that matter most.

Instructor

Manuel Vicente Vivo is a Staff Mobile Architect and seasoned Android engineer with experience at leading companies including Capital One, Google, and Bumble Inc. Beyond his technical expertise, Manuel is a dedicated mentor, accomplished public speaker, and prolific writer whose work has reached and inspired millions around the world.

This book is your practical companion whether you're a mobile engineer preparing for interviews, a technical lead sharpening your architectural thinking, or someone interested in mobile development. With real-world case studies, reusable frameworks, and implementation strategies, you'll learn how to approach complex design problems with clarity and confidence.

Image represents a whiteboard with three design problems listed, a clock indicating time constraints, and a person contemplating these problems.  The whiteboard stands on a small easel and displays three rectangular boxes stacked vertically.  The top box contains the text 'Design Facebook,' the middle box says 'Design a pagination library,' and the bottom box reads 'Design YouTube.' A simple analog clock is positioned to the left of the whiteboard, suggesting a time limit for solving these design challenges. To the right of the whiteboard, a cartoon figure of a person, a girl with dark hair tied in a ponytail, stands holding a pen or marker, seemingly considering the three design problems presented.  There is no explicit flow of information depicted; the image focuses on the presentation of the problems and the individual's contemplation of them.

Why companies ask MSD interview questions

MSD interviews fill an essential gap in the hiring process. While data structures and algorithms interviews assess coding proficiency, system design questions evaluate senior-level competencies that are harder to measure elsewhere. These interviews help calibrate your abilities across several critical dimensions:

  • Your ability to navigate ambiguity by defining requirements and scope.
  • How you approach and break down complex and ambiguous design challenges.
  • Strong problem-solving and technical knowledge.
  • Your insight into different options or trade-offs of designing complex systems.
  • Your communication and collaboration skills within a team environment.

MSD interviews intentionally present scenarios with multiple valid approaches. While many companies value your thought process and reasoning abilities above specific solutions, others may expect solutions that closely follow established industry patterns or align with their internal architecture standards. What consistently matters across all interviews is demonstrating a structured approach to problem-solving, making deliberate design choices, and clearly articulating the trade-offs behind your decisions. The strongest candidates show both technical depth and the adaptability to refine their solutions based on requirements and feedback.

Unlike coding interviews, MSD interviews rarely involve writing actual code. You might occasionally be asked to sketch pseudocode or represent data exchanges between components, but the focus remains on architecture and design decisions. With this unique interview format in mind, let's examine what interviewers are looking for at different seniority levels.

Rubric leveling explained

From an interviewer's perspective, expectations vary significantly across different seniority levels. While each company establishes its own evaluation criteria and emphasizes different aspects of system design, certain general expectations apply across the industry. Let's explore what interviewers typically look for at each level.

Image represents a stylized illustration depicting career progression. A cartoon character with wings, resembling an angel, is shown flying above a staircase representing different career levels. The staircase starts with 'Entry-Level' at the bottom, ascending to 'Mid-Level,' then 'Senior,' and finally 'Staff+' at the top. The character is holding and reading an open book with the logo 'ByteByteGo' visible on its cover, suggesting the book is a learning resource. The character's upward flight over the staircase implies advancement through these levels, with the book symbolizing the knowledge and skills acquired during the career journey.  The overall image suggests that learning and skill development ('ByteByteGo' book) are key to climbing the career ladder, from entry-level to staff and beyond.

It's important to note that the level definitions we're discussing are mainly based on β€œBig Tech” companies such as Google, Meta, Amazon, and Microsoft, where engineering ladders are clearly structured with multiple levels. In contrast, smaller companies and startups often have more compressed leveling systems and titles.

πŸ“ Note:

MSD interviews are rarely part of junior-level hiring processes. These interviews are designed to assess advanced capabilities that typically develop with experience, making them less effective indicators for early-career engineers.

Mid-level

Mid-level candidates should demonstrate foundational mobile system design knowledge and take initiative during the interview process. They're expected to:

  • Establish a clear understanding of the problem by asking relevant questions about requirements and constraints.
  • Create a coherent high-level design with logically connected components.
  • Identify common mobile patterns and appropriate technologies for the given problem.
  • Design basic API structures that support the required functionality.
  • Demonstrate awareness of basic state management approaches.
  • Recognize basic trade-offs in performance, security, and user experience.

Design challenges at this level typically focus on complete features or services of moderate complexity. Mid-level engineers may need occasional prompting to consider certain aspects of the system, which is completely normal. Interviewers expect this and will often guide the conversation toward important considerations when necessary.

Mid-level designs should demonstrate awareness of mobile-specific concerns such as network reliability, battery usage, and storage limitations. While solutions might not thoroughly address every aspect of the system, interviewers are primarily evaluating the ability to create a functional foundation with clear component relationships.

Senior

Senior candidates are expected to demonstrate greater independence and technical depth. They're expected to:

  • Drive the requirements-gathering phase with minimal guidance.
  • Proactively identify critical aspects of the design without prompting.
  • Create a comprehensive high-level architecture that addresses major functional requirements.
  • Recognize and address non-functional requirements such as performance, security, and scalability.
  • Design well-defined components with clear API contracts and communication patterns.
  • Thoughtful state management across the application.
  • Make informed decisions about trade-offs and be able to justify their choices.

At this level, candidates may face the same design challenges as mid-level engineers but with greater expectations for depth and breadth. Senior engineers should naturally consider issues such as error handling, edge cases, and potential failure scenarios without explicit prompting.

Senior-level designs should show a strong understanding of mobile platform capabilities and constraints. Components should have well-defined roles with clear APIs, and the overall architecture should reflect mobile best practices. They should demonstrate awareness of how their design decisions impact user experience, battery life, data usage, and other mobile-specific concerns.

Staff+

Staff+ candidates are expected to demonstrate comprehensive leadership and technical mastery throughout the interview. They're expected to:

  • Conduct thorough and proactive requirements gathering with minimal guidance.
  • Drive the conversation confidently, navigating through ambiguity with ease.
  • Design systems that scale efficiently and demonstrate a deep understanding of system complexities and their implications.
  • Communicate with technical precision and fluency.
  • Make rapid, well-justified decisions that reflect extensive experience.
  • Anticipate future challenges and design for extensibility.
  • Demonstrate strategic thinking that considers business impact and product evolution.
  • Articulate risk assessment and mitigation strategies.

Staff+ candidates should identify and address challenging aspects without prompting, independently exploring advanced topics such as sophisticated caching strategies, offline-first architectures, cross-platform considerations, performance optimization, and failure recovery mechanisms.

Solutions at this level often involve sophisticated trade-offs between competing factors such as user experience, performance, and security. Staff+ engineers are expected to not only create effective designs but also demonstrate the strategic thinking that differentiates them in complex situations. Their designs should reveal a deep understanding of mobile architecture patterns, platform-specific optimizations, and the broader ecosystem in which mobile applications operate.

Interviewers may intentionally introduce constraints or edge cases to evaluate how staff+ candidates optimize under limitations. At this level, the ability to adapt quickly, recognize the implications of design choices across the entire system, and articulate clear reasoning behind decisions is essential.

How to use this book effectively

This book is designed to provide a comprehensive approach to MSD interviews, offering both a structured framework and detailed case studies. To get the most value from these resources, consider the following approach on how to navigate the content effectively.

Progressive structure

The book follows a deliberate progression in how concepts are introduced and explained. Early chapters devote significant attention to foundational architecture choices and client-backend communication patterns, establishing core principles that guide all mobile designs. As you advance through later chapters, this knowledge is assumed rather than repeated, allowing us to focus on new challenges specific to each use case.

Case studies with unique focus areas

Mobile system design is a challenging field with many technical aspects to explore. To address these effectively, we created a carefully planned map that assigns unique challenges to each chapter. This approach allows us to cover a wide range of topics essential for technical interviews.

ChapterTopics
Chapter 3: Design a News Feed AppREST APIs, pagination, offline capabilities, optimistic writes, native rendering vs. WebViews.
Chapter 4: Design a Chat AppWebSockets, database design, push notifications, server-controlled ID generation.
Chapter 5: Design a Stock Trading AppGraph data visualization, always-online scenario, WebViews, buffered UI updates.
Chapter 6: Design a Pagination LibraryLibrary design, generics, in-memory caching, API requests prioritization, modular design, versioning strategies.
Chapter 7: Design a Hotel Reservation AppReservation holds, time synchronization, payment processing, search autocomplete suggestions, data prefetching, full-text search (FTS) capabilities.
Chapter 8: Design the Google Drive AppFile management, storage strategies, block-level synchronization, resumable file uploads, version history management, data encryption.
Chapter 9: Design the YouTube AppHTTP field selection, media streaming, content prefetching, enhanced video UX.
Chapter 10: Mobile System Design Building BlocksArchitecture (common patterns, arch layers, model mapping, modularization, dependency injection, testing),
Data storage (types and file visibility),
Networking (REST, authentication, CDN, pagination),
Feature development (gradual releases, force upgrading, feature flags, observability, localization, privacy, CI/CD, accessibility, performance, push notifications, app size),
Device fragmentation (form factors, OS versions).

Table 1: Main topics covered in each chapter

Going beyond the interview

You'll quickly notice that each chapter contains significantly more detail than you could possibly cover in a one-hour interview. This is intentional. The comprehensive explanations help you understand not just what decisions to make, but why those decisions make sense given specific constraints and requirements. This deeper understanding enables you to:

  • Explain your reasoning confidently when an interviewer probes your design choices.
  • Adapt your approach when faced with unexpected requirements or constraints.
  • Recognize trade-offs and explain why certain compromises might be necessary.

Extending your learning

At the end of each case study, you'll find additional requirements or feature suggestions that weren't addressed in the main discussion. These extensions provide excellent practice opportunities to test your understanding by designing solutions to these requirements on your own before comparing your approach with the book's suggestions.

Embracing multiple valid options

Throughout this book, we present design decisions based on our best judgment given the requirements. You may disagree with some choices, and that's perfectly fine. Real-world mobile system design often has multiple valid solutions to the same problem.

If you prefer a different approach than what we present, practice explaining why your solution might work better in certain contexts while acknowledging its limitations. Use this book as a guide rather than a source of definitive answers. This mindset will develop the critical thinking skills needed for impressive interviews and effective real-world system design.

CHAPTER 02

A framework for Mobile SD interviews

02

Having a clear framework for Mobile System Design (MSD) interviews is more important than many realize. Without a clear structure, the interview can feel disorganized and challenging for both you and the interviewer. Unlike coding interviews with defined solutions, system design challenges invite multiple valid approaches, making a methodical strategy essential.

Below, we briefly outline a 5-step framework to give you a high-level understanding. Detailed explanations and practical applications of each step are explored through case studies in later chapters.

A 5-step framework for effective mobile system design interviews

Image represents a flowchart outlining a five-step process for mobile system design.  The process begins with step 1, 'Understand the problem and establish design scope,' a rectangular box labeled with a bold '1' in a circle.  A line connects this box to step 2, 'API design,' another rectangular box labeled with a bold '2' in a circle.  Step 2 is connected to step 3, 'High-level client architecture,' a rectangular box labeled with a bold '3' in a circle.  Step 3 is connected to step 4, 'Design deep dive,' a rectangular box labeled with a bold '4' in a circle. Finally, step 4 connects to step 5, 'Wrap-up,' a rectangular box labeled with a bold '5' in a circle. The flow is generally sequential, with the exception of the connection from step 3 back to step 4, suggesting an iterative refinement process between high-level architecture and the detailed design.  All boxes are light gray with dark gray borders and text inside.  The connections between the boxes are represented by simple lines with small circles indicating connection points.

Step 1: Understand the problem and establish design scope

MSD interviews typically start with broad questions such as "Design a News Feed app" or "Design a Pagination library." While these questions may seem straightforward, understanding exactly what you need to design is a crucial part of the interview.

Your first task is to clarify what's in and out of scope by asking targeted questions to define the boundaries of your design. The interviewer's answers will guide your technology choices and help you make informed trade-offs. This initial scoping sets the foundation for your solution, making each interview unique.

What kind of questions should you ask? Here's a list of different types of questions to help you get started:

  • What are we building? These help uncover both functional and non-functional requirements, as well as what is out of scope for the interview. For example, you could ask:

    • What features, screens, or critical user journeys are we designing?
    • Are we pre-fetching data? Are we including user authentication in the design?
  • For whom are we building? These help understand the scale, usage patterns, and how performant the solution should be. For example, you could ask:

    • How many daily active users (DAU) does the product have? What's the expected growth?
    • Are we building a minimum viable product (MVP) or a final product?
    • What's our target market? A specific region or worldwide? What's the typical usage scenario? At home or on the train?
  • What are the system's constraints? These clarify the system boundaries and technology choices. For example, you could ask:

    • Which mobile platforms are we targeting (e.g., iOS, Android, or both)?
    • Are there any existing systems or APIs the mobile app must integrate with?

While gathering requirements, you may find that some answers reveal information about other aspects of the system. Feel free to make reasonable assumptions based on these responses, just be sure to state them clearly so your interviewer can correct any misunderstandings.

The goal isn't to ask every possible question. Instead, focus your questions on what's most relevant for the specific problem at hand, using your judgment to determine which details are most important to clarify.

πŸ“Œ Remember: Your interviewer's answers to these questions will guide your technical decisions and trade-offs throughout the design process. Make sure to reference back to these requirements when explaining your choices later in the interview.

Step 2: API design

The API design step is where you establish the contract between our client apps and external dependencies. This step is crucial because it helps align your understanding with the interviewer's expectations and creates a solid foundation for the rest of your design.

When designing an app that needs to communicate with a backend, start by defining the appropriate communication protocols for your specific use cases and the endpoints involved. On the other hand, if you're designing a mobile library, focus on outlining its public APIs, including how host apps will initialize, configure, and interact with your library.

As you define the API, outline the data models that will power these interactions. Whether you're working with JSON payloads, Swift structs, Kotlin data classes, or even protocol buffers for efficiency, these models need to capture the data's structure and relationships clearly.

Step 3: High-level client architecture

After finishing the API design, it's time to create a high-level client architecture diagram that shows how these components work together.

This high-level diagram sets the stage for the design and shows exactly which components you need to fulfill our requirements. Most importantly, it helps you validate that you have a complete end-to-end flow that meets all the functionality our system needs. As you create the diagram, you should also identify interesting points that warrant deeper discussion in the deep dive step later.

Step 4: Design deep dive

In this step, work with your interviewer to identify bottlenecks and explore specific components in greater detail. Generally, you should aim for two to three strong deep-dive discussions, though this isn't a strict rule and can vary depending on the situation.

You should continue refining your client architecture during this step. Make sure to update your diagrams to reflect any new decisions or components you introduce.

Pay attention to your interviewer's reactions and questions. If they express interest in multiple topics, manage your time carefully to give each topic the attention it deserves while staying within the interview's time constraints.

Step 5: Wrap-up (optional)

If time allows, the final phase of your interview provides an opportunity to demonstrate your ability to think critically about your design and consider future improvements. Here's how to wrap up effectively:

  • Summarize key decisions. If your discussion involved multiple trade-offs or complex decisions, consider providing a brief recap. This helps solidify the key points of your design and ensures the interviewer understands your reasoning, particularly after a detailed technical discussion.
  • Showcase critical thinking. When asked about potential improvements to your design, resist the urge to defend it as perfect. Instead, thoughtfully discuss areas where your solution could evolve. This demonstrates self-awareness and analytical thinking, qualities that interviewers value highly.
  • Address edge cases. Take this opportunity to discuss any failure points or error cases you haven't covered earlier. This shows that you think holistically about system reliability and user experience, not just the happy path.
  • Consider future scale. Think about how your system would handle growth, whether that's supporting more users or accommodating a larger development team. This demonstrates that you design with the future in mind and understand both technical and organizational scaling challenges.

Time allocation for each step

Time management is key in MSD interviews. With only 45-60 minutes to work with and a lot of ground to cover, having a solid plan helps you stay on track. Here's how to break down a typical 45-minute interview:

Image represents a pie chart illustrating the time allocation for different stages of a mobile system design interview.  The chart is divided into five equal segments, each representing a phase of the interview.  A central circle acts as the pivot point.  Clockwise from the top, the segments are labeled: 'API Design 5-10min' (shaded grey), 'Architecture Design 10-15min' (white), 'Deep Dive 15-20min' (shaded grey), 'Wrap-up 0-5min' (white), and 'Understand Problem 5-10min' (white).  A thick black pointer indicates the current stage of the interview, currently pointing to the 'Understand Problem' segment, suggesting the interview is in its initial phase.  Each segment clearly displays the estimated time duration allocated to that specific phase.
  1. Understand the problem and establish design scope (5-10 minutes): Take time to fully understand the problem and define its scope.
  2. API design (5-10 minutes): Map out how your app will communicate with backend services and define the core data models.
  3. High-level client architecture (10-15 minutes): Outline the main components of your system and how they work together.
  4. Design deep dive (15-20 minutes): Focus on the most important technical challenges and implementation details.
  5. Wrap-up (0-5 minutes): Summarize key decisions and discuss potential improvements or trade-offs.

Remember that these are guidelines rather than strict rules. Be ready to adjust based on the specific problem and your interviewer's interests. The key is maintaining enough flexibility to explore important areas while ensuring you complete the full design within the time limit.

CHAPTER 03

News feed app

03

News feeds are the core feature of many popular social media platforms. From Facebook News Feed and X (formerly Twitter) Timeline to the Instagram Feed, these continuously updating streams of content keep users engaged by providing fresh updates from friends, family, and followed accounts, encouraging them to keep scrolling.

At its heart, a news feed is a continuously updating list of content items. This content typically includes status updates, photos, videos, links, etc. While each platform might brand their feed differently, the fundamental concept remains largely the same across applications. Most news feed systems share these key features:

  • A scrollable list of content items.
  • Detailed views for individual posts.
  • Endless scrolling functionality.
  • Common social interactions like likes, comments and shares.

While the core structure is often similar, specific content types and interviewer requirements can lead to varying technical approaches. In this chapter, we'll walk through the process of designing a news feed app, tackling the challenge step-by-step.

Image represents a simplified visual design of a mobile app's feed, likely a social media or content-sharing platform.  The design shows a vertically stacked list of four identical content items within a smartphone screen outline. Each item is enclosed in a dashed-line rectangle and contains a circular placeholder representing a user profile picture, a horizontal progress bar (likely indicating loading or progress), a heart icon (suggesting a 'like' function), an upward-pointing arrow icon (possibly indicating a 'share' or 'upload' function), and either an image icon (a picture of a sun behind a mountain) or a play button icon, depending on the content type.  No text labels or URLs are visible. The arrangement suggests a scrollable feed where each item displays user-generated content, potentially images or videos, along with interaction options like liking and sharing.  The items are visually identical except for the content type indicator (image or video icon), implying a consistent design pattern for different content types within the feed.

Step 1: Understand the problem and establish design scope

The first step in any system design interview is to gather information and clarify requirements. This helps us understand the features to support and overall scope of the project. Here's how a typical conversation between the candidate and an interviewer might unfold:

Candidate: Before diving into the mobile app specifics, I'd like to understand the broader ecosystem. Are we designing only for mobile, or should we consider other clients like web or wearables that might share the same backend?
Interviewer: Good question. While today's focus is on mobile, we should keep in mind that there will likely be other clients in the future.

Candidate: That's helpful, thanks. I'm thinking the app should support the following features: Users can view their feed, share and like posts, see post details, and create new content. The feed would implement infinite scrolling, loading more content as users reach the bottom. And posts are displayed in the order we receive them from the server. Is this aligned with what you're envisioning?
Interviewer: That's spot on.

Candidate: Great. Are we limiting posts to text only, or can users include media like photos and videos?
Interviewer: Let's make it more interesting. Users can include text and media attachments such as images and videos. Additionally, let's support rich text editing, so posts can have headers, bold text, italics, and so on.

Candidate: That adds a nice touch. What about real-time features? Are we implementing live updates for things like share counts and likes? Or push notifications for new posts from close friends?
Interviewer: For simplicity, let's exclude real-time updates and notifications in this design.

Candidate: Fair enough. Can you give me an idea of the scale we're working with? How many users are we expecting, and what's their geographical distribution?
Interviewer: We're aiming big: 500 million monthly active users, spread across the globe.

Candidate: That's a substantial scope. Given the global distribution and the fact that users will be accessing our app from regions with varying network reliability, should we include an offline mode for viewing previously loaded content? And what about pre-fetching content to speed up initial load times?
Interviewer: Offline mode is a yes, great idea. Let's skip pre-fetching for this exercise.

Candidate: Noted. Are we designing user authentication, or can we assume users are already logged in?
Interviewer: Assume they're authenticated within the system.

The conversation can continue covering other topics we might consider interesting and relevant to news feed apps. Through this back-and-forth, we can build a clear picture of what needs to be designed. Let's summarize what we need to build.

Requirements

Based on our discussion, we're designing a news feed system with the following functional requirements:

  • Users can browse a feed of posts from others.
  • Users can like and share posts directly in the feed.
  • Users can compose posts with rich text, images, and videos.
  • Tapping a post opens a detailed view.
  • Infinite scrolling fetches more posts as users scroll down.
  • Offline mode allows access to previously loaded content without connectivity.

As for non-functional requirements, we need to build a system that ensures:

  • Scalability: Our system should handle 500 million monthly active users globally, adapting to varied network conditions while maintaining responsive performance.
  • Performance: The app must deliver smooth scrolling experiences and efficient post creation, with optimized handling of media content across all device types.
  • Reliability: Users should experience consistent functionality even with poor network connectivity, with effective caching for offline access.
  • Data efficiency: The system should minimize data consumption for users on limited plans through smart caching and selective content loading strategies.
  • Consistency: The app should maintain eventual consistency for actions performed offline, ensuring reliable synchronization when connectivity is restored.

Features that are out of scope for this exercise:

  • Real-time updates and push notifications.
  • Pre-fetching of posts before the user opens the app for quicker post loading.
  • Authentication, we'll assume users are already authenticated.

UI sketch

  • The News/Posts Feed screen is the heart of our app, displaying an endless list of posts. Users can like and share posts directly from this screen. Tapping a post opens its detailed view. A Floating Action Button (FAB) allows users to create new posts.
  • The Create Post screen allows users to compose new posts with rich text, images, and videos.
  • The Post Detail screen shows the full content of a selected post, along with options to like and share.
Image represents a three-screen mobile application workflow for creating and posting content.  The leftmost screen displays a user's existing content, showing a profile icon, a text input field (likely for a caption), and a large grey video player icon indicating a previously uploaded video.  A heart icon and an upload icon are present at the top, suggesting like and upload functionalities. A curved arrow points from this screen to the central screen. The central screen shows a list of content creation options, each represented by a dashed-bordered rectangle. Each rectangle contains a profile icon placeholder, a text input field, and either an image icon or a video icon, along with the heart and upload icons. A plus (+) button is at the bottom, indicating the addition of a new content creation option. A curved arrow connects this screen to the rightmost screen. The rightmost screen is titled 'Create Post' and features a large empty text box for the main post content, a grid of six smaller empty boxes likely for additional media or tags, an upload icon, and a 'POST' button at the bottom. The flow suggests a user selects content from the central screen, which is then transferred to the 'Create Post' screen for finalization and posting.

Having framed the problem and outlined the user experience, we've built a strong foundation for our design. Next, we'll explore the API design to support these features.

Step 2: API design

The purpose of the API design is to establish a clear and detailed agreement between the clients and the backend system. Well-defined APIs ensure that both you and the interviewer are aligned on the functional requirements. In this section, we will talk about three main areas:

  • Communication protocol.
  • API endpoints.
  • Additional considerations for API design.

Communication protocol

Let's first examine the main interactions that will occur between our client and backend:

  1. Feed retrieval: When a user opens the app, the client requests recent posts from the backend.
  2. Post creation: As users create new posts, the client sends this content to the backend.
  3. Post details: When a user taps on a post, the client fetches additional details from the backend.
  4. User interactions: The client notifies the backend when a user likes or shares a post.
Image represents a client-server interaction model.  A rectangular box labeled 'Client' contains a smartphone icon, symbolizing a mobile application.  This client communicates with a second rectangular box labeled 'Backend services:'.  Between these boxes are two arrows representing data flow.  A rightward arrow labeled '1 Request' indicates a request sent from the client to the backend.  The backend services box lists four functionalities: 'Feed retrieval,' 'Post creation,' 'Post details,' and 'User interactions.' A leftward arrow labeled '2 Response' shows the backend sending a response back to the client.  The numbered labels (1 and 2) suggest a sequential order of request and response.

Notice that in all these scenarios, the client initiates the interaction. Given our app's large scale and the variety of client types we're supporting, HTTP with REST APIs emerges as a suitable choice for our communication protocol. Let's examine the advantages and disadvantages in Table 1:

HTTP with REST AdvantagesHTTP with REST Disadvantages
Widely used and well-understood. Developers are generally familiar with it, making implementation and maintenance easier.

Servers are stateless. Each request contains all the information needed to process it, simplifying horizontal scaling and handling multiple clients.

Can leverage HTTP caching mechanisms to reduce server load and improve response times by serving cached data when appropriate.
Managing different versions of an API can become cumbersome as the API evolves. Without a solid versioning strategy, some APIs may become bloated or difficult to maintain.

Stateless means each request is independent and carries all necessary info, such as auth tokens or session data, every time. This repetitive overhead, especially without persistent connections, can impact mobile performance through extra data usage and connection setup latency.

Some clients might receive more data than needed (over-fetching) or require multiple requests to get all necessary data (under-fetching).

Table 1: HTTP with REST advantages and disadvantages

While there are other options for client-server communication, such as GraphQL that can avoid over-fetching by allowing clients to specify exactly what data they need in a single request, we'll focus on HTTP with REST APIs in this chapter. This approach is widely used in the industry, with prominent examples including Meta's Graph API [1], Reddit APIs [2], and X (formerly Twitter) APIs [3].

For data encoding, we have several choices, including JSON, XML, and protocol buffers. Table 2 compares these options to determine the best fit for our news feed app.

JSONXMLProtocol Buffers
Human-readable and results in smaller payloads compared to XML. Natively supported in most programming languages and commonly used with REST APIs.Given we won't need to send much metadata in our news feed app's network requests, JSON is preferable to XML.Could be a good option for our news feed app. However, we're not making our app performance-critical in this exercise, so we can avoid the steep setup cost by using JSON.

Table 2: Trade-offs for encoding data over the network in the news feed

For our news feed app, we'll use JSON as our data format. This choice offers a good balance of simplicity, readability, and efficiency. While Protocol Buffers could be a potential optimization, it's a more specialized technology, and not every developer is familiar with it. For simplicity, we'll opt for JSON in our design. However, if you're comfortable with Protobufs, feel free to use it for the API design. It's a great option too.

βœ… Decisions made!

  • HTTP with REST APIs for client-server communication.
  • JSON as the data format for these network exchanges.

API endpoints

Now that we've chosen our communication protocol and data format, let's design the specific endpoints our system needs. We'll focus on the core interactions required for our news feed functionality.

Feed retrieval

Code
Authentication: Bearer <token>
GET /v1/feed?after=<some-value>&limit=30
    Body: empty
    Response: 200 OK. Payload of type FeedApiResponse

πŸ“Œ Remember: Including a version in endpoint APIs, such as /v1/, gives us flexibility to introduce breaking changes in newer versions while maintaining stability in older ones. This versioning approach allows for controlled API deprecation when needed. For more on API versioning, see Chapter 10: Mobile System Design Building Blocks.

Data models

Since this is a GET request, the request body is empty. As a response, we receive a FeedApiResponse object with the following structure:

KotlinSwift
data class FeedApiResponse
feed: List<PostPreview>
paging: PaginationMetadata
struct FeedApiResponse
feed: [PostPreview]
paging: PaginationMetadata
data class PostPreview
postId: Long
contentSummary: String
author: AuthorPreview
createdAt: String
liked: Boolean
likeCount: Int
attachmentCount: Int
attachmentPreviewImageUrl: String?
struct PostPreview
postId: Int64
contentSummary: String
author: AuthorPreview
createdAt: String
liked: Bool
likeCount: Int
attachmentCount: Int
attachmentPreviewImageUrl: String?
data class AuthorPreview
id: Long
name: String
profileImageThumbnailUrl: String
struct AuthorPreview
id: Int64
name: String
profileImageThumbnailUrl: String

The PostPreview data model is designed with efficiency in mind. It fetches just enough data to create a rich user experience without overloading network or client resources. Instead of retrieving all attachments, we only fetch a count and a single preview thumbnail URL. We also include key interaction details such as whether the user has liked the post.

πŸ› οΈ Platform implementation details

Throughout this book, we present API responses as Kotlin and Swift data models rather than raw JSON. In practice, these JSON payloads get converted to platform-specific data structures within the network layer (specifically in the network data sources shown in our architecture diagrams).

On Android, developers often rely on libraries such as kotlinx.serialization, or Moshi for JSON serialization and deserialization.

On iOS, Swift's built-in Codable protocol handles this elegantly, mapping JSON fields to Swift struct properties.

Pagination

Our feed endpoint needs to support pagination since we're displaying posts in an infinite scrolling list. In Chapter 10, we cover different pagination types and their trade-offs. For our news feed, offset pagination would struggle with our frequently updated content. As users scroll deeper, the query performance degrades, and the content window becomes inaccurate as new posts are added.

Cursor-based pagination works better for our use case because it:

  • Handles large datasets more efficiently by using indexed columns instead of calculating offsets.
  • Provides better feed consistency by using unique identifiers to maintain stable ordering.
  • Works well with real-time content that's constantly changing

βœ… Decision made! We use cursor-based pagination in the feed retrieval endpoint.

Other headers

The presence of the Authentication header in the endpoint indicates that the user needs to be authenticated to call this endpoint. Also, as we use JSON as the data format, you can assume all our requests include an Accept: application/json header by default.

Post creation

Unlike the GET requests we use to fetch data, we use POST requests to send new information to the backend:

Code
Authentication: Bearer <token>
POST /v1/posts
    Body: NewPostRequest
    Response: 201 Created

When creating a new post, the client sends a POST request with a NewPostRequest in the body. This contains all the details about the new post. The server responds with a 201 status code to indicate successful creation, but doesn't include any data in the response body.

KotlinSwift
data class NewPostRequest
requestId: Long
content: String
attachments: List<NewAttachment>
struct NewPostRequest
requestId: Int64
content: String
attachments: [NewAttachment]
data class NewAttachment
type: String
contentUrl: String
caption: String?
struct NewAttachment
type: String
contentUrl: String
caption: String?

Note that the requestId sent by the client isn't the same as the postId assigned by the server. Some critical information, such as the postId and created_at timestamp, is generated by the backend rather than sent by the client. This approach ensures the backend remains the source of truth for critical data. We'll explore this concept in the "Additional considerations for API design: ID and timestamp generation" section.

For attachments, our endpoint design assumes that media has already been uploaded to our servers before creating the post. The client simply includes the URL of the uploaded attachment in the NewAttachment's contentUrl field. This two-step process (first uploading attachments, then creating the post) helps manage large files more efficiently. We'll explore this process in more detail in the deep dive section.

Post details

Code
Authentication: Bearer <token>
GET /v1/posts/`{postId}`
    Body: empty
    Response: 200 OK. Payload of type PostDetailApiResponse

Following REST API guidelines, the request path includes the postId parameter to identify exactly which resource we want to retrieve.

KotlinSwift
data class PostDetailApiResponse
post: PostDetail
struct PostDetailApiResponse
post: PostDetail
data class PostDetail
postId: Long
content: String
author: AuthorPreview
createdAt: String
liked: Boolean
likeCount: Int
shareCount: Int
attachments: List<Attachment>
struct PostDetail
postId: Int64
content: String
author: AuthorPreview
createdAt: String
liked: Bool
likeCount: Int
shareCount: Int
attachments: [Attachment]
data class Attachment
id: Long
type: String
contentUrl: String
previewImageUrl: String?
caption: String?
struct Attachment
id: Int64
type: String
contentUrl: String
previewImageUrl: String?
caption: String?

Unlike the PostPreview data model, PostDetail contains all the information associated with a post, including the full content and all attachments with their respective content.

πŸ“Œ Remember!

In apps with a list-detail structure such as our news feed, it's common to use two different data models for the same content:

  1. A lightweight model for the list view (PostPreview in our system) that contains just enough information for a good list experience and uses thumbnails or low-resolution images to save bandwidth.

  2. A full model for the detail view (PostDetail in our system) that includes all post details and higher-resolution images, loaded only when a user shows interest by tapping on a post.

This two-tier approach balances app responsiveness with user experience. It helps both the client and server save resources by loading detailed data only when necessary, optimizing network usage, processing power, and memory consumption. This is especially important for low-end mobile devices.

User interactions with a post

The endpoint for user interactions with posts follows a similar pattern to post creation:

Code
Authentication: Bearer <token>

POST /v1/posts/`{postId}`/interactions
    Body: PostInteractionRequest
    Response: 201 Created

KotlinSwift
data class PostInteractionRequest
requestId: Long
type: String
struct PostInteractionRequest
requestId: Int64
type: String

The interaction's type could be an enum with different values such as LIKED, REMOVED_LIKE, or SHARED. The requestId is typically a random UUID [4] generated by the client which helps the backend identify unique requests but isn't stored long-term.

πŸ“ Note: These POST requests don't include the user's ID. Instead, the backend extracts this information from the authentication token. This approach not only saves bandwidth by avoiding redundant data but also prevents potential security issues that could arise from mismatched user IDs in the request and the auth token.

Additional considerations for API design: ID and timestamp generation

You may have noticed that when creating a new post, we don't send the postId or created_at timestamp to the backend. This deliberate choice helps maintain data integrity at scale. In large-scale applications, centralizing ID and timestamp generation on the backend offers several advantages:

  1. Consistency: The backend can enforce uniform formats and validation rules across all clients.
  2. Reliability: Client devices might have incorrect system clocks or could be manipulated, leading to inaccurate timestamps.
  3. Uniqueness: The backend can guarantee unique IDs without collisions, even under high transaction volumes.
  4. Database integration: Backend systems can leverage database features such as auto-incrementing keys and timestamp functions (such as CURRENT_TIMESTAMP) to efficiently generate these values during insertion.

Many companies have developed specialized mechanisms for generating IDs at scale, such as X (formerly Twitter)'s Snowflake ID system [5], which creates distributed, time-based IDs that maintain proper ordering while avoiding collisions across multiple servers.

By keeping these critical operations on the backend, we establish a reliable foundation for our data that becomes increasingly important as our user base grows to hundreds of millions.

Step 3: High-level client architecture

With our backend communication protocol, endpoints, and data models defined, we're ready to design the high-level architecture of our mobile system. In this section, we'll cover:

  1. High-level mobile architecture design.
  2. External server-side components.
  3. Key client architecture components for UI and data layers.
  4. Data flow across app layers.

Let's build our design based on the requirements we've gathered and the APIs we've defined. These will guide our decisions about which components to include in our architecture. Figure 4 shows the high-level mobile architecture for our news feed system. Notice that the arrows represent data flow, not component dependencies. This distinction is important for understanding how information moves through our app.

Image represents a client-side architecture diagram for a mobile application, likely a social media platform.  The diagram is divided into two main layers within a 'Client' boundary: a UI layer and a Data layer. The UI layer contains three screens ('News Feed Screen,' 'Post Detail Screen,' 'Create Post Screen') each paired with a corresponding 'State Holder' managing its data.  A 'Navigator' component controls navigation between these screens. A 'Media Loader' interacts with a 'CDN' (Content Delivery Network) via HTTPS to load media assets. The Data layer uses 'Dependency Injection' to connect the UI layer to data sources.  A 'Posts Repository' interacts with both 'Posts Local DataSource' (likely a local cache) and 'Posts Remote DataSource' which fetches data from a 'Backend' server via HTTPS.  The 'Posts DB' represents a local database.  Finally, a 'User Repository' likely manages user-specific data, also potentially interacting with the backend.  Data flows from the 'Posts DB' and 'Backend' through the data sources to the repository, then up through dependency injection to the state holders, and finally to the screens in the UI layer.  The CDN is accessed directly by the 'Media Loader' for efficient media delivery.

Let's examine the key components of our architecture and how they work together, starting with the external server-side components.

External server-side components

Our system relies on two key external components to handle data storage, business logic processing, and content delivery.

Backend

The primary external component in our system is the Backend. As discussed earlier, it's a RESTful service that communicates with our client app using HTTPS and JSON for data exchange. The backend serves two primary functions:

  1. It provides the client app with the data it needs such as posts, and user information.
  2. It permanently stores users' posts and interactions.

The backend contains most of the business logic in our system, handling operations such as feed generation, post creation, and user interaction processing.

Content Delivery Network (CDN)

We can enhance our design by adding a Content Delivery Network (CDN). CDNs excel at distributing static content such as user profile images and media attachments across various geographical locations. This approach offers several benefits:

  • Helps load the news feed quickly and consistently regardless of the user's location.
  • Reduces load on servers, particularly helpful during traffic spikes or major events [6].
  • Improves overall performance by serving content from servers physically closer to users.

Given the scale of our system (500 million monthly active users) and the amount of static content we need to deliver, adding a CDN is a smart choice. It will help us maintain performance and user experience as our user base grows. In our setup, the backend is responsible for providing the CDN with the content to distribute.

Client architecture

Now that we've outlined the external components our client will interact with, let's dive into the core of our mobile system design. We're aiming to build an app that's robust, performant, and scalable. To achieve this, we've structured our architecture into two main layers:

  • The UI layer is where users interact with our app. It's responsible for displaying posts, handling user interactions, and preparing data for the screen. This layer creates the visual experience users engage with.
  • The Data layer acts as the backbone of our app, handling the client-side business logic for posts. It manages how posts are created, modified, and retrieved on the device.

πŸ’‘ Pro tip!

During your interview, use the architectural patterns you're most comfortable with, whether that's the repository pattern, Clean Architecture, MVVM, MVI, or MVP.

What matters most isn't which pattern you choose, but how clearly you can explain your reasoning and demonstrate how data flows through your design. Interviewers want to see your thought process and how you organize components to solve real problems.

For comprehensive coverage of architectural patterns and topics, refer to Chapter 10: Mobile System Design Building Blocks.

To ensure our app remains predictable and error-resistant, we're implementing Unidirectional Data Flow (UDF) and Reactive programming to propagate data through our architecture. With UDF, data flows in one direction (from data sources through our repositories to UI components) while user actions flow in the opposite direction through clearly defined channels. Reactive programming complements this by providing tools to handle asynchronous data streams and propagate changes throughout the app. Together, these approaches create a predictable, maintainable system with clear data paths and efficient state management.

πŸ› οΈ Platform implementation details

To implement UDF and Reactive Programming, Android developers can leverage Kotlin Coroutines and Flow APIs. On iOS, the Combine framework offers similar capabilities.

We're also adhering to key software design principles such as separation of concerns and single responsibility. Each component in our diagram has a specific, well-defined role. To tie everything together and make our app more modular and testable, we'll use dependency injection to keep our components loosely coupled.

βœ… Architecture decisions made!

  • Layered architecture consisting primarily of UI and data layers.

  • Unidirectional Data Flow (UDF) and Reactive programming to shape how data flows through the architecture.

Let's now examine the key components within each layer and define their responsibilities.

Data layer

When designing the data layer, we need to consider the types of application data the app handles. For each type of application data, we typically include:

  • A Repository that acts as the main interface for that data type, exposing it to the rest of the app and handling related business logic.
  • Data Sources that manage specific data operations. We might have zero to many data sources, depending on where we need to fetch or store data.

πŸ’‘ Pro tip!

The data layer is crucial as it contains both the client-side business logic and application data. In most system designs, it's worth dedicating more time to this layer as that demonstrates to your interviewer that you understand the importance of robust data management and business logic in mobile app architecture.

In our news feed app, the main type of application data is Posts. To handle this data effectively, we set up these key components in our data layer:

  • The Posts Repository is the central component that exposes the news feed and individual posts to the app, managing the core business logic.
  • The Posts Remote Data Source handles network communication with the backend for fetching and sending post data.
  • The Posts Local Data Source manages local storage of posts, enabling offline functionality.

Additionally, we include a User Repository to handle user-related information, such as the currently logged-in user's details and authentication token.

By structuring our data layer this way, we create a clean separation of concerns. Each component has a clear responsibility, making our system more modular and easier to maintain. This approach also allows us to easily add support for other types of data in the future, such as user profile data or analytics, following the same pattern.

πŸ’‘ Pro tip!

When creating and discussing the high-level architecture diagram during your interview, focus on the most critical aspects rather than covering every detail. Your interviewer will likely assume you have a solid grasp of the fundamentals. Prioritize explaining your key design decisions and trade-offs. If the interviewer wants more information on a particular point, they'll ask follow-up questions.

For example, you wouldn't need to explain basic concepts like what UI and data layers are. Instead, concentrate on how you've applied these concepts to solve the specific problem at hand. This demonstrates your ability to focus on what's important and communicate effectively under time constraints, both valuable skills in real-world mobile development.

UI layer

With our data layer components in place, let's move on to the UI layer, which handles how we present information to users. To keep things simple, we'll create high-level UI components for each screen we identified in our requirements gathering phase. Our system includes three main screens: News Feed, Post Detail, and Create Post.

πŸ’‘ Pro tip!

Don't aim for perfection in Step 3: High-level client architecture. We're creating a basic skeleton of our mobile app's architecture, not a complete blueprint. We'll have chances to refine and improve the design throughout the interview. We should keep an eye on the clock and avoid getting caught up in details, the goal is to create a solid foundation that we can build upon later.

Each screen has a corresponding State Holder component (think of a ViewModel on Android) that serves two key functions:

  1. It manages the UI state, determining what data should be displayed on the screen.
  2. It handles the screen's logic, which might involve processing user interactions, transforming data for display, and coordinating with the data layer to fetch and modify application state.

Tying these screens together is a Navigation component that manages how users move between screens and passes along any necessary information, such as a postId when opening the Post Detail screen.

Use case: News Feed

Let's use the News Feed screen to illustrate how the UI layer works in practice. While we won't cover every screen, this example represents the general approach for all screens in the app.

The News Feed State Holder automatically subscribes to the Posts Repository in the data layer. This subscription retrieves relevant post data to display to the user. The state holder exposes this data to the UI through a NewsFeedUiState stream, implemented as an observable data holder. Besides post data, the UI state may include other important information such as error messages, loading indicators, or other UI-specific states.

KotlinSwift
data class NewsFeedUiState
feed: List<PostPreviewUiModel>
isLoading: Boolean
errorMessage: String?
struct NewsFeedUiState
feed: [PostPreviewUiModel]
isLoading: Bool
errorMessage: String?

πŸ“ Note:! The NewsFeedUiState uses a PostPreviewUiModel instead of the previously defined PostPreview. This is intentional! It's a best practice to separate domain or data models from UI models. The UI layer often requires only a subset of the full data, sometimes even reshaped or pre-processed to efficiently render the screen. This approach keeps the UI lean, minimizes unnecessary memory usage, and allows the data presentation to evolve independently of backend or storage changes. The closer the model is to the UI, the more it should reflect what the UI needs, not how the data is stored or retrieved.

For navigation events, the UI component handles user interactions directly. When a user taps on a post in the feed, the News Feed Screen notifies the Navigation component about which screen to display next, passing along the relevant postId (Figure 5). This approach is common in event-based navigation libraries.

Image represents a simplified sequence diagram illustrating the user flow for opening a post detail screen in a news feed application.  A user icon on the left represents the user initiating the process.  A labeled arrow (1) indicates the user action 'Open Post Details,' which triggers a transition to the 'News Feed Screen' box.  From the 'News Feed Screen,' a downward arrow labeled (2) 'openPostDetail (postId)' shows a function call to a 'Navigator' box, which acts as an intermediary.  The 'Navigator' box, shaded light gray, receives the `postId` parameter.  Finally, a downward arrow labeled (3) shows the Navigator's response, leading to the display of the 'Post Detail Screen' box.  The overall flow depicts a user interaction on the News Feed, triggering a navigation process via the Navigator, ultimately resulting in the display of the specific post's details.

The News Feed State Holder also manages user interactions such as liking or sharing a post. When a user likes a post, the state holder delegates this action to the Posts Repository, which handles changes to the application data. If the system uses optimistic writes, the state holder immediately updates the UI state to reflect the like action, providing instant feedback to the user.

Common components in the UI layer

We introduce a Media Loader component to handle post media attachments including image loading, which are typically represented as string URLs in our data models. This component efficiently downloads and manages static images and other media, typically served from our CDN.

This approach centralizes media handling, ensuring consistency across the app and providing optimized caching, loading, and error handling for all visual content. The Media Loader can also implement features such as progressive loading or placeholders to enhance the user experience even further.

Data flow across app layers

To illustrate how data flows through the app hierarchy, Figure 6 shows what happens when a user logs in to the app for the first time. As soon as login completes, the News Feed state holder subscribes to the Posts Repository's data stream. Since the local data source is empty at this point, the repository reaches out to the backend to fetch the user's news feed (step 4).

Once the network data source receives the backend response, the Posts Repository updates the local database through the local data source (step 8). This update triggers a chain reaction: the local data source emits the updated database state into the posts data stream, which propagates up to the UI, refreshing the screen with the user's news feed (step 13).

Image represents a system architecture diagram for a news feed application.  A user (represented by a stick figure labeled 'User') initiates the process by logging in (step 1). This action triggers the display of the 'News Feed Screen'.  The screen interacts with a 'News Feed State Holder' (steps 2 and 13), which manages the application's state. The state holder communicates with a 'Posts Repository,' which in turn interacts with two data sources: 'Posts Local DataSource' (steps 3, 8, and 11) and 'Posts Remote DataSource' (steps 4 and 7). The local data source interacts with a 'Posts DB' (steps 9 and 10), representing a local database storing posts. The remote data source interacts with a 'Backend' (steps 5 and 6), representing a server-side component responsible for fetching and updating data.  Data flows between the components are numbered, indicating the sequence of operations: the repository pulls data from both local and remote sources (steps 7 and 8), updates the state holder (step 12), and the state holder updates the news feed screen (step 13).  The local data source also synchronizes with the Posts DB (steps 9 and 10).  The entire diagram illustrates the data flow and interactions between the user interface, data storage, and backend services in the news feed application.

πŸ’‘ Pro tip! Before moving on, pause to check that the functional and non-functional requirements outlined earlier are covered. It's much easier to address any missing use cases now rather than later when you're deep in technical discussions. Think of it as double-checking your foundation. You want it solid before building on top of it.

Step 4: Design deep dive

Now that we've sketched out the high-level client architecture, it's time to dig into the details. In a mobile system design interview, the areas you explore in depth often depend on your interviewer's interests and the unique challenges of the system you're designing. For our news feed app, we'll focus on these key areas:

  • Local storage and offline mode support.
  • Optimistic writes and offline interactions.
  • Displaying rich post content.
  • Troubleshooting janky news feed scrolling.

Local storage and offline mode support

A core requirement of our news feed app is the ability to function effectively without an internet connection. To deliver this experience, we need a robust local storage strategy that balances performance, resource usage, and user experience.

To maintain optimal performance, we need to implement thoughtful data management strategies for both our database content and media assets. Let's examine these topics: storage and eviction policies, offline mode user experience, and caching strategy for media content.

Choosing the right storage solution

Local storage in our app serves several critical purposes:

  • Offline browsing and resilience against poor connectivity: When there's no internet connection or in areas with spotty coverage, users can still scroll through previously cached posts, ensuring users always have something to engage with.
  • Improved performance: Opening a cached post details happens instantly, as data loads from local storage rather than making a network request.

When selecting a storage solution for our news feed, we need to consider the structure and access patterns of our data. Let's compare the main options in Table 3:

Relational DatabaseNon-relational DatabaseKey-value stores
Posts content is a well-structured data model that requires complex data relationships and advanced querying needs such as filtering, sorting, and potentially searching through posts.
Relational databases can provide data integrity and consistency and are designed to scale efficiently.
Posts data is a well-structured data model, so we won't benefit from the advantages of non-relational databases.
We need support for complex queries and cannot allow inconsistencies in our local storage.
Key-value stores aren't designed for handling large amounts of data or complex data structures. The lack of relational structure could lead to data redundancy. Storing too much data can lead to performance degradation.
They don't support relationships, advanced querying, or transactional operations out of the box.

Table 3: Trade-offs for storing posts data locally on the device

Given these considerations, we'll implement a relational database for our news feed app. This solution provides the best combination of structured data storage, query capabilities, and transactional support needed for our offline functionality.

πŸ’‘ Pro tip!

For most mobile apps, a relational database is the go-to choice for storing application data. It offers built-in scalability, robust platform support, and mature tooling that handles common data access patterns effectively. While other storage options exist, it's safe to consider a relational database the default starting point unless specific requirements suggest otherwise.

Database eviction policy

Our news feed app serves a large user base with a constant stream of new content. Storing every post indefinitely on the client would be inefficient and wasteful of device resources. Instead, we need an intelligent eviction policy that keeps our local database lean and relevant.

We can design a hybrid approach combining three complementary policies:

  1. Least Recently Used (LRU): Prioritizes keeping posts the user has recently viewed, as these are most likely to be accessed again.
  2. Time-to-Live (TTL): Sets expiration times for each post, ensuring our cache doesn't contain stale content.
  3. Custom Minimum Threshold: Guarantees that a baseline number of posts always remains available for offline viewing.

This combined approach offers an optimal balance, prioritizing content that's both fresh and relevant to the user while efficiently managing device storage. When determining optimal cache settings, we'll start with these defaults:

  • Maximum cache size: 100-200MB, adjustable based on usage patterns.
  • Default TTL: 15 days for regular posts.
  • Minimum threshold: 50 posts or equivalent to fill 2-3 screens.

These parameters should be configurable from the backend, allowing us to fine-tune based on real-world analytics data.

Offline mode user experience

When a user has no internet connection and opens the app, they'll see posts previously cached in the database. This provides a seamless experience even without connectivity. To make this work, we follow the Single Source of Truth (SSOT) pattern and store posts in a local database on the device as soon as we receive them from the backend. For security reasons, we scope the database to the current user, clearing it when they log out to protect their data.

The Posts Repository consistently exposes a stream of post data from the local data source, regardless of internet connectivity. This approach establishes the local data source as the single source of truth for post data in the client, ensuring users always have access to content they've previously seen.

πŸ“Œ Remember the Single Source of Truth (SSOT) pattern.

A fundamental principle in our architecture to support offline mode is establishing the local database as the single source of truth for post data within the client. This means:

  1. All UI components retrieve data exclusively from the local database, never directly from the network.

  2. Network responses update the local database, which then propagates changes to the UI.

  3. User interactions are recorded in the local database before being sent to the server.

This pattern creates a consistent flow of data regardless of network status: Backend β†’ Local Storage β†’ UI.

If the user attempts to refresh their feed while offline:

  1. The Posts Repository tries to fetch data from the remote data source.
  2. This attempt fails due to the connection issue.
  3. The failure propagates to the state holder, which updates the UI state.
  4. The UI displays an appropriate message about connectivity issues.
  5. Previously cached content continues to be displayed.

This offline-first approach ensures our app remains useful and engaging even in challenging connectivity scenarios, significantly improving the user experience in regions with unstable networks or for users who frequently transition between connected and disconnected states.

πŸ” Industry insights:

Early versions of Facebook's iOS app used Apple's Core Data to cache feed stories locally. This became slow at their scale and they replaced it with a lightweight, denormalized disk cache using object serialization, seeing significant performance improvements [7].

Instagram's mobile app also caches feed content on disk to enable offline usage. It maintains a response store for feed JSON and an image/video cache so that content can be delivered from disk as if from the network when offline [8].

Reddit chose to use Android's internal storage for video caching after external storage proved unreliable on some devices [9].

Optimistic writes and offline interactions

Optimistic writes and offline interaction capabilities are crucial for delivering a top-notch user experience at scale, especially for boosting engagement and retention. These features allow users to interact smoothly with the app, even on low-end devices or in areas with poor network connectivity.

Optimistic writes are a design strategy that immediately updates the app's UI in response to user actions, without waiting for backend confirmation. This approach creates a more responsive and fluid experience by giving users the impression that their actions take effect instantly.

When handling a UI event that calls for optimistic writes, such as liking a post, the state holder performs two parallel actions:

  1. It immediately updates the UI state to reflect the change, as if the data layer had already confirmed it. This is the essence of an optimistic write.
  2. It calls the Posts repository to update the application state and notifies the backend via a network request.

Once the backend syncs the optimistic write, the UI remains unchanged even if the underlying application data updates. This is because the UI was proactively updated during the initial optimistic write.

Supporting optimistic writes and offline interactions effectively requires careful implementation. We'll explain how to approach this from four angles:

  • Data layer updates.
  • Error-handling strategies.
  • Common challenges and how to avoid them.
  • A walkthrough of an optimistic write data flow example.

Data layer updates

Optimistic writes work well for some events, but it's important to go a step further. We should store all user interactions locally until the backend confirms them. This approach supports offline functionality and ensures we don't lose any data during offline periods or poor network conditions.

To make this work, we use the local database as a queue, keeping track of user interactions in the order they happen. If there are user interactions to process when the device is offline, the repository will go through the queue in the background when the device regains connectivity.

To maintain the order of records, we use an AUTO INCREMENT field as the id in a new UserInteraction data model. This model also includes:

  • postId and userId to identify the relevant post and user.
  • action field for interaction types (e.g. LIKED, UNLIKED, SHARED, BOOKMARKED).
  • status field to track the interaction's state (PENDING, FAILED or CANCELED).
  • updated_at timestamp to record the last sync attempt.
  • failureCount to track unsuccessful sync attempts.
KotlinSwift
data class UserInteraction
id: Long
postId: Long
userId: Long
action: UserInteractionAction
status: UserInteractionStatus
createdAt: String
updatedAt: String
failureCount: Int
struct UserInteraction
id: Int64
postId: Int64
userId: Int64
action: UserInteractionAction
status: UserInteractionStatus
createdAt: String
updatedAt: String
failureCount: Int
enum class UserInteractionAction
LIKED, UNLIKED,
SHARED, BOOKMARKED
enum UserInteractionAction
case liked, unliked
case shared, bookmarked
enum class UserInteractionStatus
PENDING, FAILED, CANCELED
enum UserInteractionStatus
case pending, failed, canceled

It's important to sync all user interactions with the server, even if multiple interactions occur on the same post. This data can provide valuable insights for analysis. To handle potential conflicts, we can employ the "last write wins" strategy.

In our local database, we add a new UserInteractions table to store all user interactions. Once an interaction successfully syncs with the server, the Posts Repository removes it from the UserInteractions table, and updates the corresponding entry in the Posts table.

We represent the database tables in the data layer design (Figure 7) as Data Access Objects (DAO) components.

Image represents a data layer architecture diagram for a posts system.  The diagram shows a cylindrical database labeled 'Posts DB' connected to a rectangular component labeled 'Posts DAO' (Data Access Object).  The 'Posts DAO' is further connected to a 'Posts Local DataSource' and a 'User Interactions DAO'.  The 'User Interactions DAO' also connects directly to the 'Posts DB'.  Both the 'Posts Local DataSource' and 'Posts Remote DataSource' feed into a central 'Posts Repository' component.  Finally, an upward-pointing arrow emerges from the 'Posts Repository,' suggesting data flow towards a higher layer (not shown) in the application architecture.  The entire structure is enclosed within a dashed rectangle labeled 'Data layer,' indicating the scope of the diagram.  The connections between components represent data flow or interactions, with data likely moving from the database through the DAOs and data sources to the repository and ultimately to the higher application layers.

Error handling strategies

When user interactions fail to sync with the server, we need a robust error handling approach. Let's implement a retry mechanism with exponential backoff:

  1. If a network request fails, we mark the interaction status as FAILED.
  2. The system then attempts to retry the sync after a progressively longer delay.
  3. This process continues until we reach a maximum number of attempts.

What happens if we hit that maximum? It depends on how critical the interaction is. For less critical actions, we might simply update the status to CANCELED and roll back any UI changes. For more important interactions, we could notify the user. For instance, if a post submission fails, we might send a push notification asking the user to retry manually.

But what about cases where the backend state has changed since our optimistic write? This is where conflict resolution comes into play. We have a few options: discard the local interaction, attempt to merge the changes, or notify the user about the conflict and let them decide. The best approach often depends on the specific use case and the nature of the data involved.

Common challenges

While optimistic writes and allowing offline interactions can greatly enhance user experience, they come with their own set of challenges. Let's explore some common ones and how we address them in our system:

  • The big challenge is maintaining data consistency between the client and backend.
    • To overcome this, we treat the backend as the single source of truth. Whenever we receive data from the backend, we update our local Posts database to match, ensuring alignment.
  • User interactions could potentially be applied out of order.
    • To mitigate this, we include timestamps for both creation and last modification of each interaction. Furthermore, we send these interactions to the backend in order, ensuring sequential processing.
  • Users might experience confusing UI behavior or anomalies if optimistic updates are rolled back or if conflict resolution isn't clear.
    • For critical scenarios, we notify the user of what happened and guide them on next steps.
  • Optimistic writes can put additional strain on the backend due to retries and conflict resolution.
    • We mitigate this by implementing an exponential backoff strategy for retries. We could also batch user interactions if needed.

Optimistic write data flow

  • When the state holder receives the UI event, it performs two actions in parallel:
    • Updates its UI state to reflect the change (step 1.1), providing instant feedback to the user.
    • Notifies the repository about the interaction (step 1.2), which persists locally (step 2) and syncs with the backend (step 3).
  • After the interaction is successfully synced (step 4), the local data source first removes the user interaction from the database (step 5) and updates the Post (step 6).
  • Subsequent updates to the local database after the optimistic write don't cause new UI state emissions from the state holder (step 7). This is because the UI state doesn't change, we updated the UI state optimistically in step 1.1.
Image represents a system architecture diagram for a news feed application.  A user interacts with the system by liking a post, which is sent to the 'News Feed Screen'. This screen interacts with a 'News Feed State Holder' (1.1). The state holder communicates with a 'Posts Repository' (1.2, 7). The repository interacts with both a 'Posts Local DataSource' (2) and a 'Posts Remote DataSource' (3). The local data source interacts with a database ('Posts DB') via 'User Interactions DAO' (5) and 'Posts DAO' (6). The remote data source interacts with a 'Backend' (4).  Data flows between components are numbered for clarity, showing the interaction between the user interface, data storage, and backend services.  The diagram illustrates how a user action triggers data updates and retrieval from both local and remote sources, maintaining consistency in the news feed display.

By implementing optimistic writes and robust offline capabilities, we create an app that feels responsive and reliable under all network conditions. This approach significantly enhances user experience and effectively manages the complexities of distributed systems.

πŸ” Industry insights:

Instagram developed a Direct's Mutation Manager in their mobile apps to manage potential issues such as network failures or data inconsistencies for optimistic writes [10].

Displaying rich Post content

Our system requirements include allowing users to add rich text to their posts. While the Post's content is stored as a simple String, displaying it as plain text wouldn't meet user expectations. Modern social networks offer features such as links, mentions, and text styling. Our application goes a step further by supporting rich editing, including elements such as headers, bold text, and italics.

To handle this rich content effectively, we have two main options: create our own markup language or use an existing one like HTML or Markdown. Let's examine the trade-offs of each approach.

Developing a custom markup language can be expensive and requires ongoing maintenance. Facebook's past attempt at this, the Facebook Markup Language (FBML), was eventually deprecated [11] in favor of standard HTML and JavaScript, primarily using React. X (formerly Twitter), on the other hand, still uses a custom approach with their own text library [12]. Some companies opt for existing solutions, such as Reddit, which uses a specialized version of Markdown [13].

Given our current needs, creating a custom markup language or parsing method isn't the best use of our resources. Instead, let's consider which standard markup language would best suit our system in Table 4:

OptionAdvantagesDisadvantages
HTMLSupports rich formatting needed for news feed posts.
Allows custom elements for future features such as text highlighting in posts or user mentions.
Consistent rendering across platforms.
Complexity could impact user post creation for non-technical users.
Requires sanitization to prevent XSS attacks.
Rendering HTML content can be resource-intensive and it may affect scrolling performance on low-end devices.
MarkdownSimpler for users to create formatted posts with basic features.
Can be converted to HTML for rendering, giving us flexibility in how we process and display content.
Limited formatting options for our rich content needs and might be insufficient for complex post layouts.
Markdown still has its own syntax that users need to learn.

Table 4: Trade-offs for encoding posts content

Given our requirements for a modern, rich, and interactive news feed with diverse formatting options, we've decided to use HTML for encoding post content. This choice allows us to leverage HTML's versatility while addressing its potential drawbacks:

  1. To manage HTML's complexity, we'll establish best practices that define a consistent way to implement each app feature. This approach will help prevent multiple HTML methods from being used for the same functionality.
  2. Since our backend sends post content and summaries as HTML-encoded strings, we need to prioritize content validation and sanitization. This step is crucial in preventing security vulnerabilities such as XSS attacks.

πŸ› οΈ Platform implementation details

To validate and sanitize HTML-encoded Strings, we can use the OWASP Java HTML Sanitizer or the jsoup libraries on Android. For iOS, we can use the HTMLKit or Purifier libraries.

By implementing these strategies, we can harness HTML's power while mitigating its potential issues, ensuring a robust and secure content handling system for our news feed.

Rendering HTML-encoded content

When it comes to displaying HTML-encoded content on the screen, we have two primary options to consider: using a WebView to render the HTML directly, or parsing the HTML and displaying it using native UI components. Table 5 takes a closer look at these approaches and weighs their pros and cons.

OptionAdvantagesDisadvantages
WebViewRenders complex post formatting with minimal effort and supports all HTML features in a single component.
Ensures consistent appearance across all platforms, as WebViews use the same rendering engine as mobile browsers.
Higher memory usage may impact feed scrolling.
Interactions feel less native in the scrolling feed and it's more difficult to integrate seamlessly with feed animations.
Native UI componentsBetter scrolling performance in the feed.
Consistent with platform design language that provides smoother integration with other gestures.
Requires custom HTML to native UI parser implementation.
More development effort to maintain.

Table 5: Trade-offs for rendering HTML post content on the screen

For simpler HTML content such as formatted text with links and images, parsing HTML into native components strikes the right balance between performance and visual quality. And since we can establish HTML best practices when creating post content, we opt to parse the HTML and render it using native UI components. This choice enhances app performance and delivers a more polished user experience.

βœ… Decisions made!

  • HTML for encoding post content to support rich text.

  • Native UI components to render HTML-encoded content on the screen.

Updates to the high-level architecture diagram

To enhance our high-level architecture diagram, we add a Post Content Renderer component. This component renders posts on the screen using native components, assisted by the HTML Parser. All screens in our diagram will depend on the Post Content Renderer, improving code readability, testability, and allowing reuse of logic across screens.

Similarly, we introduce a Post Editor for the Create Post Screen. This editor lets users type rich content, which is then encoded to a String using the HTML Encoder component. Figure 9 illustrates these new dependencies within the UI layer.

Image represents a UI layer architecture diagram for a mobile application, likely a news or blogging platform.  The diagram shows three main screen components: 'News Feed Screen,' 'Post Detail Screen,' and 'Create Post Screen.' Each screen interacts with a corresponding 'State Holder' (News Feed State Holder, Post Detail State Holder, Create Post State Holder) which presumably manages the data associated with that screen.  The 'News Feed Screen' and 'Post Detail Screen' both send data to a 'Post Content Renderer,' which in turn sends the rendered content to an 'HTML Parser.'  The 'Create Post Screen' interacts with a 'Post Editor,' which uses an 'HTML Encoder' to format the content before it's likely sent to a backend (not shown).  All connections between components are represented by dashed arrows, indicating data flow or interaction.  The 'Post Content Renderer' acts as a central component, receiving data from different screens and processing it for display. The shaded boxes ('Post Content Renderer,' 'HTML Parser,' 'Post Editor,' 'HTML Encoder') suggest these are distinct modules or services.

This approach ensures a consistent, high-quality rendering experience across all screens where post content appears, while maximizing performance and maintaining the native feel of our application.

πŸ” Industry insights:

Back in 2012, the Facebook iOS app switched from HTML5 to native iOS code to optimize performance [7].

Reddit reported that moving to Server-Driven UI and stripping unneeded data from responses reduced home feed latency by 12% [14].

Janky news feed scrolling

During mobile system design interviews, the interviewer might pose hypothetical scenarios to test your troubleshooting skills and technical depth. A common question might be: "What could be causing janky, slow scrolling in the news feed? If this were happening, how would you fix it?"

This type of question evaluates your understanding of mobile performance optimization and your ability to diagnose complex issues. Let's explore the potential causes and solutions.

Inefficient rendering

Several potential issues could lead to janky scrolling in a news feed. Let's examine the most common culprits.

View recycling inefficiencies are a primary concern. The main goal of item recycling is to reuse views that are no longer visible instead of creating new ones for each item that scrolls into view. This reduces the number of view creation and layout operations, which significantly improves performance and reduces memory usage.

When a new view comes into the visible area, the system should check a recycling pool for an available reusable view with the matching identifier and reconfigure it with new data. Because views are recycled rather than constantly created and destroyed, the main thread has more bandwidth to handle user interactions and animations, resulting in smoother scrolling.

πŸ› οΈ Platform implementation details

Both platforms provide native APIs that support efficient view recycling:

  • On Android, use RecyclerView in the View system, or lazy lists such as LazyRow and LazyColumn in Jetpack Compose.

  • On iOS, use UITableView or UICollectionView, or the List API in SwiftUI.

Asynchronous content loading is another critical aspect built into our news feed design. As we've covered, heavy objects such as images and attachments in the post data model come as URL strings rather than actual content. When rendering a post item on screen, these objects can be loaded asynchronously, freeing the main thread from expensive operations that could cause stuttering.

Other UI inefficiencies related to rendering that affect performance and overall user experience might include:

  • Main thread blocking: Heavy computations, synchronous network calls, or file I/O operations on the main thread can freeze the UI. To address this, move all resource-intensive operations to background threads.
  • Complex layouts: Deeply nested view hierarchies are costly for the rendering engine. Flatten layouts where possible, reduce hierarchy depth, and eliminate redundant or overlapping views.
  • Inefficient state management: Unnecessary re-renders caused by poorly managed state can severely impact performance. Implement debouncing for state updates to limit their frequency, and introduce caching and memoization to avoid redundant operations.
  • Unoptimized images: Loading and displaying large images without proper optimization is often a major performance bottleneck. Ensure your media loading library is performant, and consider resizing images to the required dimensions before display if the server doesn't provide optimized versions.

Dynamic image and video quality

The user experience in our app, particularly during news feed scrolling, is directly influenced by device performance. A high-end device is more likely to deliver a smooth experience compared to a low-end one with limited resources. CPU and memory usage, GPU performance, network conditions, and battery level all affect overall performance.

For devices with limited capabilities, we can implement a dynamic strategy that adapts image and video quality based on current conditions. This approach helps:

  • Reduce load times and make the app more responsive.
  • Maintain a steady frame rate during scrolling.
  • Use device resources efficiently.
  • Make the app more resilient across diverse devices and conditions.

To implement this feature, we first define multiple quality settings for images and videos. Depending on device performance and network conditions, the app adjusts quality dynamically based on thresholds:

  • Images: Low (480x320 or lower), Medium (720x480), High (1080x720 or higher)
  • Videos: Low (360p), Medium (720p), High (1080p or higher)

Our backend should be capable of delivering different quality versions of the same media based on the client's request. For each image or video, it needs to store several pre-processed versions at different quality levels or dynamically generate the requested quality variant on demand.

πŸ› οΈ Platform implementation details

To improve scroll smoothness on Android, Facebook developed Litho, an open source UI rendering framework which moves much of the UI work off the main thread. Facebook saw up to 20% faster scrolling performance in News Feed after the Litho-powered video component and in some list subviews as high as 42% scroll performance improvement [15].

Pinterest's teams discovered that video startup delay was hurting engagement on their feed. They optimized their Adaptive Bitrate (ABR) streaming pipeline to speed this up. Their startup latency dropped ~36% [16] [17].

Reddit cancels video prefetch downloads if the user scrolls too fast to defer non-essential work during scroll [9].

Step 5: Wrap-up

In this chapter, we've designed a comprehensive news feed system that enables users to create, view, and interact with rich content posts. Our approach leveraged HTTP with REST APIs for client-initiated requests and JSON for data encoding in network communications. We established well-structured backend endpoints following REST principles to support our core functionality.

We designed a client architecture with offline support, diving deep into key areas such as selecting the right local storage, implementing optimistic writes and seamless offline interactions, rendering rich Post content, and addressing janky scrolling in the news feed.

If you find yourself with extra time during your interview or want to challenge yourself with additional features, consider exploring these extensions:

  • Comment functionality: Enable users to comment on posts and respond to other comments [18].
  • Push notifications and live updates: Notify users of interactions with their posts and implement real-time updates for metrics like counts and new comments [19].
  • Search capabilities: Implement search for both online and offline posts. For local database searching, explore Full-Text Search (FTS) [20].
  • Post editing: Develop a strategy for handling updates to posts that have already been synced with clients.
  • Scheduled posts: Allow users to create posts that publish at a future time. Android [21] and iOS [22] links.
  • Intelligent pre-fetching: Download posts when the device is in ideal conditions [23].
  • Multi-language support: Design the system to handle posts in various languages. Android [24] and iOS [25] links.

Resources

[1] Meta's Graph API: https://developers.facebook.com/docs/graph-api/
[2] Reddit RESTful APIs: https://www.reddit.com/dev/api/
[3] X (formerly Twitter) APIs: https://developer.x.com/en/docs/twitter-api/getting-started/about-twitter-api
[4] UUID: https://en.wikipedia.org/wiki/Universally\_unique\_identifier
[5] Snowflake ID: https://en.wikipedia.org/wiki/Snowflake\_ID
[6] How production engineers support global events on Facebook: https://engineering.fb.com/2018/02/12/production-engineering/how-production-engineers-support-global-events-on-facebook/
[7] Making News Feed nearly 50% faster on iOS: https://engineering.fb.com/2014/10/31/ios/making-news-feed-nearly-50-faster-on-ios
[8] Instagram pre-fetching: https://instagram-engineering.com/improving-performance-with-background-data-prefetching-b191acb39898
[9] Reddit's improvement to video playback: https://proandroiddev.com/improving-video-playback-with-exoplayer-7ac55e9bd0af
[10] Instagram optimistic writes: https://instagram-engineering.com/making-direct-messages-reliable-and-fast-a152bdfd697f
[11] Facebook Markup Language deprecation: https://developers.facebook.com/blog/post/568/
[12] X (formerly Twitter) text library: https://github.com/twitter/twitter-text
[13] Reddit-flavored Markdown: https://www.reddit.com/wiki/markdown/
[14] Reddit Adopts Server-Driven UI for Its New Feed Architecture across Mobile Apps: https://www.infoq.com/news/2023/09/reddit-feed-server-driven-ui
[15] Improving Android video on News Feed with Litho
https://engineering.fb.com/2018/01/31/android/improving-android-video-on-news-feed-with-litho
[16] Improving ABR Video Performance at Pinterest: https://medium.com/pinterest-engineering/improving-abr-video-performance-at-pinterest-f0ea47a6d4fc
[17] How Pinterest Optimized Video Playback:
https://blog.quastor.org/p/how-pinterest-optimized-video-playback
[18] Comments section: https://en.wikipedia.org/wiki/Comments\_section
[19] Push Technology: https://en.wikipedia.org/wiki/Push\_technology
[20] Full-text search: https://en.wikipedia.org/wiki/Full-text\_search
[21] Schedule tasks on Android: https://developer.android.com/develop/background-work/background-tasks
[22] Schedule tasks on iOS: https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_background/using_background_tasks_to_update_your_app
[23] Prefetching: https://en.wikipedia.org/wiki/Prefetching
[24] Multi-language on Android: https://developer.android.com/training/basics/supporting-devices/languages
[25] Multi-language on iOS: https://developer.apple.com/documentation/xcode/supporting-multiple-languages-in-your-app

CHAPTER 04

Chat app

04

In this chapter, we'll tackle the design of a chat app. Chat apps are so common that designing one has become a very common question in mobile system design interviews. Figure 1 showcases some of the most popular chat applications currently in the market.

Image represents a collection of six popular messaging application logos, arranged in two rows and three columns.  The top row features, from left to right: a green speech bubble containing a white telephone icon labeled 'Whatsapp,' a light blue speech bubble with a white lightning bolt icon labeled 'Facebook messenger,' and a green square with two overlapping white speech bubbles labeled 'Wechat.' The bottom row displays, from left to right: a green square speech bubble with the word 'LINE' in white labeled 'Line,' a green and darker green overlapping speech bubble icon labeled 'Google Chat,' and a purple Discord logo labeled 'Discord.'  There are no connections or information flow explicitly depicted between the logos; the image simply presents the logos and their respective names for visual identification.

Chat apps have evolved to meet a wide range of communication needs. They serve as platforms to host one-on-one conversations, help friend groups stay connected, allow families to share life updates and photos, and even enable gamers to strategize during online matches. With such a diversity of potential functions, it's crucial to define clearly the specific requirements for the chat app we're designing.

Step 1: Understand the problem and establish design scope

When tackling the design of a chat app, it's essential to first get on the same page as your interviewer. Here's how that conversation might play out:

Interviewer: Welcome to today's session! We'll be designing the mobile client app architecture and backend communication for a chat application.

Candidate: Thanks, I'm looking forward to this! Before we dive in, I'd like to clarify a few points about the app's functionality. Typically, chat apps allow users to send and receive messages in real time, view their conversation list, and access chat history offline. Are we focusing on one-on-one chats, group chats, or both? This could significantly impact our design approach.
Interviewer: Let's keep it simple and focus solely on one-on-one conversations. We'll leave group chats out of scope for now.

Candidate: Understood. Now, about the backend's role, some chat systems, such as WhatsApp [1], use the backend as a temporary message relay, holding messages until they're delivered to clients, without permanent storage. Is this the approach we're taking?
Interviewer: Yes, that's exactly the approach we'll use for this design.

Candidate: I see. Are we supporting only text messages, or should we consider attachments and reactions, as well?
Interviewer: Let's keep it simple and focus on text messages only for now.

Candidate: Alright. What about common chat features such as push notifications for new messages, typing indicators, and online status updates? Should we include these in our design?
Interviewer: Yes, let's incorporate those features.

Candidate: Great. Now, can you give me an idea of the app's scale? Are we designing for a startup or a large-scale, global application?
Interviewer: We're thinking big here. We're looking at about 30 million daily active users worldwide.

Candidate: That's a significant user base. For user registration and authentication, can we assume users are already authenticated when they access these features, and that their contact lists are pre-loaded when they open the app?
Interviewer: Yes, those are safe assumptions.

Candidate: Got it. And lastly, about security, we'll obviously secure the client–backend connection, but should we also consider encrypting messages stored on the device to protect against potential security breaches?
Interviewer: For now, let's keep message encryption on the device out of scope.

This back-and-forth helps us build a comprehensive picture of our design requirements. Let's take a moment to summarize what we've established so far.

πŸ’‘ Pro tip!

When addressing a familiar interview question, take the lead by proposing both functional and non-functional requirements upfront. This proactive approach not only showcases your leadership skills but also helps your interview stand out.

Requirements

Based on our discussions, we're designing a chat system with the following functional requirements:

  • Users can view their recent conversations, organized by the timestamp of the latest message.
  • Users can chat in real time, including both sending and receiving text messages.
  • During conversations, users can monitor their contacts' online status, typing indicators, message delivery status, and read receipts.
  • Users can browse their chat history without an internet connection. The conversation history loads efficiently through pagination.
  • The app supports push notifications to alert users of new messages.

For our non-functional requirements, we need to build a system that ensures:

  • Scalability: Our system must handle 30 million daily active users worldwide and deliver reliable performance across various network conditions.
  • Data integrity: The app should maintain consistency during offline use, especially when storing messages that will be delivered once connectivity returns.
  • Durability: Client-side data must remain reliable since it serves as the source of truth for messages.

πŸ’‘ Pro tip!

When asking about Daily Active Users (DAU), you're doing more than just getting a number. The scale and geographic distribution of users directly influence your technical decisions. High DAU numbers often signal a need to focus more on topics such as performance optimization, resource efficiency, scalable architecture patterns, and the right network protocol choice.

Always connect scale requirements to your technical choices during the interview. This demonstrates thoughtful system design and shows you understand how business requirements shape architecture decisions.

To keep our design focused, we'll consider the following features out of scope:

  • User authentication and registration.
  • Group chats and media file sharing.
  • Message encryption.

As we move forward, it's important to remember that the scale of our system will significantly influence our design decisions. We'll need to consider carefully how each choice affects our ability to efficiently manage a large user base and high message volume.

UI sketch

Figure 2 shows the basic layout of our chat app. On the left, the Chat List screen displays recent conversations and a button to start a new chat. Tapping a conversation opens the Conversation screen, which shows recent messages, the other user's online and typing statuses, and a text field to send new messages.

Image represents a simplified diagram illustrating the interaction between a chat list view and a chat message view within a mobile messaging application.  The left phone screen displays a list of chat conversations, each represented by a horizontal rectangular element containing a circular user profile placeholder, a progress bar (likely indicating message read status or other metadata), and a timestamp (e.g., 5:30 PM, 12:30 PM, 10:15 AM, 08:06 AM).  These elements are arranged vertically, suggesting a chronological order from most recent to oldest.  A '+' button is present at the bottom, indicating the ability to initiate a new conversation.  A curved arrow points from the third conversation element (10:15 AM) to the right phone screen, which depicts a single chat message view. This view shows a circular user profile placeholder, an 'Online Typing...' status indicator, and several grayed-out rectangular placeholders representing incoming or outgoing messages within the selected conversation.  A play button is at the bottom, possibly indicating the ability to play voice messages or other media. The arrow indicates that selecting a conversation from the list on the left screen navigates the user to the corresponding chat message view on the right.

Step 2: API design

Now that we've established our requirements, let's dive into the API design. This step is crucial for understanding how the client and backend will communicate. In this section, we'll look at:

  • Client and backend expectations.
  • Server-initiated connections.
  • Network protocols in the chat app.
  • Endpoints and data models.

Client and backend expectations

In a chat system, clients don't communicate directly with each other. Instead, they connect to a backend that acts as a mediator. When a client sends a message, it goes to the backend. The backend then identifies the intended recipient and forwards the message. If the recipient isn't online, the backend holds on to the message until they connect.

Figure 3 illustrates the interactions between clients and the chat backend service, showing how messages flow through the system.

Image represents a simplified architecture for a messaging system.  The diagram shows three main components arranged horizontally. On the left is a rectangular box labeled 'Sender' containing a simple icon of a smartphone.  A thin arrow labeled 'Message' extends from the Sender box to a central rectangular box labeled 'Backend services:'. This central box lists two functions: '1. store message' and '2. relay message'.  Another thin arrow labeled 'Message' extends from the Backend services box to a rectangular box on the right labeled 'Receiver,' which also contains a smartphone icon. The arrows indicate the flow of a message: the Sender sends a message to the Backend services, which stores it and then relays it to the Receiver.

Let's examine how our client app and backend need to communicate. There are two distinct types of interactions we need to handle:

  1. When clients initiate contact with the backend, such as sending messages or syncing pending ones, HTTP works well for these scenarios since these client-initiated requests follow a simple request-response pattern.
  2. When the backend needs to reach clients for real-time features, such as message delivery or online status updates, the recipient's app needs to know about these events immediately, but HTTP only allows clients to make requests, not the other way around.

While push notifications can help inform users about new messages or events when the app isn't active, they aren't enough for an active chat session. Real-time features like message delivery, read receipts, and online status updates require something more immediate and reliable than push notifications, which can be slow and inconsistent. Given how sensitive chat apps are to latency, we need a more robust solution that can deliver a seamless, real-time experience.

Server-initiated connections

Over time, developers have created several techniques to support server-initiated connections. These include polling, long polling, Server-Sent Events (SSE), WebSockets, and other solutions built on top of HTTP/2 such as gRPC.

These approaches frequently come up in mobile system design interviews, so it's important to understand them. Let's explore the key methods that have shaped how mobile apps communicate with servers.

Polling

Polling (or short polling) is a basic technique whereby the client repeatedly asks the server, "Do you have any new messages for me?". As shown in Figure 4, the process works like this: The client asks for new data; the server responds with or without new information; the client waits briefly, then asks again.

While easy to implement, polling can be wasteful. It often leads to many unnecessary requests, especially when there's no new data to report. This constant back-and-forth can burden both the client and the server, consuming extra network resources and processing power.

Image represents a sequence diagram illustrating a client-server interaction for checking new messages.  Two rectangular boxes labeled 'Client' and 'Server' are positioned vertically, representing the two communicating entities.  Horizontal arrows depict the flow of communication.  The client repeatedly sends requests to the server ('New messages?') using solid arrows. The server responds with 'NO' (indicating no new messages) for several consecutive requests, followed by a dotted line representing the closing of the connection after each 'NO' response.  After several unsuccessful checks, the server finally responds with 'Yes. Return new messages,' indicating the presence of new messages, followed by a dotted line representing the closing of the connection.  The ellipsis ('...') below indicates that this polling process continues indefinitely until new messages are available.

Long polling

Long polling emerged as an improvement by addressing some of the inefficiencies of traditional polling. Let's take a closer look at how it works in Figure 5.

Image represents a sequence diagram illustrating the interaction between a Client and a Server in a messaging system using a long-polling approach.  The diagram shows a vertical timeline for both the Client and the Server.  The Client initiates the interaction by sending a request 'New messages?' to the Server. The Server then waits for new messages. If new messages arrive, the Server responds with 'Yes. Return new messages' to the Client.  The connection is then closed, represented by a dotted line labeled 'connection closed'.  This process repeats, with the Client repeatedly sending 'New messages?' requests. However, if no new messages arrive within a certain timeframe, the Server times out and closes the connection, indicated by 'Timeout' and 'connection closed'.  The ellipsis (...) at the bottom suggests this polling cycle continues indefinitely.  The arrows indicate the direction of communication, with requests going from Client to Server and responses from Server to Client.

With HTTP long polling, the client asks the server for updates and waits. Unlike short polling, the server holds the connection open until there's new data or a timeout occurs. Once the client gets a response, it immediately asks again.

While better than short polling, long polling has some issues too:

  • It's still inefficient for infrequent chatters, as most requests will just time out.
  • In multi-server setups, the server with the new message might not be the one connected to the recipient.
  • Servers can't easily tell if a client has disconnected.

Server-Sent Events

Server-Sent Events (SSE) offer a way for servers to push updates to clients over a single HTTP connection. This approach, illustrated in Figure 6, differs from traditional request–response models.

Image represents a diagram illustrating a uni-directional communication pattern between a server and a mobile device using Server-Sent Events (SSE).  The diagram shows a server icon, labeled 'Server,' connected to a mobile phone icon, labeled 'Mobile,' via a horizontal line representing the initial connection.  The text 'SSE (Server-sent event)' is positioned to the left, clarifying the communication method.  Above the horizontal line, the word 'uni-directional' indicates the communication's one-way nature.  Multiple diagonal arrows extend from the server to the mobile device, representing the server pushing data to the mobile client over time.  These arrows are labeled with 'Time' on both the top and bottom horizontal lines, indicating the progression of time and the continuous stream of data from the server.  The arrows visually demonstrate that the data flow is exclusively from the server to the mobile device, with no response or feedback from the mobile device to the server.

At its core, SSE maintains an open HTTP connection between the client and server. To prevent the connection from timing out due to inactivity, the server may periodically send a colon-prefixed comment line. This simple technique keeps the connection alive, even when there's no actual data to transmit.

πŸ“ Note! This is how SSE works under the hood:

  1. The client sends an HTTP GET request to the SSE server endpoint, setting the Accept header to text/event-stream.

  2. The server replies with a 200 OK status and sets the Content-Type to text/event-stream. It then keeps the connection open, ready to push updates.

  3. The server sends events in a simple text format, continuously updating the client.

  4. If the connection drops, the client typically reconnects automatically. To avoid missing events, it can include a Last-Event-ID header in its reconnection request, allowing the server to send only the events that occurred since the last received event.

SSE is far more efficient than polling methods. By establishing a single, persistent connection and pushing updates only when necessary, SSE significantly reduces network traffic and server load. This makes it a more resource-friendly option for real-time communication.

In the context of our chat app, we could leverage SSE for server-initiated events while using standard HTTP requests for client-initiated actions. This hybrid approach would allow us to receive messages and other events through the SSE connection, while sending user messages via separate HTTP requests.

Although SSE presents a viable solution, there's another alternative to consider: WebSockets.

WebSocket protocol

The WebSocket (WS) protocol has one of the most common solutions for real-time, bidirectional communication between clients and servers. Figure 7 shows how it works.

Image represents a client-server communication diagram illustrating the WebSocket handshake and data transfer process.  On the left, a 'Client' icon (representing a web browser) is connected via a thick vertical line to two vertically stacked rectangles labeled 'HTTP' and 'WebSocket,' respectively. On the right, a 'Server' icon (depicting a database server) is similarly connected to two vertically stacked rectangles also labeled 'HTTP' and 'WebSocket.'  The communication begins with a 'TCP 3-way handshake' between the client and server, represented by a horizontal arrow.  Next, the client sends an 'Upgrade: WebSocket' request to the server via a diagonal arrow. The server responds with a '101 Switching Protocols' message, indicated by another diagonal arrow.  Following this protocol switch, bidirectional data transfer occurs between the client's and server's 'WebSocket' components, shown by two diagonal arrows labeled 'WebSocket data' in both directions, indicating continuous, full-duplex communication.

WebSocket connections begin their life as standard HTTP connections that are then "upgraded" to WebSocket connections through a well-defined protocol. This approach allows WebSockets to operate seamlessly even in the presence of firewalls, as they utilize the same ports (80 and 443) as HTTP and HTTPS traffic.

Once established, this two-way channel enables both client and server to freely exchange messages. WebSockets don't impose restrictions on data formats; they accommodate everything from plain text to binary data. This flexibility has contributed to their widespread adoption.

Messaging protocols used in real-world apps

WebSockets are built on top of TCP (Transmission Control Protocol) and provide a reliable connection for data transfer. TCP establishes and maintains a connection throughout the session, ensuring data packets are delivered in order and without errors. It handles error-checking, acknowledges received packets, and resends lost ones.

In contrast, UDP (User Datagram Protocol) doesn't establish a connection first before sending data. It sends each packet independently, which means it can't guarantee delivery, order, or error-free transmission. While this approach lacks TCP's reliability, it has less overhead, making it faster for certain applications.

Over the years, developers have created various real-time messaging protocols designed for specific use cases. These protocols are typically built on either TCP or UDP, depending on their needs. Some notable examples include:

  • WebSocket: This full-duplex protocol enables communication over a single TCP connection.
    • Slack uses WebSockets for real-time communication [2] [3], and Uber used it as well in the past [4] [5].
  • XMPP (Extensible Messaging and Presence Protocol): An XML-based protocol supporting real-time messaging and presence information.
    • WhatsApp historically used XMPP as the backbone of its messaging system [6], and Kik Messenger also used it [7] .
  • MQTT (Message Queuing Telemetry Transport): A lightweight publish/subscribe messaging protocol that operates over TCP.
    • Facebook Messenger's initial implementation used MQTT [8].
  • WebRTC (Web Real-Time Communication): This protocol primarily uses UDP (with TCP fallback) to support peer-to-peer audio, video, and data streams.
    • Discord [9] uses WebRTC for voice and video functionalities.
  • IRC (Internet Relay Chat): Operating on TCP, IRC is used mainly for group communication in channels, but it also supports private messaging.
    • Mozilla used IRC as the primary tool for community communication [10].

Network protocols in the chat app

In summary, Table 1 compares the network protocols that can be used for server-initiated connections to meet the system's real-time requirements.

OptionAdvantagesDisadvantages
Short pollingSimple implementation.
Works with standard HTTP infrastructure and wide compatibility across platforms.
Inefficient with excessive requests.
High bandwidth usage occurs when many requests return no new data
Long pollingNear real-time updates with fewer requests than short polling.
Good for non-critical updates.
Complex timeout handling.
Doesn't scale well with many clients as it's resource-intensive on servers.
Server-Sent Events (SSE)Single open HTTP connection for server-to-client messages.
Built-in reconnection support and works well over HTTP/2.
Unidirectional (server to client only) so it requires a separate mechanism for client-to-server communication.
Resource-intensive with many connections.
WebSocketTrue bidirectional communication.
Low-latency message delivery and reduced overhead without repeated request/response cycles.
More complex to implement as it requires more infrastructure support (e.g., load balancers).
Scaling persistent connections is challenging.

Table 1: Trade-offs for choosing the network protocol for real-time features

After evaluating the different options, we chose a hybrid approach for the chat app:

  1. HTTP with REST APIs for client-initiated operations such as sending messages.
  2. WebSocket for receiving real-time messages from the server.

While implementing two protocols adds some complexity, this creates a more maintainable system as the app grows. By using WebSocket only for real-time features rather than all communication, including sending messages, we avoid common scaling issues that typically emerge in production environments.

⚠️ Careful!

Scaling WebSocket services in production requires careful consideration. Some key challenges include:

  • Managing persistent connections consumes significant server resources.

  • Load balancing WebSocket connections is more complex than standard HTTP traffic.

  • Connection recovery and state management need thorough handling.

  • Monitoring and debugging WebSocket services require specialized tooling.

By limiting WebSocket usage to just real-time features rather than all communication, we can better manage these scaling challenges.

We chose WebSockets over SSE for our chat application primarily because they offer better performance in chat scenarios. WebSockets provide lower latency and reduced header overhead, which is crucial when sending frequent small messages such as typing indicators and read receipts. While SSE could handle one-way server updates, WebSockets have more consistent support across mobile platforms and come with mature tooling for managing connections. This includes built-in capabilities for reconnection strategies and keep-alive mechanisms.

This hybrid approach, which combines HTTP and WebSockets, has been widely adopted in the industry. For example, Slack uses HTTP for most write operations through their Web API [11], while leveraging WebSocket for real-time events through their Real-Time Messaging (RTM) API [12]. Discord follows a similar pattern [13], demonstrating how well this architecture works at scale.

The client establishes the WebSocket connection for real-time updates when the app starts, and it remains active until either the client or server terminates it. Figure 8 demonstrates how clients and servers would interact with HTTP for client-initiated requests and WebSockets for real-time updates in our system.

Image represents a simplified architecture diagram illustrating a communication flow between two mobile devices, labeled 'Sender' and 'Receiver,' mediated by a central 'Backend services' component.  The 'Sender' device, depicted as a smartphone icon within a rounded rectangle, initiates communication using the HTTPS protocol (indicated by the label 'https' on the connecting arrow) to send data to the 'Backend services.' This central component, also represented within a rounded rectangle, processes the received data. Subsequently, the 'Backend services' utilize the WebSockets protocol (labeled 'wss' on the connecting arrow) to forward the processed data to the 'Receiver' device, another smartphone icon within a rounded rectangle.  The arrows indicate the unidirectional flow of information from the sender, through the backend, to the receiver.

βœ… Decisions made!

  • HTTP with REST APIs for sending messages and other client-initiated operations.

  • WebSocket for real-time features, where the server needs to push updates to clients.

  • Careful scope limitation of WebSocket usage to manage scaling complexity.

Endpoints and data models

Now that we've chosen our communication protocol and data format, let's dive into the specific endpoints and data models we'll need.

Client-initiated requests

For sending messages, we can define the following endpoint:

Code
Authentication: Bearer <token>
POST /v1/messages
  Body: NewMessageRequest
  Response: 201 Created. Payload of type NewMessageResponse

KotlinSwift
data class NewMessageRequest
requestId: Long
toUserId: Long
content: String
createdAt: String
struct NewMessageRequest
requestId: Int64
toUserId: Int64
content: String
createdAt: String
data class NewMessageResponse
message: Message
fromMessageRequestId: Long?
struct NewMessageResponse
message: Message
fromMessageRequestId: Int64?

The requestId field in NewMessageRequest acts as an idempotency key to allow the backend to de-duplicate requests when the same request is accidentally sent multiple times, and the optional fromMessageRequestId field in NewMessageResponse helps match a Message with the corresponding NewMessageRequest.

We could also define another endpoint for syncing new messages when the app initializes:

Code
Authentication: Bearer <token>
GET /v1/messages?lastSyncedMessage=`{messageId}`&limit=`{limit}`
  Body: empty
  Response: 200 OK. Payload of type SyncMessagesResponse

Data models

The main application data model in our system is the Message. Here's what it looks like:

KotlinSwift
data class Message
messageId: Long
fromUserId: Long
toUserId: Long
content: String
status: MessageStatus
createdAt: String
struct Message
messageId: Int64
fromUserId: Int64
toUserId: Int64
content: String
status: MessageStatus
createdAt: String
enum class MessageStatus PENDING, SYNCED, DELIVERED, READenum MessageStatus case pending, synced, delivered, read

The status field drives what message indicator the UI displays on the screen and contains the following values:

  • PENDING: The backend hasn't received the message yet.
  • SYNCED: The backend has received the message.
  • DELIVERED: The backend has delivered the message to the recipient.
  • READ: The recipient has read the message.

Another key data model is the ConversationPreview, which represents what users see in the Chat list screen. It's worth noting that the following data models related to client app screens are private to the client. The backend doesn't concern itself with how the client displays messages.

KotlinSwift
data class ConversationPreview
contact: User
lastMessageSummary: String
lastMessageTimestamp: String
unreadCount: Int
struct ConversationPreview
contact: User
lastMessageSummary: String
lastMessageTimestamp: String
unreadCount: Int
data class User
id: Long
name: String
avatarUrl: String
lastConnectedAt: String
struct User
id: Int64
name: String
avatarUrl: String
lastConnectedAt: String

The ConversationPreview is a lightweight model containing just enough information to display a conversation in the Chats list. When a user taps on one of these items, it opens the Conversation screen with that user. Since we can only have one conversation per user, the User's id serves as a unique identifier for each conversation.

When the user opens the Conversation screen, the app loads the full Conversation data type, which contains more detailed information about the chat:

KotlinSwift
data class Conversation
contact: User
contactInfo: ContactInfo?
unreadCount: Int
messages: List<Message>
struct Conversation
contact: User
contactInfo: ContactInfo?
unreadCount: Int
messages: [Message]
data class ContactInfo
onlineStatus: String
typing: Boolean
struct ContactInfo
onlineStatus: String
typing: Bool

Real-time features

For the real-time features powered by WebSocket, we use a Uniform Resource Identifier (URI) format similar to HTTP URLs. While HTTP URLs begin with http:// or https://, WebSocket URIs start with ws:// for standard connections or wss:// for secure, encrypted ones. For our chat backend service, we'll use the following URI: wss://my-api.chat.com/v1/socket/updates.

πŸ“Œ Remember!

We include versions in our API endpoints for good reasons. This practice gives us the flexibility to introduce breaking changes or major new features in newer versions while keeping older versions stable. It also allows us to deprecate outdated APIs in a controlled manner. For a deeper dive into API versioning, refer back to Chapter 10: Mobile System Design Building Blocks.

When sending data over the Websocket, we use JSON in the following format:

Code
{
  "type": "message_type",
  "payload": {}
}

Each event consists of two main parts: a type field that specifies the event type (as defined earlier), and a payload field containing the actual event data as a JSON object. The content of the payload varies depending on the message type.

Once the WebSocket connection is established, the backend can send real-time updates like the following:

  • new_message: New message for the client.
  • message_read: A message has been read by the recipient.
  • contact_status_update: Update on the contact's status, including online status, typing indicators, or other relevant information.

Step 3: High-level client architecture

Now that we've established the API design, let's explore the high-level client architecture. We'll focus on:

  • External server-side components.
  • Client architecture components for UI and data layers.
  • Data storage.

Figure 9 shows the high-level architecture of the chat system.

Image represents a system architecture diagram for a mobile chat application.  The diagram is divided into a client-side (left) and server-side (right). The client-side, labeled 'Client,' contains a 'UI layer' with components 'Chat List Screen,' 'Conversation Screen,' 'Chat List State Holder,' and 'Conversation State Holder,' all coordinated by a 'Navigator.'  These UI components interact with a 'Data layer' containing 'Conversations Repository,' 'Messages Repository,' and 'Users Repository.' The 'Messages Repository' interacts with 'Messages Local DataSource' which in turn interacts with a 'Chats DB.'  The data layer also includes 'Messages Remote DataSource' and 'Real time updates Remote DataSource,' which communicate with the server-side. The server-side includes a 'Backend' which communicates with a 'CDN' via HTTPS for image loading ('Image Loader' on the client side). The 'Backend' also interacts with a 'Push Notifications Provider' (a third-party service) via HTTPS for push notifications, which are received by a 'Push Notifications Client' on the client side.  Data flows between the client and server primarily via HTTPS and WebSockets (WSS) for real-time updates.  The client-side uses dependency injection between the UI and Data layers.

πŸ“ Note: The components identified in the chat system don't differ much from those identified in the previous chapter when designing a News Feed. Thus, we'll go through this section quickly. If you have questions, revisit Chapter 3: Design a News Feed App.

External server-side components

Our system relies on the Backend, CDN, and the Push Notifications Provider components.

The third-party Push Notifications Provider allows us to reach users even when they're not actively using the app, handling the complex infrastructure needed for reliable notification delivery. We'll explore push notifications in more depth during our deep dive section.

Client architecture

Building on the foundation laid in previous chapters, we choose a layered architecture that follows Unidirectional Data Flow (UDF), reactive programming, and other key principles, such as separation of concerns, to ensure our app is robust, performant, and scalable.

Data layer

The Message is the central type of application data in the chat app. To handle this effectively, we've incorporated various data layer components, with the Messages Repository at the core, managing all read and write operations. Similarly, we've included a Users Repository to handle user-related information such as names and profile images.

The Messages Repository works with both local and remote data sources. While client-initiated requests flow through traditional HTTP endpoints via the Messages Local DataSource component, the dedicated Real time updates Remote DataSource component maintains a persistent WebSocket connection to receive real-time updates from the backend. This dual approach ensures that the system stays responsive to both user actions and server events.

The Push Notifications Client component serves as a listener for notifications from the external provider.

UI layer

The UI layer is built around the app's main screens: the Chat List and Conversation. To optimize performance, we have centralized image loading into a single Image Loader component, which fetches data from the content delivery network (CDN).

The Message List is a key component. It displays all messages of a conversation in chronological order, including those that failed to send or are still pending. Failed messages are indicated on the UI, and a resend option is provided for each message.

Data storage

Let's explore the best way to store messages in the chat app. While mobile platforms offer simple storage options such as SharedPreferences on Android and UserDefaults on iOS, these key-value stores aren't the right fit for our needs. They work well for small pieces of data such as user preferences, but they weren't designed to handle growing message histories or the complex relationships between messages, conversations, and users. As message volume increases, these simple storage solutions would slow down and make it difficult to search through conversations effectively.

A relational database is a good choice for our chat app. Messages are naturally structured data: each message belongs to a conversation, has an author, and follows a clear format.

πŸ“ Note: For a deeper dive into comparing different storage alternatives, refer to the Data Storage section in Chapter 10: Mobile System Design Building Blocks.

Relational databases excel at managing these relationships while providing three crucial benefits:

  1. They handle complex queries efficiently. When users search their message history or filter conversations, the database can quickly find and return the right messages, even as the history grows large.
  2. They ensure data consistency and provide ACID properties (Atomicity, Consistency, Isolation, and Durability). When multiple messages are being sent and received simultaneously, the database maintains data integrity and prevents conflicts. This reliability is essential for a chat application.
  3. They support future growth. As we add features such as message reactions or thread replies, we can extend our database schema without disrupting existing messages. This flexibility lets our app evolve while keeping all message history intact.

These capabilities make relational databases ideal for handling the complex, growing, and interconnected nature of chat message data. While they may require more initial setup than simpler solutions, they provide the robust foundation we need for a reliable chat application.

Eviction policy

Managing local storage is crucial for our chat app since all conversations are stored on the device. While we could automatically manage storage through eviction policies or time-based deletion, putting users in control often provides a better experience. Popular messaging apps take different approaches: WhatsApp alerts users when storage runs low and guides them through freeing up space [14], while Slack automatically removes messages older than 90 days or a year for their free tier[15].

Similarly, we could implement one of the following options:

  • Implement eviction policies that remove messages and files based on specific criteria such as message date or attachment size.
  • Set up automatic deletion after a defined time period.
  • Let users manage their own storage by showing them usage stats and cleanup options.

πŸ’‘ Pro tip!

When designing mobile systems, certain decisions, such as how to handle message storage under memory constraints, often involve product teams. While you can propose different approaches and analyze their trade-offs during the interview, it's wise to first check with you interviewer on whether these product-level decisions are within scope before diving into implementation details.

For our chat app, we can suggest a user-centric storage management strategy if the interviewer is okay with that decision. Our strategy works as follows:

  1. Monitor available device storage through periodic checks.
  2. Proactively alert users when space runs low, offering clear options to delete older messages, remove entire conversations, or clean up other app data.

By giving users visibility and control over storage management, we help them make informed decisions about their chat history while preventing the app from consuming too much space.

βœ… Decisions made!

  • We choose a relational database to persist message data on disk.

  • We alert users when storage runs low.

Step 4: Design deep dive

Now that we've established the client-backend communication protocol and outlined the client architecture, it's time to dive deeper into some key areas. We'll concentrate on the following five critical areas:

  • Message ordering on the screen.
  • Sending messages.
  • DB implementation details.
  • Push notifications.

Message ordering on the screen

Message ordering is fundamental to chat apps. It's what makes conversations feel natural and easy to follow. When messages appear in the wrong order, users can quickly become confused and frustrated. What seems like a simple requirement, showing messages in chronological order, actually involves several challenging edge cases on mobile devices:

  • Network delays can cause messages to arrive out of sequence.
  • Offline messages need to be integrated once users reconnect.
  • Different devices may have mismatched system clocks.
  • Both past messages and new updates must maintain consistent ordering.

Message lifecycle

Let's explore how messages flow through our system to make a better decision on screen message ordering. Figure 10 illustrates this process:

  1. The Messages repository immediately creates and stores new messages, and the UI displays them on screen right away to provide instant feedback.
  2. Behind the scenes, the repository forwards this message to the Messages remote data source, which packages it as a NewMessageRequest and sends it to the backend via HTTP.
  3. The backend processes this request, generating a unique messageId for the message.
  4. The backend then takes two parallel actions: (step 4.1) sends a 201 response back to the sender with a NewMessageResponse object, and (step 4.2) notifies the recipient, either through a WebSocket if they are active, or a push notification if they are inactive.
  5. Once the sender's client receives the HTTP response, it updates the message in local storage with the new messageId. Lastly, the UI refreshes to show successful delivery.
Image represents a sequence diagram illustrating the process of sending a message in a mobile application.  The diagram shows two mobile devices, labeled 'Sender' and 'Receiver,' interacting with a 'Backend' server.  The 'Sender' initiates the process (1) by creating and displaying a new message.  Then, it sends an HTTP request labeled 'NewMessageRequest' (2) to the 'Backend.' The 'Backend' processes the message, assigns a new message ID (3), and replies to the 'Sender' with a 'NewMessageResponse' (4.1). The 'Sender' then updates its screen to reflect the message status (5).  Concurrently, the 'Backend' pushes the new message to the 'Receiver' using a WebSockets (WS) event or a push notification (4.2).  The arrows indicate the flow of information and actions between the components.  The numbered annotations describe each step in the process.

Message ordering

In steps 1 and 5 of Figure 10, the client needs to display the message on the screen. To implement reliable message ordering in our client app, we need to make a key design decision: what field should we use to sort messages on the conversation screen?

The Message data model provides two potential fields for ordering: the messageId assigned by the server when the message is processed and the createdAt timestamp set by the client when the message is created. Table 2 explores trade-offs between different approaches.

Trade-offs for ordering messages in the client
Order byAdvantagesDisadvantages
messageIdA server-generated ID ensures consistent ordering across clients.
Backend remains the source of truth.
The client doesn't have the messageId until the backend generates it.
Doesn't support offline messaging effectively.
createdAt timestampLow latency. Immediate message display without waiting for the backend.
Supports offline messaging.
Vulnerable to inconsistencies from device clock differences.
Susceptible to client-side manipulation.
createdAt timestamp with client-backend clock synchronizationCombines timestamp benefits with improved reliability.
Reduces ordering issues through synchronization, for example, via WebSocket events or Meta's Simple Precision Time Protocol [16].
Clock drift between devices can still cause problems.
Complex to implement accurately across diverse networks.

Table 2: Trade-offs for ordering messages in the client

While synchronizing client and backend clocks could work, it would add significant complexity and cost to our system. Instead, we can mitigate the downsides of messageId and createdAt approaches by combining them:

  • Messages that have been successfully sent to and acknowledged by the server are ordered by messageId.
  • Messages that are still waiting to be sent or are in the process of being sent are ordered by createdAt timestamp and always appear at the end of the message list.

Most popular chat apps use a server-assigned ID to sort messages on the client side. While some apps publish detailed documentation about it, such as Discord's Snowflake [17] id approach [18], others, such as WhatsApp with its id field [19], Facebook Messenger with mid [20] or Slack with ts [21], are more opaque about their specifics. How to order pending messages is a product decision, and apps might decide to implement it in different ways.

βœ… Decisions made!

The Conversation screen uses a hybrid approach to order messages on the screen:

  • Messages that have been successfully sent to and acknowledged by the server are ordered based on a server-generated messageId.

  • Messages that are still waiting to be sent or are in progress are ordered based on the client-generated createdAt timestamp and always appear at the end of the message list.

messageId responsibilities

In our system, the messageId plays a crucial role in maintaining the correct order of messages. They're designed to be sortable by time, ensuring that newer messages always have higher IDs than older ones. The backend is the source of truth for generating these IDs to ensure data integrity, reliable delivery, and consistent message ordering.

While this might seem straightforward, creating these IDs correctly can be tricky, especially for a large-scale system. That's why we typically delegate this responsibility to the backend. Servers are better suited to quickly and accurately generate unique, time-ordered IDs for millions of messages.

Sending messages

Sending messages reliably in a mobile app isn't always straightforward. Network issues can prevent messages from reaching their destination. To handle this challenge, we need a way of tracking different message states: messages not yet sent, messages in transit, and messages that failed to send.

The data layer can model those states with the following MessageRequest data model. Just before sending the message, the remote data source maps this into a NewMessageRequest object. This approach helps us handle various message scenarios and implement retry mechanisms if needed.

KotlinSwift
data class MessageRequest
requestId: Long
toUserId: Long
content: String
status: MessageRequestStatus
lastModifiedAt: String
lastSentAt: String?
failCount: Int?
struct MessageRequest
requestId: Int64
toUserId: Int64
content: String
status: MessageRequestStatus
lastModifiedAt: String
lastSentAt: String?
failCount: Int?
enum class MessageRequestStatus
DRAFT, PENDING, SENT, FAILED
enum MessageRequestStatus
case draft, pending, sent, failed

The status field tracks the message's current state:

  • DRAFT: The message is created but not yet sent by the client.
  • PENDING: The message is queued and ready to be sent to the backend.
  • SENT: The client has delivered the message to the backend via a send_messages event, but it hasn't received a delivery acknowledgment as part of the messages event.
  • FAILED: Either the server reported a failure for this message or the request timed out.

πŸ“ Note: You might notice there's no SUCCEEDED status in our MessageRequest model. This is by design. When the server acknowledges a message's delivery, we don't simply update the status. Instead, we remove the MessageRequest entirely and create a new Message object with a RECEIVED status. The NewMessageResponse model contains the information we need for this conversion, including the backend-generated messageId and the fromMessageRequestId.

Similarly, you'll never see a Message with a PENDING status in our local data source. The PENDING state is exclusive to message requests. When displaying messages in the UI, we combine both message requests and processed messages.

Sending messages data flow

Let's explore how data flows through the system when a message is sent successfully. We already saw Figure 10 with the high-level process; Figure 11 illustrates the detailed data flow when the backend confirms message delivery:

  1. The Messages Remote DataSource gets a success response from the backend.
  2. The Remote DataSource passes this information to the Messages Repository.
  3. The Messages Repository then performs two database operations:
    • Removes the temporary message request from storage.
    • Creates a new Message entry with the confirmed ID from the backend.
  4. These database changes automatically trigger updates that flow through the app, showing the user their message was delivered successfully (steps 4-7).
Image represents a system architecture diagram for a messaging application.  The diagram shows data flow between several components.  At the bottom, a 'Chats DB' database is accessed by two Data Access Objects (DAOs): 'Message Request DAO' and 'Message DAO,' which interact with a 'Messages Local DataSource.'  This local data source interacts with a 'Messages Repository' (3, 4).  The 'Messages Repository' receives data from a 'Messages Remote DataSource' (2), which in turn receives 'Message received' from a 'Backend' component (1).  The 'Messages Repository' also feeds into a 'Conversations Repository' (5), which updates a 'Conversation State Holder' (6). Finally, the 'Conversation State Holder' is connected to a 'Conversation Screen' (7), representing the user interface.  Numbers (1-7) indicate the order of data flow or interaction between components.

The process works similarly for read receipts. When someone reads a message:

  1. The backend sends a read_messages event through the WebSocket connection.
  2. The client's Real-time Updates Remote DataSource receives the event.
  3. It notifies the Messages Repository about the read status change.
  4. The Repository updates the message status in the local database.
  5. This change then flows through to the UI, updating the read status indicators.

Message delivery failures

While our previous example showed a successful message delivery, real-world scenarios aren't always smooth. Sometimes, we might never receive a response for a particular NewMessageRequest. This can happen due to unstable network connections or other unforeseen issues.

When a request fails or times out, we don't give up immediately. Instead, we update the MessageRequest status to FAILED and increment its failCount. This tracking allows us to decide whether to keep retrying with exponential backoff or stop retrying completely after a certain number of failed attempts.

πŸ“Œ Remember!

Exponential backoff is a retry strategy whereby the delay between attempts is progressively increased. For instance, if we start with a one-second delay, the next retry might wait two seconds, then four seconds, and so on. This approach avoids overwhelming our system with rapid-fire retries and gives the network a chance to recover.

We don't want to retry forever, so we set a maximum retry limit. If a message fails too many times, we automatically stop trying, mark it as permanently failed, and notify the user, offering them options to retry manually or delete the message.

βœ… Decision made! We use an exponential backoff algorithm to retry failed messages.

Data layer design update

The new functionality we've introduced to handle failures, reconnections, and message requests has significantly impacted our client architecture. Figure 12 illustrates these changes in the updated data layer.

Image represents a data layer architecture for a messaging system.  The system is composed of several interconnected components. At the bottom is a `Chats DB` database, which interacts with `Message DAO` and `Message Request DAO` components. These DAOs, in turn, feed into `Messages Local DataSource`.  `Messages Local DataSource` and `Messages Remote DataSource` both connect to a central `Messages Repository`.  The `Messages Repository` also receives data from `Pending Messages Provider`, which itself receives data from `Message Request DAO`.  `Messages Remote DataSource` communicates with a `Backend` via `https`.  Finally, a `Messages Scheduler` component interacts with `Messages Remote DataSource` and `Pending Messages Provider`, and is controlled by a `Messages Timeout Controller`.  Data flows primarily from the database, through the DAOs and data sources, to the repository, then to the scheduler, and finally to the backend.  The `Pending Messages Provider` acts as a buffer for messages awaiting processing or transmission.

To streamline the Messages remote data source and prevent it from becoming overburdened, we've introduced several new components, each with specific responsibilities:

  • The Messages Scheduler component orchestrates message sending from a background thread. It batches messages when appropriate and manages retries for failed messages, working in tandem with the Messages Timeout Controller.
  • The Message Timeout Controller component monitors timeouts for messages awaiting backend confirmation.
  • The Pending Messages Provider component retrieves message requests that are either pending or failed to send from the local data source. Following dependency injection best practices, the actual implementation would rely on the Messages Local Data Source.

By introducing more components and reducing the complexity of the Messages Remote Data Source, we enhance the overall readability and maintainability of the system.

πŸ” Industry insights:

Airbnb developed a message synchronization mechanism to reduce network requests and data inconsistencies, resulting in faster inbox loading and an improved user experience, particularly in areas with slow network connections [22].

Facebook Messenger's clients handle offline messaging through an orchestrated sync service. In the Messenger β€œLightspeed” rebuild, Facebook introduced a module called MSYS (Messenger SYnc System) to manage local data and tasks [23].

DB implementation details

When it comes to implementing our database, SQLite stands out as an excellent choice for mobile devices. It's a lightweight yet powerful relational database system that can be easily embedded into mobile operating systems. SQLite's support for complex queries and transactions makes it well-suited for our chat application's needs.

πŸ› οΈ Platform implementation details

Apple's CoreData can use SQLite as its underlying storage mechanism. You might also want to consider other open source alternatives such as SQLite.swift [24] or FMDB [25]. On Android, the most popular choices are Google's Jetpack Room [26] and Square's SQLDelight [27].

To maximize performance, we'll need to carefully structure our data models. This means optimizing our entity-relationship models to better align with how SQLite operates. We'll store regular messages in the Messages table and unsynced message requests in the MessageRequests table. This separation is important because unsynced requests don't have a Message id yet.

SQLite statements

Here are the instructions to define the tables in SQLite:

Code
CREATE TABLE Users (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  avatar_url TEXT,
  last_connected_at TEXT,
)
Code
CREATE TABLE Messages (
  id INTEGER PRIMARY KEY,
  from_user_id INTEGER NOT NULL,
  to_user_id INTEGER NOT NULL,
  content TEXT NOT NULL,
  status TEXT NOT NULL,
  created_at TEXT NOT NULL,
  FOREIGN KEY (from_user_id) REFERENCES Users(id),
  FOREIGN KEY (to_user_id) REFERENCES Users(id)
);
Code
CREATE TABLE MessageRequests (
  request_id INTEGER PRIMARY KEY,
  to_user_id INTEGER NOT NULL,
  content TEXT NOT NULL,
  status TEXT NOT NULL,
  last_modified_at TEXT NOT NULL,
  last_sent_at TEXT,
  fail_count INTEGER,
  FOREIGN KEY (to_user_id) REFERENCES Users(id)
)

πŸ“ Note: For the data model's IDs, we use an INTEGER in the SQLite statement even if that's represented as a Long in Kotlin or Int64 in Swift.

SQLite uses a dynamic type system based on type affinity, which means it associates storage classes with columns based on the declared data type. INTEGER is a type representing a signed integer, stored in 1, 2, 3, 4, 6, or 8 bytes. Any type name that contains the string "INT" (e.g., INT, INTEGER, BIGINT, SMALLINT) will have an INTEGER affinity.

Data layer updates

Figure 13 illustrates the updates to our data layer design. We've represented the three newly created tables as distinct Data Access Object (DAO) components. To separate concerns, we split the different types of application data (messages and user information) into their own repositories.

Image represents a data layer architecture diagram for a chat application.  The diagram is enclosed within a dotted-line box labeled 'Data layer'. At the bottom is a database cylinder labeled 'Chats DB,' which serves as the persistent storage.  Above it are three Data Access Objects (DAOs): 'User DAO,' 'Message DAO,' and 'Message Request DAO,' each connected to the database with a downward-pointing arrow indicating data retrieval and persistence.  The 'User DAO' connects upward to 'Users Local DataSource,' which in turn connects to a 'Users Repository.'  Similarly, 'Message DAO' and 'Message Request DAO' connect upward to 'Messages Local DataSource,' which connects to a 'Messages Repository.'  Arrows between 'Users Repository' and 'Conversations Repository' and between 'Messages Repository' and 'Conversations Repository' show data flow between these repositories, likely representing the relationship between users, conversations, and messages.  An upward-pointing arrow from the 'Conversations Repository' suggests data might be flowing out of this layer to a higher layer (not shown) in the application architecture.

and local data sources-related components

It's important to note that while we've split our message storage across different tables, this shouldn't impact the overall data flow of the application. We hide the fact that messages are split across two tables behind the data layer. The repository merges data from Messages and MessageRequests, so higher layers of the app see a unified conversation list and don’t need to know about the split.

Challenges

Storing messages locally on a device for a chat application sounds straightforward, but it comes with its own set of hurdles. Let's explore these challenges and discuss practical solutions:

Managing table dependencies and schema initialization

When working with multiple SQL statements, whether they're included in the same or different SQL script files, the execution order matters. The Users table must be created before other tables since they depend on it. Keeping all these CREATE TABLE statements in a single SQL script file helps prevent execution order issues and makes the database setup more reliable.

For simplicity, consistency, and data integrity, we've chosen to consolidate all three tables (Users, Messages, and MessageRequests) within a single SQLite database file named chats.db.

While separating Users into its own database may promote modularity or support future cross-app reuse, this approach introduces several complications:

  • Foreign key constraints across separate database files are not enforceable, so referential integrity must be maintained manually in the application layer.
  • Managing multiple databases in the same app requires careful orchestration, especially when setting up the database in different environments (development, testing, production).
  • Separate databases increase the likelihood of incorrect execution order, which can result in schema setup failures. Debugging these issues can be time-consuming and frustrating.

By keeping all tables in a single chats.db database file, we've opted for simplicity and reliability in our current scenario. This choice allows us to focus on other aspects of the system design without introducing unnecessary complexity in our database setup.

βœ… Decisions made!

  • The Users, Messages, and MessageRequests tables are kept in the same chats.db database.

  • CREATE TABLE statements are part of the same SQL script file.

Size and performance

As your chat app grows, the database can become large and slow down app performance. Both Android and iOS have limits on efficient data storage and access.

To address these performance challenges, we could:

  • Split large tables into smaller ones. For example, divide the Messages and MessageRequests table by user IDs, where each logged-in user's messages are stored in their own database file (e.g., chats-currentUserId.db). This helps manage database size and improve query performance for the current user's data.
  • Add indexes to frequently searched columns, such as to_user_id. But be careful, while indexing speeds up searches, it can slow down adding new data.
  • Batch transactions by grouping multiple database writes into a single transaction instead of performing them individually. This reduces disk I/O operations and enhances throughput.
  • Set up proper error handling with retry mechanisms for temporary failures, especially during high load periods.
  • Use a connection pool to manage concurrent database access efficiently in case multiple threads access the database simultaneously. This prevents bottlenecks and ensures efficient resource usage.

πŸ“Œ Remember! Mobile clients should perform all database operations on a background thread to prevent blocking the UI thread and ensure a responsive user experience.

Push notifications

Our design requirements include implementing push notifications to alert users about new messages when they are offline. We've already outlined the key components for push notifications in our high-level architecture diagram. Figure 14 illustrates the various elements involved in the push notification system. Let's dive deeper into how these components interact and explore the platform-specific approaches for Android and iOS.

Image represents a system architecture diagram focusing on push notifications.  The diagram shows a 'Data layer' containing a 'Messages Repository' and a 'Push Notifications Client'. The 'Push Notifications Client' maintains a 'Persistent TCP connection' with a 'Push Notifications Provider' which is labeled as 'Third Party'.  The 'Backend' component communicates with the 'Push Notifications Provider' via an 'https' connection.  Information flow is unidirectional from the 'Backend' to the 'Push Notifications Provider' and bidirectional between the 'Push Notifications Client' and the 'Messages Repository'.  The 'Messages Repository' likely stores the notification messages, the 'Push Notifications Client' receives notifications via the persistent TCP connection, and the 'Push Notifications Provider' handles the actual delivery of these notifications to end-users.  The 'Backend' is responsible for sending notifications to the 'Push Notifications Provider'.

Sending push notifications from the backend

The backend triggers a push notification when a message is sent to an inactive user, that is, one without an active WebSocket connection to our servers. To deliver these notifications, the backend sends HTTPS requests to an external Push Notifications Provider. This provider is Firebase Cloud Messaging (FCM) for Android devices and Apple Push Notification Service (APNs) for iOS.

To correctly route notifications, the backend maintains a mapping between users and their devices. This mapping is created during the app installation process, where each device registers for push notifications in the background. The backend then stores a unique token for each user's device, allowing for targeted notification delivery.

πŸ“Œ Remember!

There are many types of push notifications you can send from the backend, for example, alert notifications that immediately alert users; silent notifications that are processed in the background without letting the user know; and rich media notifications that can include images, videos, or other media.

In our case, we send alert notifications to keep the user informed regarding how many unread messages they have and the message content.

Receiving push notifications

Android and iOS devices maintain a low-power, persistent TLS-encrypted TCP connection to FCM and APNS servers, respectively. This approach eliminates the need for individual apps to manage their own connections, which would be resource-intensive. When a notification arrives, the device either wakes up the relevant app or displays the notification if the app isn't running.

πŸ› οΈ Platform implementation details

On Android and iOS, the system maintains a persistent connection to handle push notifications efficiently. Android uses Google Play Services for this purpose, while iOS relies on a background service called the push notification daemon.

For Android developers, implementing push notifications involves creating a service that extends FirebaseMessagingService. iOS developers, on the other hand, work with the UNUserNotificationCenterDelegate protocol, typically integrating it into the app's UIApplicationDelegate.

The handling of notifications differs based on the app's state:

  • If the app is in the foreground, the Push Notifications Client processes the notification directly and calls the Messages Repository to sync messages.
  • If the app is in the background or terminated, the device shows the notification in the system tray. When the user taps on it, the system brings the app to the foreground, allowing the Push Notifications Client to access the notification payload.

Challenges with push notifications

While push notifications are a powerful feature, they come with several challenges that developers need to navigate:

  • FCM, APNs, or even the device itself might throttle notifications, especially in power-saving modes where the system may delay delivery, limit network access, or batch requests to conserve battery.
  • Apps must obtain user consent to send notifications. If users decline or later opt out through device or app settings, your app loses this communication channel.
  • Android and iOS use distinct APIs and workflows for push notifications. This necessitates platform-specific code and can result in inconsistent user experiences due to varying feature support and behaviors.
  • Push notifications often need to direct users to specific screens within the app. This requires implementing URL schemes or universal links. Restoring the appropriate app state based on these deep links can be tricky, particularly if the app wasn't already running.
  • Ensuring compatibility across various devices and OS versions is crucial but challenging. The asynchronous nature of push notifications can make debugging delivery issues particularly complex.

Given these potential pitfalls in message delivery, order, and overall complexity, our chat app uses push notifications primarily as a lightweight alert mechanism to notify users of new messages.

πŸ” Industry insights:

Slack's server-side infrastructure decides when and what to push. Every message posted in Slack goes through Slack's event pipeline, and a background job is enqueued to handle potential notifications. Slack's job queue system processes over a billion jobs per day, including the fan-out of push notifications [28].

Step 5: Wrap-up

In this chapter, we've designed a chat system. We used HTTP for client-initiated requests and WebSockets for real-time updates, including read receipts and users' online statuses.

We outlined the client architecture, defined the backend HTTP and WebSocket endpoints, and created the necessary data models. We also took a closer look at message ordering on the screen, how the client sends messages, the database implementation, and push notifications.

If you have extra time in your interview or want to challenge yourself with different requirements, consider these additional topics:

  • Online status or Presence information [29]: How WebSocket disconnections affect the user's online status and what role TCP half-open state [30] plays in this.
  • Group chat functionality: Design message creation and delivery for group settings. Think about implementing role-based permissions for group management [31] and adding user mention capabilities within group conversations.
  • Enhanced security: Implement database encryption to protect conversations from unauthorized access. You could also explore end-to-end encryption to ensure only senders and recipients can read messages [32].
  • Rich media support: Extend conversations with attachments such as images, videos, and location data. Address the challenges of larger file sizes through compression techniques and implement thumbnail generation for efficient media previews.
  • Message management: Design systems for editing and deleting messages. Consider how to track and display edit history for transparency, and implement selective message deletion (for sender only or all participants).
  • Search capabilities: Develop search functionality within individual conversations and create a global search feature across all chats [33].

References

[1] WhatsApp's message storage policy: https://www.whatsapp.com/legal/privacy-policy-eea
[2] WebSockets at Slack: https://api.slack.com/apis/connections/socket
[3] WebSockets at Slack: https://slack.engineering/rebuilding-slack-on-the-desktop/
[4] WebSockets at Uber: https://blog.bytebytego.com/p/how-uber-built-real-time-chat-to
[5] WebSockets at Uber: https://www.uber.com/blog/building-scalable-real-time-chat/
[6] WhatsApp uses a customized version of XMPP: https://en.wikipedia.org/wiki/WhatsApp
[7] Products using XMPP: https://xmpp.org/uses/instant-messaging/
[8] Facebook Messenger uses MQTT: https://engineering.fb.com/2011/08/12/android/building-facebook-messenger/
[9] Discord uses WebRTC: https://discord.com/blog/how-discord-handles-two-and-half-million-concurrent-voice-users-using-webrtc
[10] Mozilla uses IRC: https://developer.mozilla.org/en-US/docs/Glossary/IRC
[11] Slack Web API: https://api.slack.com/web
[12] Slack Socket Mode APIs: https://api.slack.com/apis/socket-mode
[13] Discord API: https://discord.com/developers/docs/events/gateway
[14] Whatsapp's policy on full device memory: https://faq.whatsapp.com/5503646096388294
[15] Slack's free version message storage policy: https://slack.com/trust/privacy/privacy-faq
[16] Simple Precision Time Protocol at Meta https://engineering.fb.com/2024/02/07/production-engineering/simple-precision-time-protocol-sptp-meta/
[17] Snowflake ID: https://en.wikipedia.org/wiki/Snowflake\_ID
[18] Discord's message API: https://discord.com/developers/docs/resources/message
[19] WhatsApp message API: https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages
[20] Facebook Messenger message API: https://developers.facebook.com/docs/messenger-platform/reference/webhook-events/messages\#messaging
[21] Slack message API: https://api.slack.com/events/message
[22] Messaging Sync β€” Scaling Mobile Messaging at Airbnb: https://medium.com/airbnb-engineering/messaging-sync-scaling-mobile-messaging-at-airbnb-659142036f06
[23] Facebook's Project LightSpeed: Rewriting the Messenger codebase:
htts://engineering.fb.com/2020/03/02/data-infrastructure/messenger
[24] SQLite.swift docs: https://github.com/stephencelis/SQLite.swift
[25] FMDB docs: https://github.com/ccgus/fmdb
[26] Google's Jetpack Room docs: https://developer.android.com/training/data-storage/room
[27] SQLDelight docs: https://github.com/sqldelight/sqldelight
[28] Scaling Slack's Job Queue: https://slack.engineering/scaling-slacks-job-queue
[29] Online status or Presence information: https://en.wikipedia.org/wiki/Presence\_information
[30] TCP half-open: https://en.wikipedia.org/wiki/TCP\_half-open
[31] Role-based access control: https://en.wikipedia.org/wiki/Role-based\_access\_control
[32] End-to-end encryption: https://faq.whatsapp.com/820124435853543
[33] Full-text search: https://en.wikipedia.org/wiki/Full-text\_search

CHAPTER 05

Stock trading app

05

Stock trading apps tackle some unique technical challenges. They're not just another app. They're fast-moving platforms where timing is everything, and getting accurate, real-time information to users immediately can make a huge difference in their financial decisions. Figure 1 shows some of the most popular stock trading apps in the industry.

Image represents a collection of logos for different online brokerage firms, arranged in two rows.  The top row features, from left to right: E*TRADE's purple and teal arrow logo with the text 'E*TRADE' beside it; Robinhood's green app icon depicting a stylized leaf with the text 'Robinhood' to its right; and Trade Republic's black, wavy logo with the text 'Trade Republic' to its right. The bottom row displays, from left to right: Charles Schwab's light blue square logo with the text 'Charles Schwab' beside it; DEGIRO's light blue horizontal double-bar logo with the text 'DEGIRO' to its right; and eToro's green logo featuring a stylized 'e' flanked by bull horns, with the text 'eToro' to its right.  There are no connections or information flow depicted between the logos; they are simply presented as a visual list of different brokerage platforms.

The core challenge lies in managing high-frequency data streams. Every price change and market shift needs to reach users with minimal delay, as even a few seconds of latency could mean missed opportunities.

And it's not just about being fast. These apps handle real money and sensitive financial information, so security and reliability must be robust. We need to build in strong security, dependable APIs, and make sure we're following financial regulations to keep things running smoothly and safely.

Step 1: Understand the problem and establish design scope

To figure out what we're building, let's picture a quick conversation with an interviewer. This back-and-forth helps us define the app's main features and set some clear boundaries.

Candidate: Before we dive in, I'd like to understand the scope of the trading functionality we're building. What's our stock trading app focusing on? Just stocks, or are we considering other instruments, such as options and futures too?
Interviewer: Let's keep it straightforward for now and focus on stocks.

Candidate: Sounds good. I'm thinking of key features such as placing and canceling orders, viewing portfolio details, showing real-time prices, and historical data. Does that cover it?
Interviewer: Pretty much! Let's also add a simple chart for historical stock trends. It'll give users a better sense of what's happening.

Candidate: How about push notifications for price alerts? And what kind of scale are we looking at? Big user base across multiple markets?
Interviewer: Definitely add notifications. For scale, we're looking at around 500k daily active users across different markets. The app needs to handle market data for thousands of stocks with real-time price updates.

Candidate: Given the focus on security and compliance, I propose minimizing sensitive data storage on the device and implementing robust security measures. Should we include authentication in the scope?
Interviewer: Good call. Let's assume users are already authenticated. Tech-wise, consider this a greenfield project.

Requirements

Based on that discussion, here's what our stock trading app needs to deliver:

  • Users can see their portfolio with live prices of stocks.
  • Users can buy and sell stocks by placing and canceling orders.
  • Users can see historical stock data in charts.
  • The system sends price alert notifications.

As for non-functional requirements, we need to build a system that ensures:

  • Real-time data: Our system must push market updates to users with minimal delay, ensuring they have the most current information when making trading decisions.
  • Performance: The UI must remain smooth even while handling streaming data and complex visualizations, and trades need to execute quickly and reliably.
  • Security: Financial data must be protected at all times, with strong authentication mechanisms and full compliance with financial regulatory standards.

Features that are out of scope for this exercise:

  • Options and futures trading.
  • User registration.

UI sketch

Let's think about how the app will look. Figure 2 lays out the main screens:

  • The Home screen (left) shows users their financial snapshot, portfolio, and recent orders at a glance.
  • The Stock Detail screen (center) digs into a specific stock, with historical data and trading options.
  • The Place Order modal (right) provides a streamlined experience for users to buy or sell.
Image represents a three-screen mobile application workflow for stock trading. The first screen displays the user's portfolio, showing 'Funds:', a portfolio performance graph indicating upward ('Portfolio ↑') and downward ('↓') trends for 'Stock 1' and 'Stock 2' respectively, and a 'Last orders' section listing executed and open trades with stock symbols and prices.  A curved arrow connects this screen to the second screen, indicating data transfer. The second screen focuses on a specific stock ('Stock 1'), displaying its current price ('$ x.yz'), a price graph, and empty fields (likely for order details).  'BUY' and 'SELL' buttons are present. A filled circle on the 'SELL' button indicates the current interaction point.  Another curved arrow connects the 'SELL' button to the third screen. The third screen shows the same stock ('Stock 1') and price ('$ x.yz'), its graph, and a shaded section for order confirmation. This section includes 'Market price: $x.yz', fields for 'Quantity' and 'Total value' (both empty for user input), and a 'SELL' button to finalize the transaction.  The data flow is unidirectional: portfolio information feeds into the stock detail screen, and the order details from the stock detail screen are passed to the order confirmation screen.

Step 2: API design

Now we'll establish how our client and backend will communicate. We'll focus on the communication protocol, client-backend interactions, and core data models.

Choosing the right communication protocol

The stock trading app requires two primary forms of interaction: standard client-server exchanges, such as submitting orders or retrieving portfolio details, and real-time updates, such as live stock price feeds. To meet these demands efficiently, we adopt a hybrid protocol strategy:

  • HTTP with REST APIs is ideal for client-initiated actions, such as placing orders, querying accounts, and retrieving historical data. REST offers a robust, scalable framework widely utilized in mobile development due to its structured request-response model.
  • WebSockets are a great option for delivering real-time data, including stock price changes and order status notifications. By maintaining a persistent, bidirectional connection, WebSockets enable the server to push updates to the client instantly, ensuring low-latency delivery of time-sensitive market information.

This combination provides a good balance between standard request-response patterns and continuous data streaming. Many trading platforms such as TradeRepublic [1] use this tech stack, confirming its effectiveness at scale.

Endpoints and data models

With the communication protocols established, let's dive into the specific endpoints and data models we'll need.

Stocks

These endpoints provide access to stock information and historical trends:

  • GET /v1/stocks/{symbol}
    retrieves detailed data for a specified stock, including current price, daily high/low, and percentage change.

  • GET /v1/stocks/{symbol}/history
    ?interval={interval}&period={period}
    supplies historical price data, with parameters allowing customization of time intervals (e.g., 1m for one minute, 1d for one day) and periods (e.g., 1wk for one week, 3mo for three months).

The corresponding data model is:

KotlinSwift
data class Stock
id: String
symbol: String
name: String
currentPrice: BigDecimal
changePercent: BigDecimal
highPrice: BigDecimal ...
struct Stock
id: String
symbol: String
name: String
currentPrice: Decimal
changePercent: Decimal
highPrice: Decimal
...

πŸ“Œ Remember:

For financial apps such as stock trading platforms, always use BigDecimal in Kotlin or Decimal in Swift instead of Float or Double types.

Floating-point types (Float/Double) can introduce subtle rounding errors that are unacceptable in financial contexts. BigDecimal provides exact decimal representation and precise arithmetic operations. While those operations are somewhat slower than primitive types, the guaranteed precision is essential for maintaining data integrity.

Portfolio and orders

These endpoints facilitate portfolio management and trading:

  • GET /v1/portfolio
    provides the user's current portfolio, detailing owned stocks and quantities.

  • GET /v1/orders?status={status}&page={page}&limit={limit}
    returns a paginated list of orders, filterable by status (e.g., open, executed, canceled).

  • POST /v1/orders
    creates a new buy or sell order.

  • DELETE /v1/orders/{orderId}
    cancels an existing order.

For order history, we implement offset pagination. This approach works well for this use case because:

  • Orders rarely change after creation.
  • Users typically focus on recent trading activity, and offset pagination provides efficient access to recent orders.

The data models are defined as:

KotlinSwift
data class Portfolio
stocks: List<PortfolioStock>
struct Portfolio
stocks: [PortfolioStock]
data class PortfolioStock
stock: Stock
quantity: BigDecimal
averagePrice: BigDecimal
struct PortfolioStock
stock: Stock
quantity: Decimal
averagePrice: Decimal
data class Order
id: String
stockSymbol: String
quantity: BigDecimal
status: OrderStatus
createdAt: String ...
struct Order
id: String
stockSymbol: String
quantity: Decimal
status: OrderStatus
createdAt: String ...
enum class OrderStatus
OPEN, EXECUTED, CANCELED
enum OrderStatus
case open, executed, canceled

Notifications

To support price alerts, we include:

  • GET /v1/notifications
    gets all created alerts.

  • POST /v1/notifications
    establishes a new price alert for a stock.

  • DELETE /v1/notifications/{id}
    deletes an existing alert.

WebSocket connection

For real-time functionality, we establish a WebSocket connection at wss://api.stocktrading.com/ws. This supports message types such as:

  • stock-prices: Streams live price updates for subscribed stocks.
  • order-updates: Reports changes in order status.
  • portfolio-updates: Reflects portfolio value shifts due to market fluctuations.

Clients subscribe to updates by sending events like:

Code
{
  "action": "subscribe",
  "channels": [
    {
      "name": "stock-prices",
      "symbols": ["AAPL", "GOOGL", "MSFT"]
    }
  ]
}

Step 3: High-level client architecture

With our API design in place, let's develop the high-level mobile architecture. Figure 3 illustrates this architecture, with arrows showing the flow of data through the system.

Image represents a mobile application architecture diagram.  The diagram is divided into a client-side and a server-side. The client-side, labeled 'Client,' contains a 'UI layer' with components: 'Home Screen,' 'Stock Detail Screen,' 'Place Order Modal,' and 'Image Loader.'  Below these are corresponding 'State Holders' for each screen, managing their respective application states.  These UI components interact with a 'Data layer' containing 'Portfolio Repository,' 'Stocks Repository,' and 'Orders Repository,' which in turn access 'Portfolio Remote Data Source,' 'Stocks Remote Data Source,' and 'Orders Remote Data Source' respectively.  The data flow is facilitated by 'Dependency Injection.'  The server-side includes an 'API Gateway' communicating with a 'Backend' and a 'CDN' (Content Delivery Network) via HTTPS. The 'API Gateway' uses HTTPS for communication with the client and WSS (WebSockets) for real-time updates with the 'Real Time Updates Dispatcher.' The 'Backend' also interacts with a 'Third Party Push Notifications Provider' via HTTPS to send push notifications, managed by the client-side 'Push Notifications Client.'  The 'HTTP Network Dispatcher' handles HTTP requests.  The 'Navigator' component in the UI layer manages navigation between screens.  Data flows from the remote data sources to the repositories, then to the state holders, and finally to the UI components.  The image loader fetches images from the CDN.

Let's see how the key components of our architecture interact with each other.

External server-side components

Before we get into the client itself, let's look at the external pieces it relies on:

  • The Backend talks to the client through REST APIs and WebSockets, managing requests and keeping data consistent.
  • The CDN speeds up access to static content such as historical data or chart assets by caching it near users.
  • The Push Notifications Provider keeps users informed with timely alerts for price changes or order updates even when the app's in the background.
  • The API Gateway is the gatekeeper that handles authentication and traffic, shielding the backend from overload or bad actors.

API Gateway

In our stock trading app, security is non-negotiable. We need robust authentication, careful request monitoring, and strict policy enforcementβ€”all while maintaining a seamless user experience. This is where the API Gateway [2] plays a crucial role.

Serving as our system's front door, the API Gateway validates and routes all incoming client requests to the appropriate backend services. It blocks any suspicious or unauthorized requests before they can reach our core systems.

The API Gateway handles several key responsibilities:

  • Security: It manages user authentication and authorization, implements rate limiting, and detects suspicious clients to protect our system.
  • Traffic management: It balances load across services and controls request volumes to prevent system overload during peak trading hours.
  • Request processing: It validates all incoming requests, transforms data formats when needed, and manages request/response headers to ensure proper communication.
  • Monitoring: It tracks API usage, performance metrics, and error rates to maintain system health and identify potential issues early.
  • Simplification: It provides a clean, unified API that shields mobile clients from the complexity of our backend architecture.

Client architecture

Our app follows a clean layered architecture that separates concerns between the UI and data layers, creating a more maintainable and testable codebase.

The UI layer consists of three primary screens, each backed by its own state holder to manage presentation logic:

  • The Home screen serves as the app's central hub, displaying the user's portfolio overview and recent trading activity.
  • The Stock Detail screen provides comprehensive information about individual stocks, including price charts and key metrics.
  • The Place Order modal offers a streamlined interface for executing buy and sell transactions.

Behind the scenes, three specialized repositories handle the core business data in the data layer:

  • The Portfolio repository serves as the single source of truth for the user's holdings, continuously synchronizing with real-time updates.
  • The Stocks repository manages market data, ensuring users always see current pricing information.
  • The Orders repository handles the creation, tracking, and execution status of all trading transactions.

Complementing these components, the Push Notifications Client component listens for important alerts from our backend, ensuring users receive timely updates about price movements, order completions, and other critical events.

Data storage

In our stock trading app, data freshness requirements vary across different types of application data:

  • For real-time market data, we'll implement in-memory caching only, avoiding disk storage. This approach:
    • Ensures users see the most current pricing for trading decisions.
    • Maintains the data integrity expected in financial applications.
  • For less time-sensitive information, such as historical charts and user preferences, we'll apply appropriate disk caching with clear expiration policies, balancing performance with accuracy.

This architecture provides a solid foundation for our stock trading app. Now let's dive deeper into some of the more complex aspects of our system.

Step 4: Deep dives

There are some unique aspects of our app that deserve special attention. Let's take a closer look at creating historical stock price charts and real-time stock data.

Creating historical stock price charts

Visualizing historical price data is essential in any stock trading app. Historical stock data typically comes in various time frame options such as daily, weekly, monthly, yearly, and max views; each offering different insights to traders as shown in Figure 4.

Image represents a mobile application screen displaying stock market information for Alphabet (GOOG).  The top section shows the company logo (G), ticker symbol (GOOG), company name (Alphabet), current stock price (172.79), change (2.86), percentage change (1.68%), and a note indicating the market is closed and the prices are in USD.  Below this, a tabbed navigation bar offers 'Overview,' 'Chart,' and 'Analysis' views. The currently selected 'Overview' tab shows a year-to-date performance summary (13.85% increase) and key financial metrics: Market Cap (2.11T), Day's Range (170.31 - 172.63), and 52-Week Range (147.98 - 222). The 'Chart' tab displays a line graph illustrating the stock's price fluctuation over different time periods (1D, 1W, 1M, 6M, 1Y, MAX), with corresponding price markers on the y-axis. A prominent green 'Trade' button is positioned below the 52W Range.  The bottom navigation bar provides icons and labels for 'Home,' 'Watchlist,' 'Portfolio,' 'Discover,' and 'Wallet' sections.  The top right corner features icons for notifications, menu options, and sharing.

from the eToro Android app

Data density considerations

Each time frame presents unique data handling challenges. For instance, a 1-day chart might require 390 individual data points (one per minute during a 6.5-hour trading day), while a 5-year chart could theoretically contain over 1,250 daily data points or more than 260 weekly points.

One key challenge when implementing historical charts is managing data density appropriately. More data points provide greater detail but come with performance costs:

  • High-density data (e.g., minute-by-minute prices over a month) can create rendering performance issues and excessive memory usage.
  • Too few data points might miss important price movements and lead to misleading visualizations.

πŸ” Industry insights:

Different stock trading apps provide different user experiences. Many of these product decisions influence how the charts are designed and displayed. If you're interested in this topic, here's more information on how charts work in the Robinhood app [3] and in eTrade [4].

Implementing the chart

Rendering historical stock charts on mobile devices presents unique challenges due to the combination of large datasets and limited screen space. Let's evaluate the different approaches available to us in Table 1:

OptionDescriptionAdvantagesDisadvantages
Native graphics renderingUsing platform graphics APIs (e.g., Android Canvas, iOS Core Graphics) to draw chart UI elements manually.Full control over visuals and performance. Smallest memory footprint. Direct hardware acceleration.Complex implementation requiring platform-specific expertise. Significant development time. Duplicate code across platforms.
Third-party librariesUsing established mobile charting libraries (e.g., MPAndroidChart [5] or ChartsOrg/Charts [6]) that provide ready-made components.Quick implementation with built-in features like zooming and gestures. Well-documented and maintained.Performance overhead compared to native solutions. Potential integration challenges with native components.
WebView-based renderingEmbedding web tools (e.g., D3.js, Chart.js) or professional offerings (e.g., TradingView [7]) in a WebView.Rich feature set from the web charting ecosystem. Consistent implementation across platforms.Performance overhead and higher memory usage. WebView/native integration challenges and less native feel.
Server-side rendering with Image APIGenerating charts server-side and delivering images to the client.Minimal client-side processing requirements. Works consistently across all devices.Limited interactivity. Network dependent. Higher server costs and bandwidth usage.

Table 1: Trade-offs for presenting historical stock price charts on the client

Given our business context with multiple clients sharing the backend, we'll implement charts using WebView-based rendering. This approach allows us to maintain consistent visualization across platforms while leveraging powerful web-based charting libraries optimized for financial data visualization.

Optimizing WebView-based historical chart implementation

Implementing charts through WebViews presents several important challenges we need to address.

Performance impact

While WebViews offer flexibility, using complex JavaScript charting libraries such as D3.js or ECharts can strain device resources. We can mitigate this by:

  • Isolating charts from high-priority flows, such as order execution.
  • Pre-processing data server-side to reduce client strain.
  • Streamlining updates to avoid redundant redraws.
  • Performing delta updates on the charts after the initial full load.

The historical stock data density also presents unique challenges. We should implement the following strategies:

  • Pre-process historical data before passing it to the WebView to reduce JavaScript processing time, especially for longer time frames with more data points.
  • Implement a phased loading approach for extended time periods, where lower-resolution data loads first, followed by higher-resolution details as needed.
Interaction and responsiveness

WebView-rendered charts may not match the smooth interactions of native implementations, particularly with complex user interactions. To ensure good performance, we:

  • Offload computations to the backend or background threads where feasible.
  • Validate performance across diverse devices.
Visual Integration

WebView components can sometimes feel disconnected from the native UI. To create a cohesive experience, we'll bridge this gap by:

  • Aligning chart styles with the app's design system.
  • Tuning WebView properties for a cohesive look.
  • Adhering to platform UI best practices for familiarity.
Platform-Specific Challenges

Each OS presents unique hurdles:

  • Android:
    • WebView updates independently from the app.
    • Behavior may vary across devices and Android versions.
    • Requires testing across different WebView implementations.
  • iOS:
    • WKWebView has specific performance characteristics.
    • Local storage persistence needs careful handling.
    • Memory management requires special attention.

By carefully considering these challenges during implementation, we can create chart experiences that balance functionality with performance while maintaining a native feel.

πŸ” Industry insights:

eToro opted to integrate the web-based TradingView [7] charting toolkit into their platform, delivering advanced interactive charts via a web component in their app.

Robinhood uses native drawing APIs to maximize performance and control on Android. They open sourced a custom Android library called Spark [8] for their sparkline charts.

Real-time stock data

Stock prices fluctuate rapidly during trading hours, often changing multiple times per second. To display these dynamic changes, stock trading apps typically use intraday charts such as the one shown in Figure 5.

Image represents a mobile application screen displaying real-time stock information for Alphabet (C).  At the top, a menu icon (three horizontal lines) is present, along with a 'Following' indicator (a checkmark) suggesting the user is tracking this stock. Below, the company name 'Alphabet (C)' is prominently displayed, followed by its current share price ('157.06 €') and a percentage change ('-2.08%') indicated in red, signifying a price decrease. A line graph immediately follows, showing the stock's price fluctuation over different time periods (1D, 1W, 1M, 1Y, Max), selectable by the user.  Below the graph, a 'About' section provides a brief description of Alphabet Inc. as a holding company, listing its main segments (Google Services, Google Cloud, Other Bets), with an expandable section for more details. Further down, the 'Share price' (157.06 €) and 'Company value' (1.902T €) are shown. Finally, at the bottom, three interactive buttons are presented: 'Save' (presumably to save the stock to a watchlist), a refresh button (a circular arrow), and 'Buy' (to initiate a purchase), accompanied by a plus icon suggesting adding to an existing order.  The overall layout is clean and intuitive, providing a concise overview of Alphabet's stock performance and relevant information.

from the Trade Republic Android app

To ensure consistency across our app, we'll use the same WebView-based approach for both historical and intraday real-time data visualization. This provides a unified visual experience while simplifying maintenance, as we only need to maintain expertise in one charting technology stack.

Design options for real-time chart updates

To integrate real-time data into our WebView chart implementation, we need a strategy for updating the chart efficiently. Each approach has different implications for device resources such as battery life, processing power, and memory usage. Table 2 shows multiple options with their trade-offs:

OptionDescriptionAdvantagesDisadvantages
Direct UI updatesUpdate the UI instantly with every new data point received from the WebSocket.Simplest implementation. Ensures users see the most current data. Minimal latency between data receipt and display.Can cause UI jank if updates are too frequent. High battery consumption. May overwhelm the main thread with excessive updates.
Buffered updatesCollect data changes in a buffer and update the UI at fixed intervals.Reduces UI update frequency. More efficient for battery life. Smooths out frequent changes.Introduces a small delay in displaying the latest data. May miss intermediate price points. Requires careful tuning of update intervals.
Priority-based updatesAssign different update priorities to different UI elements.Focuses resources on the most important data. Allows critical information to update more frequently. Better overall user experience.More complex implementation. Requires careful tuning of priorities. May cause user confusion if related data elements update at different rates.

Table 2: Trade-offs for displaying real-time UI updates

For our stock trading app's intraday chart, we'll implement the buffered updates approach. This strategy provides the best balance between responsiveness and resource efficiency for a single chart element.

πŸ“ Note: This decision assumes we're displaying just one intraday chart with real-time updates at a time. If product requirements change to display multiple charts simultaneously, we would need to reconsider our strategy and implement a hybrid solution with priority-based updates.

Implementation strategy

Our buffered update implementation for the WebView-based intraday chart consists of several key components:

  1. The Stocks repository contains data buffering logic that collects price updates from the WebSocket connection and maintains them in a queue.
  2. A scheduled updater runs at fixed intervals (e.g., every 1-2 seconds) to transfer the buffered price data to the WebView chart.
  3. A JavaScript bridge between the native code and WebView that efficiently transfers market data updates.
Understanding the JavaScript bridge

The stock chart runs as JavaScript within a WebView. Since the chart rendering happens in this JavaScript environment, we need a way to send our processed data from the native app to the WebView. This is where the JavaScript bridge comes in.

The JavaScript bridge creates a communication channel between our native app code and the WebView chart. When real-time stock data arrives, it first comes to our native code through the WebSocket connection, not directly to the WebView. This approach has several benefits:

  • Native code handles network connections and data processing more efficiently.
  • We can format the data correctly before sending it for visualization.
  • We can implement better buffering strategies at the native level.

This setup creates a clear separation of duties:

  • Native code handles: Managing the WebSocket connection, receiving and validating incoming data, buffering updates to reduce processing overhead, pre-processing data for display, and determining when to send updates to the chart.
  • WebView code handles: Rendering the chart visualization, managing user interactions (zoom, pan, tap), and handling chart animations and transitions.

The JavaScript bridge allows data to flow both ways:

  1. Native to WebView: Sending stock updates, configuration changes, and user preferences.
  2. WebView to Native: Communicating user interactions such as zooms, taps on data points, or feature toggles.

πŸ› οΈ Platform implementation details

Buffered updates require efficient data stream management that needs to be sent through the JavaScript bridge to update the WebView.

On Android, we can leverage:

  • The JavascriptInterface mechanism to establish reliable communication between native code and WebView charts.

  • Kotlin Flows with appropriate dispatchers to handle buffering and throttling off the main thread.

On iOS, we can leverage:

  • WKUserContentController and WKScriptMessageHandler enable native-to-JavaScript communication.

  • Combine framework's throttle and debounce operators to control update frequency.

Real-time chart update data flow

Figure 6 maps the data journey when a user opens a stock's intraday chart, updating at real time:

  1. The Stocks remote data source fetches the current day's historical data via a REST API call to establish the baseline chart data.
  2. Simultaneously, the remote data source establishes a WebSocket connection for receiving real-time price updates.
  3. The Stocks repository combines these two sources and implements the buffered updates strategy:
    1. The repository creates the data stream and emits the current day's historical data as the initial value.
    2. As WebSocket updates arrive, they're collected in a buffer within the repository.
    3. The repository doesn't send each update immediately to subscribers. Instead, it batches these updates and emits them at regular intervals.
  4. The Stock detail state holder subscribes to this buffered data stream and is responsible for communicating with the JavaScript bridge to update the chart as new data arrives.
  5. The WebView chart renders these updates, creating the appearance of smooth, real-time data visualization.
Image represents a system architecture diagram for displaying intraday stock values.  A user initiates the process by requesting to 'See intraday stock value graph,' which triggers a request to the 'Stock Detail Screen.' This screen interacts with a 'Stock Detail State Holder,' which in turn communicates with a 'Stocks Repository.' The 'Stocks Repository' receives data from two sources: 'HTTP Network Dispatcher' (1) and 'Real Time Updates Dispatcher' (2), both feeding into a 'Stocks Remote DataSource.'  The 'Stock Detail State Holder' (3) then sends data to a 'JavaScript Bridge' (4), which finally updates the 'Chart Rendering WebView' (5) to display the intraday stock graph to the user.  The numbered annotations (1-5) indicate the flow of data between components.

Common bottlenecks and optimizations

Several common issues can still affect real-time UI performance:

  • WebView-related bottlenecks. Frequent updates to the WebView can trigger expensive rendering operations. To minimize this impact, we:
    • Configure the charting library to use partial redraws, which update only the changed elements.
    • Disable animations during high-frequency updates to reduce rendering overhead.
  • Data processing bottlenecks. Processing incoming market data can sometimes become a bottleneck, especially when it involves complex calculations. To minimize this impact, we:
    • Move all data transformations to native code and execute them in a background thread before passing the data to the WebView.
    • Pre-compute derived values rather than calculating them in JavaScript.
    • Implement caching for computed values to prevent redundant calculations.
  • Bridge communication overhead. Every call between native code and WebView has a cost. We optimize this by:
    • Batching multiple price updates into a single bridge communication.
    • Using compact data formats to reduce serialization/deserialization costs.
    • Maintaining a consistent update interval that balances freshness with performance.
  • Intermittent connectivity. Mobile networks are inherently unreliable. When connectivity issues occur:
    • Clearly indicate when the data is stale due to connection loss.
    • Implement exponential backoff for reconnection attempts.
  • Battery impact. Real-time updates can significantly impact device battery life. We should:
    • Stop real-time updates when the app is in the background.
    • Implement adaptive throttling based on battery level.

As our app grows in complexity and user base, our real-time update system needs to scale accordingly. Several aspects of our design ensure this scalability:

  • WebSocket message filtering: We implement server-side filtering to ensure clients only receive the specific data they need, reducing client-side processing as the app adds features.
  • Buffer size: Our buffer needs to be large enough to accommodate all price updates between refresh intervals, even during periods of high market volatility. For most stocks, a buffer capacity of 10-20 price points should be sufficient.
  • Adaptive scheduling: We can implement adaptive scheduling that adjusts update frequency based on market conditions, device state, and user engagement when the user is actively looking at their data and the app is in the foreground:
    • Increase frequency during market hours with high volatility.
    • Allow users to adjust the frequency based on battery level and device performance.

πŸ” Industry insights:

Institutions such as J.P. Morgan or Commerzbank, and trading platforms like Binomo, use SciChart's [9] native engine to power real-time stock dashboards [10].

Coinbase's exchange platform exposes a WebSocket feed to broadcast live market data for orders and trades [11].

Step 5: Wrap-up

In this chapter, we've designed a mobile stock trading app that delivers core functionality for investment management. Our system supports viewing real-time stock prices, executing trades through placing and canceling orders, analyzing historical price data through charts, and receiving timely price alerts.

The architecture combines REST APIs for standard operations with WebSockets for real-time updates. We've implemented a layered client architecture that separates UI and data concerns, making the codebase both maintainable and testable. We also covered creating both historical and real-time intraday stock value charts.

For those preparing to discuss this in a Mobile System Design interview, consider exploring these extensions to deepen the conversation:

  • Offline functionality: Designing a system for caching and local operation when network connectivity is limited.
  • Cross-platform considerations: Discussing how the architecture would adapt to support web and other platforms
  • Regulatory Compliance: Address the specific compliance requirements and regulations that govern stock trading in different regions.
  • Personalized UI: Offer customizable dashboards or watchlists, enhancing engagement through tailored experiences.

References

[1] TradeRepublic's state of Android in 2024 with REST and WebSocket protocols:
https://engineering.traderepublic.com/state-of-android-at-tr-2024-edition-bc032620b83e
[2] API Gateway: https://en.wikipedia.org/wiki/API\_management\#gateway
[3] Robinhood's charts: https://robinhood.com/us/en/support/articles/using-charts/
[4] ETrade's charts: https://us.etrade.com/knowledge/library/getting-started/power-etrade-charting-enhancements
[5] MPAndroidChart: https://github.com/PhilJay/MPAndroidChart
[6] iOS Charts library: https://github.com/ChartsOrg/Charts
[7] TradingView: https://www.tradingview.com/
[8] Robinhood's Spark Android library: https://github.com/robinhood/spark
[9] SciChart: https://www.scichart.com/
[10] SciChart usage in the industry: https://www.scichart.com/blog/ios-android-native-apps-vs-javascript
[11] Coinbase WebSocket approach for real-time updates: https://docs.cdp.coinbase.com/exchange/docs/websocket-overview

CHAPTER 06

Pagination library

06

Efficiently managing large datasets is a cornerstone of mobile app performance. Pagination ensures that users experience seamless interactions, even with extensive content. While earlier chapters addressed pagination within specific applications, we now shift our focus to a broader challenge: crafting a reusable pagination library.

A well-designed pagination library offers substantial benefits as organizations grow. It promotes consistency across applications, reduces code duplication, and consolidates performance optimizations in one place. Building such a library requires careful attention to API design and implementation, striking a balance between flexibility and practicality.

πŸ’‘ Pro tip!

Designing a library differs fundamentally from designing an app. Apps serve end users through interfaces and screens, whereas libraries serve developers through code. Our design must prioritize how developers integrate and interact with the library, solving their challenges while enhancing their workflow.

When creating a library, our focus shifts from user-facing experiences to developer-centric APIs. The "user experience" transforms into the ease with which developers adopt our solution, integrate it into their projects, and address their pagination needs.

Like all system design challenges, the specific requirements will shape our technical decisions. Before diving into implementation details, we need to understand clearly what our library should accomplish and how other developers will use it. Let's begin by defining these requirements and exploring the key decisions that will influence our design.

Step 1: Understand the problem and establish design scope

Before considering design decisions, it is crucial to first understand what we're building and why. Let's walk through how this conversation typically unfolds in an interview setting.

πŸ“ Note: This chapter assumes familiarity with pagination techniques such as offset-based and cursor-based pagination. For a detailed refresher, refer to the Pagination section in Chapter 10: Mobile System Design Building Blocks.

Candidate: To begin, I'd like to confirm the core requirements for the pagination library. I'm thinking we'll need a flexible solution that supports common pagination patterns such as offset and cursor-based approaches, while allowing pagination from both local and remote data sources. Does this align with your vision?
Interviewer: Yes, that captures the library's intended flexibility and scope. And it should be able to handle any type of application data.

Candidate: Understood. Should the library include user interface components for rendering paginated data, or should it focus exclusively on the pagination logic and data management?
Interviewer: Let's keep our scope focused on the business logic, excluding UI components for now.

Candidate: Makes sense. To optimize performance, I think we should implement a caching system that stores previously fetched pages. We could use in-memory caching by default for quick access to recent pages, and optionally allow disk-based caching for persisting data between app sessions. What do you think?
Interviewer: Yes, that sounds perfect!

Candidate: I'd also propose adding a prefetching capability that anticipates user navigation patterns. For example, when a user views page 5, the library can automatically load pages 6 and 7 in the background, ensuring a seamless browsing experience as they continue scrolling. This would significantly reduce perceived loading times. Should we also support fast-scrolling scenarios where users jump quickly through large datasets?
Interviewer: Let's include prefetching, but exclude fast scrolling, as we're not handling UI components.

Candidate: Noted. Lastly, will this library be open-sourced? This consideration could impact our API design, documentation standards, and versioning strategy.
Interviewer: Yes, we're planning to open-source it.

Requirements

From these considerations, we're designing a pagination library with the following functional requirements:

  • The library supports various application data types and multiple pagination techniques.
  • Users can paginate through different local and remote data sources.
  • The library provides business logic functionality. It doesn't provide UI support.
  • The library provides in-memory caching and configurable disk caching.
  • Users can prefetch specific pages.

As for non-functional requirements, we need to build a system that ensures:

  • Performance: The library must deliver efficient pagination with optimized resource usage and minimal overhead, operating safely in a multi-threaded environment.
  • Reliability: The library should address network failures and interruptions gracefully, ensuring consistent behavior regardless of device type or OS version.
  • Usability: We need to offer an intuitive API with comprehensive documentation and clear learning resources that help developers implement pagination effectively.

Features that are out of scope for this exercise:

  • Pagination support in the UI.
  • Support for fast scrolling.

Now that we've established our requirements, we can begin designing the pagination library's API. This foundation will shape how developers interact with our library.

Step 2: API design

Having established our pagination library's requirements, we now shift our focus to designing its public API. This interface must be intuitive, adaptable, and robust enough to meet the varied demands of mobile developers.

πŸ“Œ Remember!

Think of a library's API as its public faceβ€”it's everything developers can directly interact with, including its functions, classes, methods, and properties. Just like a well-designed user interface makes an app intuitive for its users, a well-crafted API surface makes a library approachable for developers.

A good public API helps developers use the library effectively without needing to know how it works internally. To achieve this, it requires three key qualities: clear documentation that helps developers understand how to use each component, stability so developers can rely on consistent behavior, and an intuitive design that follows platform conventions and patterns.

Central to this design is the Paginator interface, which acts as the cornerstone for users interacting with the library.

πŸ’‘ Pro tip!

When designing a library's API, focus first on defining its core elements: the key interfaces, classes, and data models that developers will interact with most frequently. These form the foundation of your API surface and will shape how developers use your library.

Paginator

Mobile apps frequently handle varied datasets such as social media feeds, chat histories, or product catalogs. To support any data type, we define Paginator<T> using generics. This decision offers immediate benefits:

  • Flexibility: A single Paginator can handle any data type, reducing repetitive code and satisfying the requirement for multiple data types.
  • Isolation: Independent instances coexist without conflict, each managing an independent cache and pagination flow.
  • Optimization: Memory usage stays lean, as each instance tracks only its relevant data.
  • Reliability: Generics enforce type safety at compile time, catching errors early.

πŸ“Œ Remember!

Using generics with Paginator<T> means we can handle any data type without creating separate implementations. Instead of writing separate Paginator classes for posts, messages, or other content types, the generic <T> parameter allows us to write a single, flexible implementation. This not only reduces code duplication but also makes the library much more versatile and maintainable.

The Paginator<T> interface needs core methods that align with our requirements:

  1. Fetching individual pages, allowing users to request specific pages of data from a data source.
  2. Fetching surrounding pages, enabling prefetch of pages around a specific page.
  3. Cache management, providing the ability to clear specific pages from the cache.

These translate into three methods: fetch, fetchAround, and clear. The public API definition looks like the following:

Kotlin
Code
  interface Paginator<T> {
    fun fetch(key: String, pageSize: Int)

    fun fetchAround(
      key: String, pageSize: Int,
      depthLevel: Int, direction: PageFetchDirection
    )

    fun clear(key: String)
  }

enum class PageFetchDirection
ALL, FORWARD, BACKWARD

Swift
Code
  protocol Paginator {
    associatedtype T

    func fetch(key: String, pageSize: Int)

    func fetchAround(
      key: String, pageSize: Int,
      depthLevel: Int, direction: PageFetchDirection
    )

    func clear(key: String)
  }

enum PageFetchDirection
case all, forward, backward

The Paginator's methods are idempotent, ensuring that repeated calls with identical inputs yield the same results.

πŸ’‘ Pro tip!

By defining Paginator as an interface/protocol, we're not just creating an entry point to our library; we're also making it easier to test. This approach allows developers to create test double [1] versions that simulate different scenarios.

Supporting diverse pagination techniques

A key strength of this API is its agnosticism toward pagination techniques. The key parameter is intentionally flexible, adapting to the underlying method:

  • Offset-based pagination: key might be an integer (e.g., "1" for page 1, "2" for page 2).
  • Cursor-based pagination: the key could be a token (e.g., "cursor123") that points to the next dataset.

This adaptability ensures that developers can use the same Paginator interface regardless of their data source's pagination strategy.

Data propagation

Next, we consider how the library communicates results and errors to its users. To keep it lightweight and dependency-free, we opt for native callbacks. This decision preserves flexibility, allowing developers to integrate the library without adopting a prescribed async framework.

We can also make each method return a PaginatorExecution object, empowering users to cancel operations as needed.

πŸ“ Note!

Since we're designing an open-source library that can be used across multiple apps, we need to be thoughtful about our dependencies. Any third-party libraries we include will become required dependencies for apps using our library. To keep things lightweight and flexible, we'll use native language constructs for core functionality while making the library extensible enough to work well with popular libraries. We'll explore this design approach in more detail in the deep dives section.

Here's how the improved Paginator interface looks, with new API additions in bold and existing definitions in gray:

Kotlin
Code
interface Paginator<T> {
  fun fetch(
    key: String, pageSize: Int, 
    listener: PaginatorFetchListener<T>
  ): PaginatorExecution
  
  fun fetchAround(
    key: String,
    pageSize: Int,
    depthLevel: Int, direction: PageFetchDirection,
    listener: PaginatorFetchAroundListener<T>
  ): PaginatorExecution
  
  fun clear(key: String)
}
Swift
Code
protocol Paginator {
  associatedtype T

  func fetch(
    key: String, pageSize: Int,
    listener: PaginatorFetchListener<T>
  ) -> PaginatorExecution

  func fetchAround(
    key: String, pageSize: Int,
    depthLevel: Int, direction: PageFetchDirection,
    listener: PaginatorFetchAroundListener<T>
  ) -> PaginatorExecution

  func clear(key: String)
}

Internally, each Paginator instance maintains a thread-safe cache, typically a Map that associates page keys with their data. This setup accelerates access to recently loaded pages, an aspect we'll explore in more detail in the following sections.

Data models

Let's look at the core data models that power the pagination library. At the heart of the design is the PaginatorPage model, which encapsulates everything needed to represent a single page of data:

KotlinSwift
public class PaginatorPage<T>
content: List<T>
key: String
expirationTime: String?
prevKey: String?
nextKey: String?
public class PaginatorPage<T>
content: [T]
key: String
expirationTime: String?
prevKey: String?
nextKey: String?

The PaginatorPage model serves as a container for both the paginated content and the metadata needed for navigation between pages: the current key, the previous and next keys, if available. The optional expirationTime field enables smart caching decisions, letting the library determine when cached data needs refreshing.

πŸ’‘ Pro tip!

You might notice that we're using public classes for our data models here, rather than the data classes and structs we used in previous chapters. This change is intentional and is essential for library development.

In Kotlin, data classes automatically generate several methods such as copy, componentN, equals, and hashCode. While convenient for application development, these generated methods can cause binary compatibility issues in libraries. When you modify properties in a data class, the generated methods also change, potentially breaking compatibility for library consumers [2].

Similarly, in Swift, structs are value types whose memory layout is part of their binary interface. Changes to stored properties can affect this layout, which in turn impacts the library's ABI stability [3] [4].

By using classes instead, we maintain better control over our library's public interface and ensure compatibility across versions.

How apps implement the Paginator interface

When developers integrate the pagination library into their apps, they need to implement and integrate the Paginator interface within their architecture properly. Let's explore how the Paginator interface is typically used in application code.

Apps that consume our pagination library generally implement it within repositories or UI state holders that:

  • Create a configured Paginator instance with appropriate data sources using the CallbackPaginator implementation (more about this in the next section).
  • Trigger Paginator's methods in response to specific user actions such as scrolling.
  • Combine PaginatorPage objects into a unified list.
  • Monitor pagination state to determine when additional data should be loaded.
  • Handle various states, including loading, errors, and empty results.

For linear pagination scenarios such as scrolling through content, apps primarily use the fetch method to load pages sequentially. For non-linear scenarios, the fetchAround method enables apps to prefetch content surrounding the target page, ensuring a smooth navigation experience.

This separation of responsibilities ensures that the Paginator focuses exclusively on efficient data retrieval and caching, while the application host code handles the presentation logic and user interactions.

Creating configurable Paginator instances

As we've seen, host applications need to instantiate a properly configured Paginator to handle pagination effectively. Let's explore how the library provides this flexibility through implementation classes and configuration options.

While the Paginator interface defines the contract for pagination functionality, apps need concrete implementations they can configure for their specific use cases. With our library, we include the CallbackPaginator<T> class, a fully-featured implementation of the Paginator<T> interface that can be customized through dependency injection.

CallbackPaginator serves as the primary implementation that apps will instantiate, configured with components that define its behavior:

  1. PaginatorDataSource<T> handles the actual data fetching for each page, whether from a remote API, local database, or a mix of both. By separating data retrieval into its own component, CallbackPaginator stays focused on orchestrating the process.
  2. PageValidator<T> ensures cached pages are still valid, checking factors such as expiration timestamps or data integrity. If a page fails validation, CallbackPaginator triggers a fresh fetch to keep data up-to-date.
  3. (Optional) PaginatorStore<T> enables on-disk storage of fetched pages for offline usage. It cuts down on redundant network calls and boosts performance by keeping data locally accessible.
  4. PaginatorConfig controls aspects such as concurrency handling, cache eviction policies, and other settings.

Let's look at the definition of the CallbackPaginator<T> class that embraces dependency injection to allow consumers to provide their own specialized implementations tailored to their specific use cases:

Kotlin
Code
  class CallbackPaginator<T>(
    dataSource: PaginatorDataSource<T>,
    validator: PageValidator<T>,
    store: PaginatorStore<T>? = null,
    config: PaginatorConfig = DefaultPaginatorConfig(),
  ) : Paginator<T> { ... }

class PaginatorConfig
threadPool: ExecutorService,
maxParallelCalls: Int? = null,
inMemoryEvictionStrategy: PaginatorEvictionStrategy,
storeEvictionStrategy: PaginatorEvictionStrategy? = null,
...

class PaginatorConfig dispatchQueue: DispatchQueue, maxParallelCalls: Int?, inMemoryEvictionStrategy: PaginatorEvictionStrategy, storeEvictionStrategy: PaginatorEvictionStrategy?, ...

Swift
Code
  class CallbackPaginator<T>: Paginator<T> {
    init(
      dataSource: PaginatorDataSource<T>,
      validator: PageValidator<T>,
      store: PaginatorStore<T>? = nil,
      config: PaginatorConfig = DefaultPaginatorConfig() 
    ) {
      // self.field statements
    }
  }

πŸ“ Note: While some libraries use static configuration methods such as Paginator.init(...), this approach isn't ideal for our pagination library. Since each Paginator instance needs independent configuration and high customizability, we opted for instance-level configuration instead. This gives users more flexibility to configure each instance according to their specific needs.

The library comes with several built-in common eviction policies such as LRU (Least Recently Used), FIFO (First In, First Out), and LIFO (Last In, First Out). Beyond these standard options, developers can also define custom policies based on their specific needs, whether that's time-based expiration (TTL), memory size limits, or maximum page count. This flexibility ensures the library can adapt to various caching requirements and resource constraints.

Step 3: High-level client architecture

With the API design established, we now shift our focus to crafting a high-level client architecture that powers our pagination library. This structure must prioritize modularity, scalability, and maintainability to integrate seamlessly into a variety of mobile applications.

Figure 1 provides a visual overview, detailing the core components and their interactionsβ€”solid arrows denote data flow, dotted arrows show dependencies, and arrows pointing outside the box represent external communication. In the diagram, components with a gray background represent implementation classes contributed by developers, while the remaining components are implementations provided by the library itself.

Image represents a system architecture diagram labeled 'Library,' depicting the interaction between different components of a paginator.  The central component is a large rectangle labeled 'Paginator,' which receives input from four other components: 'Paginator DataSource' (bottom-left), 'Paginator InMemory Cache' (top-center), and 'Paginator Evictor' (top-right), all connected via solid lines indicating direct data flow.  The 'Paginator Scheduler' (top-left) connects to the central 'Paginator' via a dashed line, suggesting asynchronous or scheduled communication.  The central 'Paginator' also sends data to 'Paginator Validator' (bottom-center) via a dashed line, implying validation occurs after data processing.  The 'Paginator Evictor' and 'Paginator Store' (bottom-right) are connected by a dashed line, indicating data transfer for storage or eviction purposes.  Finally, a solid upward-pointing arrow from the top of the 'Library' boundary suggests an external input or trigger initiating the pagination process.  The 'Paginator InMemory Cache' sends data to the 'Paginator Evictor' via a dashed line, suggesting a mechanism for managing cached data.  All components are rectangular boxes with their respective names clearly labeled.

provided by developers with gray background, implementations provided by the Pagination library with white background.

Having laid the groundwork with earlier discussions, let's introduce two critical components that enhance the Paginator's efficiency and focus:

  • The PaginatorScheduler takes charge of all request-related operations: queuing, ordering, and processing responses. It also manages debouncing, throttling, retry mechanisms, and concurrency, offloading these tasks from the Paginator to keep its responsibilities focused and streamlined.
  • The PaginatorEvictor manages cache efficiency for both in-memory and disk storage. This separation gives us the flexibility to implement different eviction strategies without impacting other parts of the system. It ensures our caches remain performant while keeping resource usage in check.

πŸ’‘ Pro tip!

Breaking functionality into dedicated components serves two purposes: (1) it demonstrates your understanding of modular design principles, and (2) it clearly shows the interviewer you're addressing specific requirements such as caching and prefetching. |

This design achieves a careful balance: it offers users the flexibility to integrate custom data sources and storage solutions while entrusting the library with the intricacies of scheduling and cache management.

Step 4: Deep dives

With our high-level architecture in place, let's explore some key implementation details. While the specific areas to dive into during an interview often depend on your interviewer's interests and your system's particular challenges, we'll focus on several critical aspects:

  • Implementing effective caching strategies.
  • Priorities in API calls.
  • Modular API design.
  • Handling failures and versioning.

Implementing effective caching strategies

Caching is a pivotal element in our pagination library, directly shaping its performance and the resulting user experience. In this section, we'll explore the mechanics of our dual caching system, which integrates in-memory and disk-based approaches to streamline data retrieval.

While users can optionally use the PaginatorStore for persisting PaginatorPage objects on disk, the in-memory cache significantly improves user experience. Both caching mechanisms can be fine-tuned through eviction policies defined in PaginatorConfig when initializing a Paginator instance.

In-memory caching

At the core of this system lies the PaginatorInMemoryCache that provides fast storage for paginated data (i.e., PaginatorPage objects).

PaginatorInMemoryCache uses a Map<String, PaginatorPageResult<T>> structured as follows:

  • A String key as the unique identifier for each page (offset, page index, or cursor).
  • A PaginatorPageResult<T> value representing the current state of that page. This object reflects the page's lifecycle:
    • key not present in the cache: The page awaits its initial fetch.
    • Loading: A retrieval operation is underway.
    • Success(page: PaginatorPage<T>): The page is ready and validated.
    • Error(message: String): A fetch attempt failed, with diagnostic details provided.

Modeling these states explicitly with PaginatorPageResult brings several key advantages: type safety that helps catch errors at compile time, clear representation of a page's status at any point, easier debugging when issues arise, and better scalability as the codebase grows.

How the dual caching system works

Our caching strategy operates through a tiered structure. Let's explore how the caching components work together when a user requests a page. Figure 2 illustrates the data flow during in-memory and disk cache misses.

When a user calls fetch on a Paginator instance (step 0), the system implements the following caching strategy:

  1. In-memory cache check: The Paginator begins by querying the in-memory cache to see if an existing PaginatorPage already exists. If a valid page is found, it's returned immediately for a low-latency response.
  2. Disk cache check (optional): If the in-memory cache misses and a PaginatorStore is configured, the system consults the disk cache. A valid page found here is promoted to memory for future use and promptly returned.
  3. Data source fetch: If both caches fail to provide the requested page, the PaginatorScheduler triggers a fetch call on the PaginatorDataSource.
  4. Validator check: Once the new page is retrieved, the PageValidator verifies the data's validity, checking for proper formatting or expiration.
  5. Cache update: Once validated, the fresh page is cached in both the in-memory cache and in the disk store if configured.
  6. Then, the fresh page is returned to the caller.
Image represents a system architecture diagram for a paginator.  A developer, represented by a person icon with code symbol, initiates a `fetch(key, limit)` request (0). This request flows to the `Paginator` (central component), which first passes through a `Paginator Validator` (4). The `Paginator` then interacts with a `Paginator Scheduler` (3), which in turn communicates with a `Paginator DataSource` to retrieve the data.  The `Paginator` then sends the retrieved data to both a `Paginator InMemory Cache` (1, 5.1) and a `Paginator Store` (2, 5.2).  The cache is indicated as having a cache miss, implying that the data was not found in the cache and had to be fetched from the `Paginator Store`. Finally, a `PaginatorPage` (6) is returned to the developer.  The numbered arrows (1-6) indicate the flow of data and control between components.  The 'X' symbols next to the cache and store indicate that these are likely external components or services.

This layered design strikes a balance between speed and durability: in-memory caching ensures rapid access, while disk caching offers a dependable fallback. The PaginatorEvictor plays a key role by enforcing eviction policies, keeping both caches lean, and preventing resource overload.

Benefits and considerations

Our dual caching approach offers several advantages:

  • Better performance through reduced latency by avoiding network calls.
  • Lower network usage through intelligent request management.
  • Smoother user experience with faster data retrieval.

While powerful, this caching system requires careful attention to several implementation details:

  • The system assumes that cache keys map directly to network requests. For cases where this assumption doesn't hold, we may need to implement custom key generation logic.
  • Since the Paginator engine operates in a multithreaded environment, we must ensure thread-safe cache access and atomic operations to prevent race conditions or data corruption.

To support fine-tuning, we can configure the library with analytics and logger providers to capture cache hits, misses, and evictions. These insights equip developers to optimize performance for their unique needs.

πŸ” Industry insights:

The Store5 library [5] for Kotlin MultiPlatform and the Hyperoslo Cache library [6] for iOS can be configured to write to both in-memory and disk storage, giving fast in-memory reads with persistent disk backup.

Google's Jetpack Paging library [7] encourages a network + database hybrid: using a local database as the single source of truth for paged content, and a RemoteMediator that fetches new pages into the DB only when the app runs out of local data.

Priorities in API calls

Users expect rapid responses, yet the library must juggle multiple tasks, often under constraints such as limited device resources or fluctuating network conditions. Prioritizing API calls becomes vital when:

  • Concurrent requests compete for bandwidth or processing power.
  • Background operations, such as prefetching, overlap with user-initiated actions.
  • Device limitations, such as low battery or weak connectivity, demand optimized performance.

Consider a user scrolling to load new content: this request must outrank background prefetching to ensure immediate feedback. Likewise, in fetchAround scenarios, the explicitly requested page should load before its neighbors. To achieve this, we can enhance our public APIs with priority controls, allowing developers to dictate the execution order effectively.

Here's how the improved Paginator interface looks like, with new API additions in bold and existing definitions in gray:

Kotlin
Code
  interface Paginator<T> {
    fun fetch(
      key: String,
      pageSize: Int,
      listener: PaginatorFetchListener<T>,
      priority: FetchPriority = FetchPriority.MEDIUM
    ): PaginatorExecution
    
    fun fetchAround(
      key: String, pageSize: Int,
      depthLevel: Int, direction: PageFetchDirection,
      listener: PaginatorFetchAroundListener<T>,
      keyPriority: FetchPriority = FetchPriority.MEDIUM,
      aroundPriority: FetchPriority = FetchPriority.LOW
    ): PaginatorExecution
  }

enum class FetchPriority
HIGH, MEDIUM, LOW

Swift
Code
  protocol Paginator {
    associatedtype T

    func fetch(
      key: String, pageSize: Int,
      listener: PaginatorFetchListener<T>,
      priority: FetchPriority
    ) -> PaginatorExecution

    func fetchAround(
      key: String, pageSize: Int,
      depthLevel: Int, direction: PageFetchDirection,
      listener: PaginatorFetchAroundListener<T>,
      keyPriority: FetchPriority,
      aroundPriority: FetchPriority
    ) -> PaginatorExecution
  }

enum FetchPriority
case high, medium, low

Each fetch operation accepts a priority parameter that signals its relative importance to the library. For the fetchAround call, we introduce two distinct priority levels: keyPriority for the main requested page, and aroundPriority for the surrounding pages. This granular control allows us to optimize how the library processes different types of requests.

Now, let's explore how to implement this prioritization system in our library.

Priority-based task scheduling

Effective priority handling demands a robust scheduling strategy. Table 1 evaluates several approaches, each balancing simplicity, scalability, and concurrency in different ways.

OptionDescriptionAdvantagesDisadvantages
Separate queues for each priority levelDedicated queues per priority, processed sequentially by importance.Simple to implement and understand, and clear separation of priorities. Direct control over task execution order.Performance degrades with more priority levels and complex workload balancing across queues. Higher memory overhead.
Priority QueueSingle queue that automatically maintains tasks in priority order through heap structure. Reorders on enqueue/dequeue.Efficient priority-based ordering and simpler architecture with a single queue. Lower memory footprint.Potential overhead from heap reordering. No fairness guarantee, lower-priority tasks may starve.
ThreadPools with priority levelsSeparate thread pools per priority, with more resources for higher levels.True concurrency with hardware parallelism, and better resource utilization. Natural handling of priority levels.Complex thread pool management. Higher resource demands.
Weighted Round-Robin schedulingCyclic processing with higher-priority tasks receiving greater execution time.Guaranteed processing for all priorities, and predictable task execution. Good fairness properties.Complex weight tuning needed. Higher-priority tasks may face delays.

Table 1: Trade-offs for implementing priority-based task scheduling

The Priority Queue pattern [8] stands out as a strong choice for our library. It delivers efficient ordering, ensures high-priority tasks are executed first, and simplifies the implementation. This balance is well-suited for our mobile constraints.

πŸ” Industry insights:

Google's Volley networking library lets developers tag requests with a Priority so that an "immediate" request jumps ahead in the queue [9].

Modular API design

Two important considerations for designing a pagination library are handling async operations well and making testing easy. First, we'll look at managing asynchronous tasks across different setups. Then, we'll cover testing approaches that keep it reliable and simple to use.

Async framework compatibility layers

When designing a mobile library, we face a critical decision: how to manage async operations effectively. Frameworks such as Kotlin Coroutines or Swift's Combine offer robust solutions for async programming, and it might be tempting to build our entire library and public APIs around one of these powerful tools. However, mandating such dependencies could burden users whose projects rely on different tools. Imagine an RxJava-based app forced to adopt Coroutines solely to integrate our library. To avoid this, we should craft an API that works well with various async approaches.

πŸ› οΈ Platform implementation details

Both the Android and iOS platforms provide several approaches for handling asynchronous operations:

  • Android's ecosystem offers multiple async solutions, including traditional callbacks, Kotlin Coroutines and Flow, RxJava, LiveData, and CompletableFuture from Java.

  • iOS developers can choose from options such as closure-based completion handlers (callbacks), the Combine framework, async/await syntax, the delegate pattern, and Grand Central Dispatch (GCD) for low-level concurrency control.

Our solution is a modular design that anchors the core API in native callbacks, free of external dependencies, and complements it with optional extension modules for popular frameworks. This gives developers maximum flexibility:

  • paginator-core delivers the essential functionality with no external dependencies.
  • paginator-coroutines, paginator-combine, or paginator-rx provide optimized support for specific async solutions.

This strategy offers multiple benefits:

  • Flexibility: Developers can adopt only the extensions that suit their project's tech stack.
  • Lightweight core: The base module remains unburdened by external frameworks, enhancing its portability.
  • Native integration: Each extension aligns with its framework's conventions, ensuring an intuitive developer experience.

πŸ› οΈ Platform implementation details

Android and iOS handle library distribution differently.

Android's build system, typically Gradle, provides built-in support for creating multiple library artifacts from a single codebase. This means you can publish each async integration as a separate artifact, giving developers the flexibility to include only what they need.

iOS traditionally distributes libraries as single frameworks, but it offers several approaches to achieve modularity:

  1. Use conditional compilation to selectively include code.

  2. Create extension files for different async patterns that developers can optionally add.

  3. Leverage Swift Package Manager to define separate products or targets for different integrations.

This modular approach not only keeps the library adaptable for current users but also prepares it for emerging async paradigms without disrupting existing implementations.

Let's see this adaptability in action. Here are examples of how our API transforms when used with Kotlin coroutines and Swift's async/await syntax, with new API additions in bold and existing definitions in gray:

Kotlin using coroutines and flows
Code
  interface Paginator<T> {

    suspend fun fetch(key: String, pageSize: Int): PaginatorPage<T>

    fun fetchAround(
      key: String, pageSize: Int,
      depthLevel: Int, direction: PageFetchDirection
    ): Flow<PaginatorPageT>>

    suspend fun clear(key: String)
  }

Swift using async/await

Code
  protocol Paginator {
    associatedtype T 
  
    func fetch(
      key: String, pageSize: Int
    ) async throws -> PaginatorPage<T> 
  
    func fetchAround(
      key: String, pageSize: Int,
      depthLevel: Int, direction: PageFetchDirection
    ) -> AsyncThrowingStream<PaginatorPageT>, Error>
    
    func clear(key: String) async throws
  }

Pagination library testing suite

While async flexibility is crucial for adoption, equally important is comprehensive testing support. Our design's foundation of interfaces and protocols naturally facilitates testing, but we can elevate the library's value by offering a dedicated testing-only artifact, complete with pre-built test doubles and utility tools tailored for developer convenience.

This testing artifact equips developers with a practical toolkit, including:

  • Pre-built test doubles: These emulate core components such as PaginatorDataSource, PaginatorStore, and PageValidator, enabling developers to simulate diverse scenarios without crafting bespoke mocks.
  • Helper functions: Tools for generating sample paginated data or mimicking network latency simplify the process of setting up meaningful test cases.
  • Error simulation capabilities: Features that let developers test how their code handles failures or edge cases, ensuring robust error handling and recovery logic.

By providing these resources in a standalone artifact, we empower developers to adopt our library with confidence, knowing they have the means to achieve thorough test coverage. This offers several benefits to developers:

  • Eliminate the need to create and maintain custom test implementations.
  • Ensure test doubles stay in sync with library updates automatically.
  • Promote higher test coverage by simplifying the testing of edge cases.
  • Demonstrate our commitment to testing best practices.

πŸ“Œ Remember!

A dedicated testing-only artifact complements our modular design while keeping the core library lightweight. This separation allows us to refine the testing tools independently, having them aligned with evolving testing frameworks and methodologies. Additionally, developers get powerful testing capabilities without bloating their production builds.

πŸ” Industry insights:

Google's Jetpack Paging 3 [7] takes a similar approach: it exposes a common PagingData stream which can be emitted as a LiveData, RxJava Flowable, or Kotlin Flow depending on what the app wants to use.

The Moya networking library [10] in iOS, for example, was built with testing in mind. It lets you provide stubbed responses or use a sampleData property for each API endpoint, so that unit tests can run without real network calls [11].

Handling failures and versioning

Now let's address potential failure points and versioning strategies for the pagination library. These topics often come up during interviews and demonstrate your thorough understanding of library development beyond just the core functionality.

Handling failure points

A pagination library operates at the intersection of data sources, network, and device constraints, making it susceptible to a variety of failures. Identifying and mitigating these points of failure is essential to ensure reliability and a seamless user experience. Here are the key points specific to pagination and how our design addresses them.

Network failures

Fetching remote data is a common task, but network issues such as timeouts, dropped connections, or server errors can disrupt pagination.

Mitigation: The library uses retry mechanisms with exponential backoff to handle transient failures gracefully. If a fetch fails, callbacks transmit the detailed errors, enabling developers to fallback to cached data or display user-friendly messages, preventing app crashes.

Database errors

When paginating from a local data source, data storage query failures or data corruption can halt operations.

Mitigation: Robust error handling within PaginatorDataSource ensures that issues are caught and reported via callbacks. Developers can configure fallbacks, such as loading stale cache data, to maintain functionality when fresh data isn't available.

Cache inconsistencies

Stale or corrupted cache entries can lead to outdated or incorrect page displays, a critical concern in pagination where continuity matters.

Mitigation: The PageValidator checks page validity using expiration times or checksums. Invalid pages trigger cache invalidation and a fresh fetch, ensuring users see accurate content. This is paired with configurable eviction strategies in PaginatorConfig to keep the cache lean and reliable.

Concurrency issues

Multi-threaded environments, common in mobile apps, risk race conditions when multiple threads access the cache or fetch data simultaneously.

Mitigation: Thread-safe data structures (e.g., concurrent maps) and synchronization mechanisms safeguard the in-memory cache and PaginatorStore. This ensures a consistent state across threads, vital for smooth pagination during rapid user interactions.

Invalid page keys

In cursor-based or keyset pagination, users might request pages with expired or malformed keys, especially after data updates.

Mitigation: The library validates keys before processing, rejecting invalid requests with clear error messages via callbacks. This guides developers to handle such cases, perhaps by resetting to a known starting point.

Last notes

These mitigations prioritize user experience, ensuring that failures don't derail the app but instead degrade gracefully. Adding analytics and logging options in PaginatorConfig further empowers developers to monitor and troubleshoot issues, enhancing reliability in production.

Versioning

As an open-source library, maintaining a clear versioning strategy is vital for developer trust and long-term adoption. It ensures that updates, whether new features, bug fixes, or breaking changes, are predictable and manageable.

For our library, we adopt Semantic Versioning (SemVer) [12] with its MAJOR.MINOR.PATCH format, tailoring it to the pagination library's evolution:

  • The MAJOR version (X.y.z) signals breaking changes that require code updates.
    • In our pagination library, this would be the case if we modify the Paginator interface or alter the default caching behavior.
  • The MINOR version (x.Y.z) adds new features while maintaining compatibility.
    • In our pagination library, this would be the case if we enhanced the PaginatorConfig's functionality without disrupting existing integrations.
  • The PATCH version (x.y.Z) addresses bugs and small fixes without changing behavior.

πŸ› οΈ Platform implementation details

Both iOS and Android ecosystems strongly favor SemVer for their dependency management.

SemVer integration is particularly important for iOS because the Swift Package Manager (SPM) uses it as the foundation for dependency resolution [13]. Using alternative versioning schemes could lead to compatibility issues and make it harder for SPM to properly manage your library's dependencies.

Similarly, on Android, build tools such as Gradle and Maven are designed to work seamlessly with SemVer. Following SemVer ensures these tools can automatically resolve version conflicts and manage dependencies effectively.

Without SemVer, you would lose access to much of the sophisticated dependency management these tools provide.

Other versioning strategies

While SemVer is widely adopted, including by our pagination library, for its clear and structured approach to versioning, other strategies such as Calendar Versioning (CalVer) are also used in certain ecosystems to meet specific needs.

A prominent example is Jetpack Compose [14], which combines CalVer with elements of SemVer to manage its rapidly evolving UI toolkit and guarantee compatibility across its suite of libraries. Here's how it works:

  • Individual Compose libraries (compose-ui, compose-material, compose-foundation, etc.) strictly use SemVer.
  • Compose also provides a Bill of Materials (BOM) artifact, which uses Calendar Versioning (CalVer). Jetpack Compose's BOM artifacts are formatted as YYYY.MM.PATCH (e.g., 2025.02.00), where the year (YYYY) and month (MM) tie releases to a specific date.

The Compose BOM simplifies dependency management by specifying a single, calendar-based version that corresponds to a specific set of compatible libraries. The BOM maps a specific CalVer release (e.g., 2025.02.00) to a group of compatible library versions (e.g., compose.animation:1.7.8, compose.material3:1.3.1, etc.). This guarantees compatibility across all included libraries.

Step 5: Wrap-up

In this chapter, we designed a flexible pagination library that works with:

  • Any kind of application data.
  • Different pagination techniques, such as offset-based or cursor-based pagination.
  • Both local and remote data sources.

We defined a public API, designed a robust, scalable, and extensible architecture, and covered key aspects such as caching, prioritization of API calls, points of failure, testing, and versioning.

If you have extra time in your interview or want to push yourself with more advanced requirements, here are a few additional areas you could explore:

  • Pagination support in the UI layer: consider integrating with common UI elements such as table views, collection views, or scrolling widgets.
  • Fast scrolling support: enable users to navigate quickly through large datasets, similar to how long lists often allow the skipping of entire sections.
  • Background services: implement prefetching of pages while the app is idle or running in the background, thus ensuring a smooth user experience without interruptions.
  • Custom error handling: provide a way for users to define custom error handling strategies for failed fetches, for example, retries, fallbacks to cached data, or user alerts.
  • Partial page loading: optimize performance by supporting partial page loads when resources are limited or users need only a subset of the data.

References

[1] Test double: https://en.wikipedia.org/wiki/Test\_double
[2] Public API Challenges In Kotlin: https://jakewharton.com/public-api-challenges-in-kotlin/
[3] Swift's ABI stability: https://github.com/swiftlang/swift/blob/main/docs/ABIStabilityManifesto.md
[4] Swift Library Evolution for Stable ABIs available in Swift 5.1: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0260-library-evolution.md
[5] Store5 KMP library: https://github.com/MobileNativeFoundation/Store
[6] Hyperoslo Cache iOS library: https://github.com/hyperoslo/Cache
[7] Google's Jetpack Paging 3 library docs: https://developer.android.com/topic/libraries/architecture/paging/v3-network-db
[8] Priority Queue Pattern: https://learn.microsoft.com/en-us/azure/architecture/patterns/priority-queue
[9] Optimizing Android Volley: https://javanexus.com/blog/optimizing-android-volley
[10] Moya iOS library: https://github.com/Moya/Moya
[11] Handling Network calls in Swift with Moya: https://medium.com/simform-engineering/handling-network-calls-in-swift-with-moya-c82908c93e5
[12] SemVer: https://semver.org/
[13] Swift Package Manager integration with SemVer: https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html
[14] Jetpack Compose: https://developer.android.com/compose

CHAPTER 07

Hotel reservation app

07

In this chapter, we will design a hotel reservation system that empowers users to search, compare, and book hotels. Platforms like Booking.com and Hotels.com have transformed travel planning by integrating robust search capabilities and instant booking features into mobile applications. These systems must manage real-time room availability, prevent double bookings, and deliver accurate pricing.

Image represents a mobile phone screen displaying the Booking.com website.  At the top, a blue header shows 'Booking.com' prominently. Below, a search bar prompts 'Where do you want to go next?'.  Beneath the search bar are three horizontally aligned buttons: 'Sort,' 'Filter,' and 'Map,' each with corresponding icons. The main content area displays a list of hotels. Each hotel entry includes a small image, the hotel name (e.g., 'Hotel Villa Rosa'), a star rating (five stars in this case), a numerical rating (e.g., '8.9'), a descriptive adjective (e.g., 'Excellent'), and additional details like location ('In city center') or specific features ('Cleanliness 9.3').  A heart icon appears next to each hotel entry, presumably for saving favorites.  The hotels are listed vertically, one after another, with each hotel's information presented in a consistent format.  No URLs or specific parameters are visible beyond the website's domain name.

The architectural patterns we explore here are applicable to other booking systems as well, such as property rentals, flight reservations, or event ticketing.

Step 1: Understand the problem and establish design scope

Before diving into the design, we must first define its requirements and boundaries. Imagine we are discussing this with an interviewer to clarify the system's focus:

Candidate: I'd like to understand the scope first. Are we focusing on a single hotel chain, or is this an aggregator platform, like Booking.com, that pulls together options from multiple providers?
Interviewer: We're aiming for an aggregator model, where users can browse and book hotels from various sources.

Candidate: That gives us a solid starting point. For the core features, I'd suggest users should be able to search for hotels by location or destination, view details such as room types and pricing, and finalize bookings within the app. Should we also consider real-time updates to reflect room availability as others book during a user's search?
Interviewer: Those features work well as a foundation, but let's hold off on real-time updates for simplicity. While searching for hotels, we should include autocomplete suggestions. Another key requirement, though, is setting a time limit for users to complete their booking once they start entering payment details.

Candidate: Great. That helps clarify the booking flow. Building on that, let's explore the feature set further. Will users need options such as canceling reservations or checking in through the app? And should we plan for authentication flows or push notifications, such as reminders for upcoming trips?
Interviewer: Let's keep it focused. Assume users are already logged in, so no authentication flows. We'll also exclude cancellations and check-ins. For payments, include support for credit cards and third-party options such as PayPal. Push notifications aren't needed either.

Candidate: Got it. To guide our technical choices, could you share the system's expected scale? What's the monthly active user base we're designing for?
Interviewer: We're currently at around 5 million monthly active users worldwide, but the system should handle growth beyond that over time.

The conversation can continue, covering other topics we might consider interesting and relevant to the app. Let's summarize what we're building in this chapter.

Requirements

Based on our discussions, we're designing a hotel reservation app with the following functional requirements:

  • Users can search for hotels by location or specific destination.
  • The app provides autocomplete suggestions during searches.
  • Users can view detailed hotel information and available room types.
  • Users can complete room reservations within the app.
  • Users can process payments through multiple providers (e.g., credit cards, PayPal).
  • Users can access confirmed reservations even when offline.
  • The system enforces a time limit for completing reservations.

As for non-functional requirements, we need to build a system that ensures:

  • High availability: The app must remain accessible and reliable, even during peak loads, such as holiday seasons when user traffic increases significantly.
  • Low latency: Booking confirmations should process within a few seconds to maintain a responsive user experience and minimize wait times during critical transactions.
  • Data consistency: The system must prevent concurrent bookings of the same room for overlapping time periods, ensuring reservation data remains accurate across all users.

UI Sketch

To visualize how users will interact with the system, Figure 2 presents the core screens of our hotel reservation app:

  • The Hotel List screen (left) serves as the entry point, allowing users to search for hotels and displaying featured options initially. After a search, the screen shows the search results (middle) that update the list of available hotels.
  • The Hotel Details screen (right) offers comprehensive information about a selected hotel, including available rooms. Users can initiate the reservation process by tapping the "Book" button.
Image represents a three-screen mobile application workflow for hotel booking. The first screen displays a search bar with a 'Search' button. Below it, a section labeled 'Discover featured hotels' shows three hotel listings, each with a placeholder image, some text (likely hotel name and brief description), and a heart icon suggesting a 'favorite' functionality.  A curved arrow connects the 'Search' button to the second screen. The second screen is titled 'Your search' and displays three hotel search results, each with a placeholder image, text fields (likely hotel name, description, and price), and a heart icon. A filled circle on one of the results indicates the selected hotel. A curved arrow connects this selected hotel to the third screen. The third screen displays hotel details under the heading 'Hotel name,' followed by 'Amenities' and 'Available rooms' sections. Each 'Available rooms' entry shows a placeholder image, text (likely room type and price), and a 'Book' button.  The overall flow depicts a user searching for hotels, selecting one from the search results, and then viewing its details and booking options.

Step 2: API design

With a clear understanding of the requirements and user interface in place, let's transition to designing the API that will facilitate communication between the mobile client and the backend server.

Network protocol

Since the system does not require real-time updates or push notifications, most interactions will be client-initiated. We will adopt HTTP with REST APIs for client-backend communication, using JSON as the data format.

API endpoints and data models

Let's examine the RESTful endpoints our backend will expose. We'll focus on the most important endpoints and data models. Others can be explored in more detail if the interviewer requests.

πŸ’‘ Pro tip! During the interview, concentrate on defining endpoints and data models for the most critical requirements that typically align with the main user flows in the app.

While you should acknowledge that other functionality exists, you can describe it at a high level to show awareness without going into implementation details. This demonstrates you're considering the full scope while consciously prioritizing the most important aspects.

Hotel endpoints

Hotels are the cornerstone of the app, and users need intuitive ways to find and explore them. Here are the primary endpoints to enable this:

  • GET /v1/hotels/search?page={page}&limit={limit} &latitude={x}&longitude={y}&other_query_parameters
    allows users to search hotels by location and travel dates
    Response: 200 OK with HotelSearchResponse
    Other query parameters:
    • check_in_date (Date): Desired arrival date
    • check_out_date (Date): Planned departure date
    • guests (Integer): Number of guests staying
KotlinSwift
data class HotelSearchResponse
hotels: List<HotelPreview>
paging: PaginationMetadata
struct HotelSearchResponse
hotels: [HotelPreview]
paging: PaginationMetadata
data class HotelPreview
id: String
name: String
location: String
price: BigDecimal
lowResImageUrl: String
...
struct HotelPreview
id: String
name: String
location: String
price: Decimal
lowResImageUrl: String
...

πŸ› οΈ Platform implementation details

Throughout this book, we present API responses as Kotlin and Swift data models rather than raw JSON. In practice, these JSON payloads get converted to platform-specific data structures within the network layer (specifically in the network data sources shown in our architecture diagrams).

On Android, developers often rely on libraries such as kotlinx.serialization, or Moshi for JSON serialization and deserialization.

On iOS, Swift's built-in Codable protocol handles this elegantly, mapping JSON fields to Swift struct properties.

  • GET /v1/hotels/{id}?query_parameters
    fetches details for a specific hotel
    Response: 200 OK with HotelDetailsResponse
KotlinSwift
data class HotelDetailsResponse
hotel: HotelDetails
struct HotelDetailsResponse
hotel: HotelDetails
data class HotelDetails
id: String
name: String
location: String
imagesUrls: List<String>
amenities: List<Amenity>
curatedRooms: List<RoomSummary>
price: BigDecimal
...
struct HotelDetails
id: String
name: String
location: String
imagesUrls: [String]
amenities: [Amenity]
curatedRooms: [RoomSummary]
price: Decimal
...
data class Amenity
name: String
iconUrl: String
category: String
struct Amenity
name: String
iconUrl: String
category: String
data class RoomSummary
id: String
name: String
description: String?
imageUrl: String
maxOccupancy: Int
...
data class RoomSummary
id: String
name: String
description: String?
imageUrl: String
maxOccupancy: Int
...

Reservations endpoints

Booking a hotel is the natural next step, and we need endpoints to handle this process efficiently:

  • POST /v1/reservations
    initiates a reservation by securing a room
    Body: ReservationRequest
    Response: 201 Created with ReservationResponse
    Endpoint-specific errors:
    • 400 Bad Request if conditions such as HotelNotAvailable, RoomNotAvailable, TooManyGuests, or InvalidDates exceptions
KotlinSwift
data class ReservationRequest
requestId: String
hotelId: String
roomIds: List<String>
checkInDate: String
checkOutDate: String
guests: Int
struct ReservationRequest
requestId: String
hotelId: String
roomIds: [String]
checkInDate: String
checkOutDate: String
guests: Int
data class ReservationResponse
reservationId: String
hotelId: String
status: ReservationStatus
...
struct ReservationResponse
reservationId: String
hotelId: String
status: ReservationStatus
...

The ReservationRequest model includes the requestId field, which acts as an idempotency key. This prevents accidental duplicate bookings if network issues cause the same request to be sent multiple times.

When the backend processes a reservation request, it responds with a ReservationResponse model. This response contains all the original request information, plus several additional fields:

  • A backend-generated reservationId that serves as the definitive identifier for tracking and updating the reservation.
  • A status field indicating whether the reservation is pending, has failed, or has succeeded.

πŸ“Œ Remember: Adding idempotency keys to POST requests helps the backend de-duplicate client requests.

In the hotel booking system, this is critical. The requestId field in ReservationRequest prevents double-booking rooms.

  • GET /v1/reservations/{id}
    returns the details of a reservation and its status

  • PUT /v1/reservations/{id}
    updates an existing reservation with personal details, payment details, etc.
    Endpoint-specific errors:

    • PaymentError, PaymentProviderNotAvailable, PriceMismatch, UserInfoIncomplete, or CurrencyNotSupported exceptions

Step 3: High-level client architecture

With the API design in place, let's shift our attention to the client-side architecture. Figure 3 sketches out the architecture of our hotel reservation app.

Image represents a system architecture diagram for a mobile hotel booking application.  The diagram is divided into a client-side UI layer and a data layer, connected via an API Gateway. The UI layer contains screens for displaying a hotel list, hotel details, reservations, and making payments, each with a corresponding state holder managing its data.  A Navigator component controls the flow between these screens.  The data layer comprises repositories for hotels and reservations, each accessing both local and remote data sources.  A Network Dispatcher handles communication with the backend. The API Gateway communicates with the backend via HTTPS, which in turn interacts with a CDN for image loading and a third-party Payments Provider.  The data flow is primarily from the UI layer, through the data layer and API Gateway to the backend and third-party services, and back to the UI layer to update the screens.  HTTPS is used for secure communication between the client and the API Gateway, and between the API Gateway and the backend. The CDN provides image content to the API Gateway. Dependency Injection is shown as a mechanism for connecting the UI layer to the data layer.

Let's explore how the key components of our architecture work together.

External server-side components

Our system relies on several external services:

  • The Backend serves as our primary communication point, handling client interactions through HTTP.
  • A CDN improves performance by efficiently delivering static content.
  • The API Gateway manages authentication and controls system access.
  • External Payment Providers process and validate payment transactions.

Client architecture

The client architecture follows a similar layered approach to our previous designs, with distinct UI and data layers working together to create a seamless hotel booking experience.

In the UI layer, we have three main screens: Hotel List, Hotel Detail, and Reservation, each with its own dedicated state holder that manages the screen's logic and data presentation. We've also introduced a Make Payment Screen component to handle payments flexibly, supporting both direct credit card processing and integration with third-party payment providers.

The Data layer orchestrates the core business logic through specialized repositories that manage our primary data types: hotels and reservations. Each repository works with both local and remote data sources to ensure efficient data handling. All network communications flow through a central Network Dispatcher component, which coordinates backend interactions and manages access control.

This architecture provides a solid foundation for our hotel booking system. Let's explore some of its key aspects in more detail.

Step 4: Design deep dive

With the high-level architecture in place, let's examine some key aspects of our system in more detail.

  • Implementing reservation holds.
  • Enhancing search with autocomplete suggestions.
  • Processing payments.

Implementing reservation holds

In designing our hotel reservation app, we face the critical task of managing how long users can hold a room before finalizing their booking. This mechanism, often termed "reservation holds" in the industry, ensures that our system balances user convenience with the need to keep room inventory available for others.

Let's break down how this feature should work.

Requirements

Our reservation hold feature revolves around several key behaviors:

  • Fixed booking window: When a user starts a booking process, the backend temporarily holds the requested room for a fixed time window (typically 15 minutes). This countdown begins when the server acknowledges the initial reservation request via the POST /v1/reservations endpoint. The backend acts as the source of truth, providing an authoritative expiration timestamp.
  • Automatic release: If a user fails to complete the reservation within this timeframe, the system automatically releases the hold, returning the room to the available inventory. The released room is then instantly bookable by other users.
  • Visible countdown: Throughout the reservation process, the app provides clear, real-time feedback about the remaining reservation hold time, visually highlighting urgency.
  • Expiration cleanup: Upon expiration, any unpaid reservations are automatically canceled. This keeps our inventory accurate, preventing stale holds from cluttering the system and ensuring that availability reflects the actual state of our resources.

This combination of user-facing transparency and system-driven management maintains an equitable booking process while preserving the platform's responsiveness and accuracy.

Managing reservation hold timers

Ensuring that the client and backend remain synchronized in tracking reservation time is a pivotal challenge. Mobile devices complicate timer management because:

  • Unreliable device time: Users can manually set their device's clock, throwing off local timers.
  • Network delays: Latency between the client and server can skew when the hold appears to start or end.
  • App suspension: When the app is minimized or the phone locks, the timer must stay accurate without breaking.

Relying on the device's clock alone won't work. We need a better approach that has the backend as the source of truth. Thus, the backend communicates the reservation expiration UTC timestamp to the client.

To display the most accurate representation of the remaining reservation time to the user on the client, regardless of device or network conditions, we use the device uptime.

The device's uptime ensures that timers remain accurate during app suspension (e.g., when the app is minimized or the phone locks) and is immune to user clock changes, unlike standard wall clock time, which can be manipulated.

πŸ› οΈ Platform implementation details

Both iOS and Android provide native APIs to handle time tracking and countdowns:

  • On iOS, use ProcessInfo.processInfo.systemUptime to get system uptime, and create timers using the Timer class.

  • On Android, get system uptime via SystemClock.elapsedRealtime(), and implement countdowns with CountDownTimer.

This choice ensures precision from the server while accommodating mobile-specific interruptions.

βœ… Decisions made!

  • Backend timestamps serve as the single source of truth for tracking reservation holds.

  • Client displays the countdown by combining the backend's expiration timestamp with local device uptime measurements.

Architecture changes in the diagram

  • A Reservation Timing Controller in the data layer to handle the core reservation timing logic we discussed.
  • A Reservation Timer in the UI layer to display the countdown to users.

The addition of separate components in different layers of the Architecture follows the Separation of Concerns principle (Figure 4). It creates clear responsibility boundaries, making the system more maintainable and testable. These components work together to keep users informed of their remaining reservation time.

Image represents a client-side architecture diagram for a reservation system.  The diagram is divided into two main layers: a UI layer and a data layer. The UI layer contains a `Navigator`, which controls the flow between three screens: `Reservation Timer`, `Reservation Screen`, and `Make Payment Screen`.  Data flows from `Reservation Timer` to `Reservation Screen`, and then to `Make Payment Screen`.  Below the screens, a `Reservation State Holder` manages the reservation's state.  The data layer, separated by a `Dependency Injection` layer, consists of a `Reservations Repository`, which interacts with both `Reservations Remote DataSource` and `Reservations Local DataSource`. The `Reservation Timing Controller` interacts with the `Reservations Repository`, suggesting data synchronization or validation.  Arrows indicate the direction of data flow and interaction between components.  The overall structure suggests a layered approach to mobile app design, separating user interface concerns from data management and persistence.

πŸ” Industry insights:

Expedia's API explicitly supports a two-step booking: a client can put a room β€œon hold” (no charge) and then confirm the booking within a given time window [1].

To explore how to design the backend system, check out the "Hotel Reservation System" chapter of the System Design Interview Volume 2 book [2].

Enhancing search with autocomplete suggestions

Autocomplete is a popular feature in mobile design interviews, especially for hotel booking apps. It helps users quickly find cities, destinations, or hotels as they type, but implementing it on mobile comes with technical challenges.

While autocomplete can get complex with advanced algorithms, personalization, and scalability, we'll keep it simple by focusing on efficiently loading autocomplete data.

Understanding the search experience

Currently, our app relies on a basic search mechanism: users enter a query, press "Search," and the client retrieves results via a GET /v1/hotels/search request to the backend. While this approach works, it lacks the dynamic interactivity that modern mobile users expect.

Autocomplete makes things easier by showing suggestions as people type. However, to achieve this effectively, we must address several performance considerations:

  • Network latency and bandwidth: Autocomplete responses must be nearly instantaneous to feel responsive. Querying a server for every keystroke risks slow responses and excessive data usage, frustrating users on slow connections.
  • Offline expectations: Users expect functionality even without a network. Autocomplete must offer meaningful suggestions offline, especially for frequent travelers in remote areas.
  • Resource constraints: Mobile apps must minimize battery, CPU, and memory usage. Overly complex logic or frequent network calls can degrade performance.
  • Input dynamics: Mobile keyboards lead to slower typing and more errors, making fast, forgiving autocomplete critical.

With these considerations in mind, let's explore how we can effectively implement autocomplete within our app.

To deliver meaningful autocomplete suggestions, our app must anticipate user intent, offering relevant matches for countries, cities, and hotels as queries take shape. Table 1 explores multiple strategies for sourcing this data, each with its own merits and drawbacks:

OptionDescriptionAdvantagesDisadvantages
Local static datasetEmbed all autocomplete data within the app.Works offline. Eliminates network delays. Ensures stable performance.Expands app size. Demands updates for freshness. Impractical for evolving data.
Real-time remote API fetchingRetrieve suggestions from the backend with each keystroke.Provides current data. Requires no local storage. Highly adaptable.Latency can disrupt UX. Increases data usage. Needs strong error handling.
Third-party servicesLeverage external APIs (e.g., Google Places) for suggestions.Access to rich, curated datasets. Reduces maintenance burden.May incur costs. Raises privacy concerns over data sharing. Ties us to external providers.
Pre-fetch popular dataLoad common search terms (e.g., top cities, hotels) in advance.Speeds up frequent queries. Optimizes network use.Risks of storing unused data. Requires periodic refreshes.
On-demand data loadingRetrieve suggestions from the backend after users complete their search.Conserves resources. Delivers tailored results. Keeps storage lean.Offers limited initial suggestions. It may feel sparse for new searches.

Table 1: Trade-offs for providing autocomplete data

As you can see, each approach has its pros and cons. For this exercise, we'll go with a hybrid solution: pre-fetching popular data combined with on-demand loading for user-specific searches. This method finds a middle ground, and here's why it works well:

  • Keeping the app lean and avoiding a bulky embedded dataset helps preserve a manageable installation size.
  • Minimizing network load, such as pre-fetching common terms at startup, reduces real-time requests.
  • Personalizing the experience with on-demand fetches that adapt to users’ search patterns over time, delivering tailored suggestions based on their history.

This strategy assumes that users often search within popular regions, starting with broad suggestions that become more personalized over time. By preloading commonly searched terms and incrementally updating the cache based on user interaction, we achieve immediate responsiveness while maintaining flexibility and relevance.

Search autocomplete data flow

Our autocomplete system operates through a three-step process:

  1. Initial data loading. When our app launches, it checks the current version of the cached autocomplete data. If the server indicates newer data exists, the app downloads and updates the local cache accordingly. For this process, we can implement patterns such as the ChangeList pattern or timestamp-based synchronization.
  2. Local query processing. As the user types, the app queries the local autocomplete dataset. To avoid overwhelming the system with rapid-fire database requests, we introduce a brief input debounce delay (typically 200–300 ms). This means the app waits briefly after the user pauses typing before performing the search, balancing responsiveness and performance.
  3. Network-based refinement. When users select a suggestion or finalize a search, the app fetches additional data for that region in the background. This newer data is cached locally with a shorter time-to-live (TTL), ensuring freshness without excessive network load.

Next, let's consider how to store this data effectively.

Storage solution

For autocomplete to shine, we need a storage system optimized for rapid text retrieval. Given our focus on names and locations, Full-Text Search (FTS) [3] proves ideal, offering:

  • The system uses fast prefix matching to find terms that begin with the user's input, which is great for providing autocomplete suggestions.
  • Smart text tokenization that improves search accuracy.
  • Built-in result ranking based on relevance.
  • Fuzzy search capability that handles common mobile typing mistakes.

While FTS demands extra storage for indexes and adds some complexity, its benefits to the user experience might justify the investment. SQLite with FTS extensions emerges as our choice, leveraging its strengths across platforms.

πŸ› οΈ Platform implementation details

Both Android and iOS can implement Full-text search (FTS) through SQLite.

  • On Android, the Jetpack Room persistence library provides built-in FTS support [4], making implementation straightforward. Room handles the complexity of FTS configuration and query optimization, allowing you to focus on your search functionality.

  • iOS developers have a few options, as Core Data doesn't natively support FTS. You can either work directly with SQLite to implement FTS capabilities or use wrapper libraries such as GRDB [5] that simplify SQLite integration while maintaining full FTS functionality.

Potential challenges and considerations

While autocomplete significantly enhances usability, it also introduces complexities we must carefully address:

  • Data synchronization: Efficient updates via ChangeList or similar methods require careful versioning to avoid conflicts. Fetching only deltas since the last sync optimizes bandwidth.
  • Throttle tuning: A 200–300ms delay suits most users, but we might refine this dynamically based on typing speed or device capability to enhance responsiveness.
  • Data freshness: For volatile data such as hotel availability, shorter expiration times maintain accuracy without excessive network calls.
  • Request optimization: On-demand loading could strain the server during peak use. Background syncing over Wi-Fi or predictive pre-fetching can mitigate this.

To recap, our autocomplete storage implementation leverages FTS via SQLite, offering robust performance optimized for fast queries. This setup, complemented by intelligent caching and synchronization strategies, ensures a responsive and intuitive user experience across mobile clients.

πŸ” Industry insights:

If you're interested in how other companies solved this problem, you can read how:

  • Traveloka.com supports multiple languages and incorporates geographic location awareness to improve search relevance and reduce typos [6].

  • Tiket.com developed a machine learning model that improved the quality of autocomplete ranking results by 4% to 7.5% [7].

Processing payments

Handling payments securely is an essential aspect of hotel reservation apps. Designing a payment integration requires careful consideration of user experience, data security, compliance with industry regulations, and reliable interaction with external services.

Our hotel reservation app supports two primary payment methods:

  • Direct credit card payments, where users manually enter their card details (name, number, expiry, CVV).
  • Third-party payment providers, such as PayPal, Apple Pay, or Google Pay, facilitate quick and secure transactions.

In this section, we will focus on integrating these payment methods within the mobile app.

Credit card payments

When processing credit card payments, security must remain a top priority. Integrating credit card payments securely into our app involves specific considerations:

  • Avoid storing sensitive data on the device. Card details should reside only briefly in volatile memory during active transactions.
  • HTTPS communication with TLS encryption ensures secure data transmission.
  • Tokenization [8] securely stores credit card information by replacing sensitive details with tokens that reference the actual data safely stored by a payment processor. Tokenization enables users to reuse previously entered payment methods securely, enhancing convenience without compromising security.

With tokenization, we can maintain minimal client-side storage for saved payment methods by retaining only a unique id that references the card securely, and the last four digits of the card number to help users quickly identify saved payment methods without exposing sensitive details.

Integrating credit card payments in our design

Once the credit card information is sent to the server as part of the PUT /v1/reservations/{id} endpoint, we face two viable paths:

  1. Direct integration with payment processors [9]. Here, our backend connects directly to services such as Worldpay, Fiserv, or Global Payments. This grants us control but requires substantial effort to meet PCI DSS compliance, a demanding and resource-heavy task.
  2. Leveraging Payment Service Providers (PSPs) [10]. Partnering with PSPs such as Stripe or PayPal offloads compliance and security responsibilities. Their SDKs streamline integration across our backend and mobile clients, aligning with industry best practices.

⚠️ Important security note:

Directly handling credit card data and communicating with Payment Processors requires extensive security measures. This approach demands significant investment in infrastructure and ongoing maintenance to comply with PCI DSS standards [11].

For most applications, it's safer and more practical to use established Payment Service Providers that already handle these security requirements.

Recognizing the complexity of direct integration, we've chosen to collaborate with trusted PSPs. This decision simplifies our development efforts and reinforces user trust by aligning with recognized payment experts, setting a solid foundation as we explore broader integration options.

Integrating with third-party Payment Service Providers

Integrating third-party Payment Service Providers (PSPs) enables secure, user-friendly payment processing. Modern PSPs, such as Stripe or PayPal, handle not only basic card payments but also offer comprehensive solutions, including digital wallets and alternative payment methods (e.g., Klarna).

When integrating PSPs into mobile apps, one key architectural choice we must consider is whether to route payments directly from the client or channel them through our backend server. Table 2 explores different approaches that come with unique benefits and trade-offs.

AspectBackend integrationClient-side integration
ImplementationBackend orchestrates PSP interactions.Clients engage PSPs directly, managing payment interfaces.
PerformanceAdditional latency from backend processing.Lower latency. Faster payments due to direct client–PSP communication.
SecuritySensitive data is handled exclusively server-side, improving overall security.Sensitive data managed client-side, increasing complexity in securing client apps.
MaintenanceEasier PSP integration management. Changes are centralized.Multiple platform-specific integrations. Requires client updates for PSP changes.
User ExperienceUniform user experience across platforms, limited by backend implementation.Richer, more native experience with full PSP features such as biometric authentication.
DevelopmentIncreased backend complexity. Simpler client-side implementation.Increased client complexity due to direct PSP SDK integration.

Table 2: Trade-offs for integrating with PSPs

After evaluating these trade-offs, integrating PSPs directly on the client provides users with a richer, native payment experience and access to advanced payment options, including device-specific biometric authentication methods. Although this adds complexity in client-side management, it aligns with the expectations of a high-quality, modern mobile experience.

βœ… Decisions made!

The hotel reservation system processes all payment transactions from the client UI through third-party Payment Service Providers.

Architecture updates

Integrating payments into the mobile architecture (Figure 5) requires minimal adjustments to the architecture diagram:

  • The Make Payment State Holder component handles all payment logic, connecting third-party payment SDKs with our Reservations repository.
  • The PSP Payment UI components from an external SDK manage the user-facing aspects of payments, securely collecting payment details and offering convenient options.
Image represents a client-side architecture for a mobile payment system.  The diagram shows a client application, labeled 'Client,' containing a UI layer with three components: a 'Navigator,' a 'Make Payment Screen,' and a 'Make Payment State Holder.'  The 'Navigator' component likely controls navigation within the UI. The 'Make Payment Screen' displays the payment interface to the user, and it interacts with the 'Make Payment State Holder,' which manages the application's payment-related data.  The 'Make Payment Screen' communicates with a 'Third Party client SDK' (Software Development Kit), which acts as an interface to a Payment Service Provider (PSP).  This interaction is represented by a unidirectional arrow from the 'Make Payment Screen' to the 'Third Party client SDK.' The SDK, in turn, interacts with the PSP's 'PSP Payment UI,' which likely handles the actual payment processing.  The entire PSP interaction is enclosed within a dashed box labeled 'Third Party client SDK.'  The data flow is primarily from the client's UI components to the PSP via the SDK, suggesting a process where the client initiates a payment, and the PSP handles the transaction.

These additions to our architecture clearly delineate payment responsibilities, improving maintainability and ensuring secure and reliable handling of sensitive user transactions.

Payment data flow

To understand how payment data travels through our system, consider the following end-to-end flow when a user books a hotel room, as illustrated in Figure 6.

After the initial reservation, when the client sends a POST /v1/reservations request with booking details and the server returns a successful response with a reservationId, the following steps take place:

  • Payment collection. The client calls the PSP's SDK to gather payment details, keeping sensitive data off our servers (step 1).
  • Submit payment confirmation for the reservation. Post-payment, the client submits a PUT request to/v1/reservations/{id} with tokenized transaction identifiers provided by the PSP (step 2).
  • Backend verification. The backend confirms the payment with the PSP (step 3), locks in the reservation upon success, and returns the confirmation to the client (step 4).
Image represents a system architecture diagram for a mobile payment system within a reservations application.  A user (labeled 'User') initiates a payment (labeled 'Makes payment') which flows into a 'Make Payment Screen'. This screen interacts with a 'Third Party client SDK' (labeled 'PSP Payment UI'), specifically a Payment Service Provider's (PSP) client-side software development kit, indicated by the numbered connection '1'.  The 'Make Payment Screen' also interacts with a 'Make Payment State Holder', which in turn communicates with a 'Reservations Repository'. The 'Reservations Repository' is connected to a 'Reservations Remote DataSource' via bidirectional arrows, indicating data persistence and retrieval.  The 'Make Payment State Holder' also receives data from and sends data to the 'Reservations Repository'.  The numbered connection '2' shows data flow from the 'Reservations Remote DataSource' to a 'Backend' component, and connection '4' shows the reverse flow.  Finally, the 'Backend' interacts with the 'PSP Payment Server Provider' (labeled 'PSP Payment Server Provider'), a third-party payment processing service, via bidirectional arrows labeled '3', completing the payment processing loop.  The entire PSP interaction is enclosed in a dashed box, clearly separating the third-party components from the application's internal components.

πŸ“ Note:

Payment Service Providers (PSPs) typically handle transactions in two steps:

  • Authorization: Reserve the funds in the customer's account.

  • Capture: Move the money when the transaction is confirmed.

This process makes payments more reliable. If something goes wrong before the transaction is confirmed, the money is still in the customer's account, since it has only been reserved, not taken.

When using a PSP's SDK, to ensure everything stays in sync, the backend should always verify the reservation before capturing the payment.

Integrating third-party PSP SDKs in the client

Popular PSPs such as Stripe, PayPal, or Apple Pay offer extensive SDKs, making client-side integration more straightforward.

When integrating PSP SDKs into our app, we should carefully handle common mobile-specific payment implementation challenges, including:

  • Error handling: Implement robust handling for common payment-related exceptions, such as declined transactions, timeouts, or network issues. Clearly communicate payment status and potential actions to the user.
  • Transaction retries: Use exponential backoff strategies to manage retries efficiently, preventing excessive load on PSP servers in case of temporary failures.
  • User feedback: Consistently update users with clear, real-time feedback on their payment progress and status, ensuring transparency throughout the process.

Addressing these considerations enhances overall reliability, resulting in a secure, transparent, and user-friendly payment experience.

πŸ” Industry insights:

If you're interested in how other companies solved this problem, you can read how:

  • Expedia migrated to a cloud-based microservices payment platform for flexibility and speed [12].

  • Stripe explains payment tokenisation [13] and handles fraud prevention with 3D Secure [14].

Step 5: Wrap-up

Throughout this chapter, we've designed a comprehensive hotel reservation system from the ground up. At its core, our system enables seamless hotel search, browsing, and booking. We took special care to address critical aspects such as managing booking timeouts, optimizing search performance, and ensuring smooth payment flows.

If you have extra time in your interview or want to explore more advanced features, consider these additional requirements:

  • Real-time availability updates: Implement live updates to room availability, ensuring users always see the current inventory.
  • Smart notifications: Design a system for timely push notifications about upcoming stays, check-in reminders, and booking confirmations.
  • Reservation management: Enable users to modify or cancel bookings, with appropriate handling of cancellation policies and refunds.
  • Promotional system: Design a flexible framework for handling various types of discounts, loyalty programs, and seasonal offers.

Resources

[1] Expedia booking hold and resume APIs: https://developers.expediagroup.com/docs/products/rapid/lodging/booking/hold-resume
[2] System Design Interview – An Insider's Guide: Volume 2 by Alex Xu and Sahn Lam: https://www.amazon.com/dp/1736049119/ref=sspa_dk_hqp_detail_aax_0?psc=1&sp_csd=d2lkZ2V0TmFtZT1zcF9ocXBfc2hhcmVk
[3] Full-text search: https://en.wikipedia.org/wiki/Full-text\_search
[4] Google's Room library FTS support: https://developer.android.com/training/data-storage/room/defining-data
[5] iOS GRDB library: https://github.com/groue/GRDB.swift
[6] Traveloka autocomplete search: https://medium.com/traveloka-engineering/high-quality-autocomplete-search-part-1-fcc1b0a4baa6
[7] Tiket.com autocomplete search ML model: https://medium.com/tiket-com/algorithm-behind-search-in-tiket-com-4bfaeb42980b
[8] Tokenization: https://en.wikipedia.org/wiki/Tokenization\_(data\_security)
[9] Payment Processor: https://en.wikipedia.org/wiki/Payment\_processor
[10] Payment Service Provider (PSP): https://en.wikipedia.org/wiki/Payment\_service\_provider
[11] Payment Card Industry Data Security Standard (PCI DSS): https://en.wikipedia.org/wiki/Payment\_Card\_Industry\_Data\_Security\_Standard
[12] Expedia vendor payment transactions: https://aws.amazon.com/solutions/case-studies/expedia-aurora-case-study/
[13] Stripe payment tokenisation: https://stripe.com/en-es/resources/more/payment-tokenization-101
[14] Stripe 3D secure fraud prevention: https://docs.stripe.com/issuing/3d-secure

CHAPTER 08

Google Drive app

08

Cloud storage has become fundamental to how we work with files across devices. Services such as Google Drive, Dropbox, Microsoft OneDrive, and Apple iCloud have revolutionized file management by seamlessly integrating cloud storage into everyday workflows. In this chapter, we'll explore the design of a Google Drive mobile client.

Figure 1 illustrates the Google Drive mobile interface, showing how these complex requirements translate into a clean, user-friendly experience.

Image represents a split-screen view of a Google Drive mobile application on an iPhone.  The left screen displays a list of files under the 'Suggested' tab, categorized by file type icons (document, spreadsheet, presentation) and showing file names, last modified information (e.g., 'You edited Β· Apr 25, 2024'), and the editor's name.  Files include 'Leadership Plan,' 'Forecasting Q2,' 'Q4 Proposal,' 'Weekly Notes,' 'AcmeBranding 0.8.3 Corrective...', 'Project Roadmap (Final),' 'DataGuidelines_v2_Process_and_P.,' and 'Leadership & Organization Update.'  Navigation icons at the bottom represent 'Home,' 'Starred,' 'Shared,' and 'Files.'  The top bar shows 'Search in Drive,' a user profile picture, and the 'Activity' tab. The right screen shows the 'My Drive' view, displaying folders titled 'Work' and 'Holidays.' A bottom sheet menu is visible, offering options to 'Upload file,' 'Scan document,' 'Take photo,' 'Create folder,' and create new Google Docs, Sheets, and Slides files.  Both screens share a top bar with the search bar and user profile picture, indicating a consistent user session.  No explicit data flow is visually represented, but the interaction implies that selecting a file on the left would open it, while selecting an option from the bottom sheet on the right would initiate the corresponding action within the Google Drive application.

Step 1: Understand the problem and establish design scope

Here's how the conversation with the interviewer might unfold:

Candidate: Let's begin by defining the core functionality of the Google Drive mobile client. I think the essential features include uploading and downloading files, with options to cancel these operations if needed. Users should also be able to create and delete folders to organize their content. Since Google Drive handles common file types like photos, videos, and documents, we'll also need to support those formats. Does that sound like a good starting point?
Interviewer: Yes, that covers the basics. Since this is a mobile app, network reliability can be an issue. Let's make uploads resumable so users can pick up where they left off if a connection drops.

Candidate: That makes sense! Resumable uploads are especially useful for larger files or unreliable networks. Speaking of file sizes, is there a maximum limit we should design for?
Interviewer: Let's set it at 10 GB.

Candidate: Got it. Users might upload the same file multiple times with updates. Should we include file versioning so they can track changes and restore previous versions of files?
Interviewer: Yes, file versioning is a valuable feature for cloud storage.

Candidate: Great. Next, since users often access Google Drive from multiple devices, we should ensure files sync seamlessly across them. Are we designing for a single user with multiple devices, or do we need to consider multi-user scenarios such as file sharing?
Interviewer: Let's focus on a single user with multiple devices for simplicity. We'll exclude sharing and permission management for now.

Candidate: For non-functional requirements, I assume we need scalability for a large user base and data durability for reliable file access, even in poor network conditions. Does that align with your expectations?
Interviewer: Exactly. For scale, plan for about 10 million daily active users.

Candidate: Noted. One last thing, Google Drive offers real-time editing and collaboration on Docs and Sheets. Should we include those features?
Interviewer: For this design, treat Docs and Sheets as standard files, leaving out real-time editing and collaboration.

Requirements

Based on our discussions, let's outline the core functionality needed for our Google Drive app.

  • Users can upload and download files with a maximum size of 10 GB and cancel operations in progress.
  • Users can create and delete folders to organize content.
  • Users can view and restore previous versions of files.
  • The app supports resuming interrupted uploads.
  • Files sync across the user's multiple devices.

For non-functional requirements, our system must ensure:

  • Scalability: Our system should handle 10 million daily active users and support a growing user base, as well as large data volumes, across various network conditions and device types.
  • Data durability: Users need reliable access to their downloaded files, even in poor network connectivity conditions.

To keep our design focused, the following features are out of scope:

  • Real-time collaboration (e.g., multi-user editing on Docs and Sheets).
  • User collaboration features, such as file sharing and permissions.

UI sketch

Figure 2 shows the core screens of our Google Drive app:

  • The Files List screen (center) is the app's main hub where users view and manage their files and folders.
  • The Upload Modal (left) provides a simple, focused interface for uploading files.
  • The File Revisions screen (right) shows the version history of a specific file for review and restoration.
Image represents a mobile application workflow for file management and version control.  The diagram shows three distinct screens. The leftmost screen depicts a file explorer interface with a list of files, each showing a small icon, a modification date (e.g., 'Modified Jul 1, 2025'), and ellipses suggesting further options.  Below the file list is a section labeled 'Create folder' and 'File upload,' indicating options for adding new files or folders. The central screen is similar, displaying a list of files with modification dates and ellipses, but also includes a '+' button at the bottom, suggesting the ability to add new files.  A curved arrow connects this '+' button to the leftmost screen, implying that new files are added through the 'File upload' function.  The rightmost screen is titled 'File revisions' and displays a list of file versions, each with a checkbox, a modification date (e.g., 'Modified Jan 10, 2025'), and 'OPEN' and 'RESTORE' buttons.  The top entry is labeled 'Current version,' indicating the most recent version. A curved arrow connects the central screen to the 'File revisions' screen, suggesting that selecting a file in the central screen opens its revision history.  The overall flow shows the user uploading files (left screen), viewing them (center screen), and accessing their revision history (right screen) to open or restore previous versions.

Step 2: API design

With the app's requirements and UI sketch defined, we now turn to designing the API that connects the mobile client to the backend.

Network protocol

The Google Drive app primarily involves client-driven actions such as uploading files, downloading content, or organizing folders. For these, HTTP with REST APIs offers a straightforward and widely adopted solution.

However, to handle real-time notifications, such as when a shared file is updated, we'll complement REST with either WebSockets or Server-Sent Events (SSE). Since these notifications primarily signal changes rather than transfer actual file content, either approach can effectively handle these lightweight updates.

πŸ“ Note:

While we're using REST in this design, gRPC with Protocol Buffers would also be a strong choice for the Google Drive app, given our scale requirements. gRPC offers several advantages that align well with our needs:

  • Binary encoding for improved performance.

  • Strong type safety to ensure API reliability.

  • Efficient bidirectional streaming for real-time file updates.

If you're experienced with gRPC and Protocol Buffers, feel free to adapt the API design accordingly. The patterns we discuss remain relevant regardless of the protocol choice.

Endpoints and data models

Next, let's outline the core API endpoints and their data structures, starting with the essentials: files and folders.

Files and folders

Since folders in Google Drive behave like files with a special type, we'll unify their management under a single set of endpoints for consistency.

  • List files
    GET /v1/files?parentId={fileId}&page={page}&limit={limit}

    Response: 200 OK with GetFilesResponse. Optional query parameters:

  • parentId (String): ID of the parent folder. Defaults to the root if absent.

  • Get file details
    GET /v1/files/{fileId}

    Response: 200 OK with File.

  • Download file
    GET {downloadUrl} (provided in the File object)

  • Create file or folder
    POST /v1/files

    Body: Metadata (e.g., name, mimeType, parentId).

  • Delete file or folder
    DELETE /v1/files/{fileId}

  • Update file or folder metadata

PATCH /v1/files/{fileId}

KotlinSwift
data class GetFilesResponse
files: List<File>
pagination: PaginationMetadata
struct GetFilesResponse
files: [File]
pagination: PaginationMetadata
data class File
id: String
name: String
mimeType: String
size: Long
downloadUrl: String?
checksum: String?
...
struct File
id: String
name: String
mimeType: String
size: Int64
downloadUrl: String?
checksum: String?
...

πŸ“ Note: These endpoints follow standard REST conventions. All requests require:

  • Authentication via a JWT bearer token in the Authorization header.

  • JSON content type (application/json).

Like any REST API, these endpoints may return 4xx status codes for client errors such as invalid authentication, missing resources, and malformed requests, and 5xx status codes for server errors.

Having covered file management, let's shift to versioningβ€”a key feature for tracking changes and restoring past states.

File revisions

To enable users to view and revert file history, we'll add revision-specific endpoints tied to each file.

  • List revisions
    GET /v1/files/{fileId}/revisions?page={page}&limit={limit}

  • Get revision details
    GET /v1/files/{fileId}/revisions/{revisionId}

  • Restore revision
    POST /v1/files/{fileId}/revisions/{revisionId}/restore

The FileRevision data model is defined as follows:

KotlinSwift
data class FileRevision
revisionId: String
fileId: String
mimeType: String
size: Long
modifiedAt: String
downloadUrl: String
checksum: String
struct FileRevision
revisionId: String
fileId: String
mimeType: String
size: Int64
modifiedAt: String
downloadUrl: String
checksum: String

This configuration allows the client to easily view and revert to previous versions of the document.

Step 3: High-level client architecture

Having defined the API structure, let's shift our focus to the mobile client's architecture. The goal is to create a modular and scalable design that supports the app's features while remaining adaptable for future growth.

We'll outline the key components and their interactions, as illustrated in Figure 3, starting with the external dependencies and then diving into the client itself.

Image represents a client-server architecture for a file management system.  The client, enclosed in a rounded rectangle labeled 'Client,' features a UI layer with a 'Navigator' component directing interactions between 'File Revisions Screen,' 'File List Screen,' and 'File Upload Modal.'  Each screen has a corresponding 'State Holder' managing its data.  Below, a 'Data layer' houses 'Files Repository' and 'Revisions Repository,' each interacting with local and remote 'DataSources' (Files and Revisions respectively).  These DataSources access a central 'Files DB.'  A 'Network Dispatcher' manages communication with the server. The server-side, depicted on the right, includes an 'API Gateway' communicating with a 'Backend' via HTTPS and SSE/WSS protocols. The API Gateway also interacts with a 'CDN' via HTTPS for image loading, indicated by a connection from the client's 'Image Loader' to the CDN.  The 'Dependency Injection' layer facilitates communication between the UI and Data layers within the client.  Data flows from the DataSources to the Repositories, then to the State Holders, and finally to the Screens in the UI layer.  The API Gateway acts as an intermediary, handling requests from the client and responses from the Backend and CDN.

External server-side components

To set the stage, let's first consider the external services the client relies on:

  • The Backend is the core system handling file storage, metadata, and business logic. It provides REST APIs for operations such as uploads and deletions, as well as SSE/WebSockets for real-time updates.
  • The CDN boosts performance by caching static files closer to users, minimizing download times.
  • The API Gateway is the client's single point of entry to the backend, managing authentication, request routing, and security enforcement.

Client architecture

Now, let's explore the client side, which splits into two core layers: the UI layer and the Data layer. This separation keeps the code clean, testable, and maintainable.

The UI layer consists of three main screens: a File List Screen for browsing content, a File Upload Modal for adding new files, and a File Revisions Screen for viewing file history.

The data layer manages the core functionality through Files and Revisions repositories. They work together with the Network Dispatcher to handle HTTP communication with the backend, while the Revisions Remote Data Source listens for real-time updates through SSE or WebSockets.

This architecture provides a solid foundation for our Google Drive app. Now, let's dig deeper into some of the system's more complex aspects.

Step 4: Design deep dive

Some unique aspects of the Google Drive system deserve special attention. Let's take a closer look at data storage, resumable file uploads, version history, and data encryption.

Data storage

Efficient data management is key in a cloud storage app like Google Drive, where the mobile client must handle metadata (e.g., file names, folder hierarchies, version info) and downloaded file content. Let's break down how each is stored locally for optimal performance and security.

File metadata storage

Our app needs to efficiently handle several types of data as users interact with their files:

  1. File and folder metadata when browsing through directories, using the File data model.
  2. Version history and real-time updates for tracking changes, using the FileRevision data model.

This metadata requires frequent querying and updates. For managing this, a relational database proves ideal because:

  • Enables fast, structured queries for operations such as listing files or finding revisions.
  • Maintains data relationships (e.g., linking files to folders via parentId), preserving integrity.
  • Performs well even as metadata grows, supporting scalability.
Database design

The File and FileRevision data models map naturally to database tables since they have well-defined structures. This makes it straightforward to perform complex queries across the file hierarchy, track relationships between files and their revision history, and maintain data consistency as files change.

Figure 4 shows how we've updated our architecture to reflect this design, with separate Data Access Object (DAO) components for each database table.

Image represents a data layer architecture diagram for managing files and their revisions.  The diagram is enclosed within a dashed rectangle labeled 'Data layer'. Inside, there are two main branches. The left branch depicts file management: a 'Files Local DataSource' box is positioned at the top, connected via an upward-pointing arrow to a 'Files DAO' box below. The right branch handles file revisions: a 'Revisions Local DataSource' box sits at the top, connected via an upward-pointing arrow to a 'FileRevisions DAO' box below.  Both 'Files DAO' and 'FileRevisions DAO' boxes are connected via curved upward-pointing arrows to a central cylindrical database labeled 'Files DB'. This indicates that both file and revision data are stored and retrieved from this database. The arrows represent the flow of data, showing how the DAOs (Data Access Objects) interact with the database to manage the data from their respective local data sources.
Caching approach

To balance speed and storage, we'll cap the metadata database at a configurable size, such as 200MB, using an LRU (Least Recently Used) eviction policy. This keeps recently accessed metadata on-device while clearing out older entries. Since each metadata record is small, we can cache a large number of entries with minimal storage impact.

Managing downloaded files

For offline access, users can download files, which need secure and practical local storage.

The app's private internal storage, sandboxed by iOS and Android, is ideal because:

  • Files are protected from external access.
  • The app can manage files (e.g., delete or update) without affecting other apps or user directories.
  • Files are directly accessible within the app, with an option to export to shared storage if needed.
How it works

Files are stored in a specific folder (e.g., /data/user/0/com.google.android.apps.drive/files/downloads/<fileId> on Android), using their fileId as a unique file name to avoid conflicts.

Before downloading, the app checks available space and alerts users if it's low, ensuring smooth operation even with large files.

πŸ” Industry insights:

Box Drive allows users to mark folders for offline access. When selected, content is automatically downloaded in the background and stored locally for offline use [1].

Resumable file uploads

Resumable uploads are a cornerstone of a mobile cloud storage app, especially for managing large files and coping with unreliable networks. They deliver key benefits: users can pause and resume uploads without needing to restart, recover from interruptions seamlessly, and manage uploads based on network or battery conditions, which enhances both efficiency and the user experience.

Upload types

Many popular apps use resumable uploads to efficiently handle large files. This includes cloud storage services such as Google Photos [2], X (formerly Twitter) [3], Box [4], Dropbox [5], Microsoft OneDrive [6].

Google Drive [7], for example, offers three distinct upload methods according to its documentation [8]:

  • "Simple upload (uploadType=media): Use this upload type to transfer a small media file (5 MB or less) without supplying metadata."
  • "Multipart upload (uploadType=multipart): Use this upload type to transfer a small file (5 MB or less) along with metadata that describes the file, in a single request."
  • "Resumable upload (uploadType=resumable): Use this upload type for large files (greater than 5 MB) and when there's a high chance of network interruption, such as when creating a file from a mobile app. Resumable uploads are also a good choice for most applications because they also work for small files at a minimal cost of one additional HTTP request per upload."

Let's explore how to implement file uploads in our Google Drive mobile client. Since simple upload is straightforward, let's focus on resumable uploads.

High-level steps

  1. Initiate: The client requests an upload session and receives a unique upload ID and session details from the server.
  2. Upload chunks: The file is sent in smaller chunks. If the connection is interrupted, the client resumes from the last successful chunk.
  3. Complete: Once all chunks are uploaded, the server finalizes the file with an optional integrity check.
Image represents a simplified state diagram for a file upload process.  The diagram shows three rectangular boxes representing distinct states: 'Init,' 'Upload,' and 'Complete.'  Arrows indicate transitions between these states.  The process begins in the 'Init' state and transitions to the 'Upload' state.  From the 'Upload' state, the process transitions to the 'Complete' state upon successful upload.  A smaller rectangular box labeled 'Pause / Resume' is connected to the 'Upload' state, indicating that the upload process can be paused and resumed from within the 'Upload' state, creating a loop back to itself.  No data or parameters are explicitly shown flowing between the states; the arrows simply represent the progression of the upload process through its different stages.

Companies implement these steps differently, primarily varying in how they structure the endpoints:

  • Single unified endpoint: Companies like X (formerly Twitter) and Google (Drive and Photos) handle everything through a single endpoint. They manage metadata either through headers or query parameters. For example, Google Photos takes the following approach with its resumable uploads endpoint:

    X-Goog-Upload-Command=[start|upload, finalize]

    X-Goog-Upload-Offset={bytes\_offset}

    • POST /files?uploadType=resumable&uploadId={uploadId}
  • Multiple endpoints. Companies such as Dropbox and Box split the functionality across different endpoints, with each endpoint handling a specific step of the process. For example, Dropbox exposes the following endpoints:

    • POST /files/upload_session/start
    • POST /files/upload_session/append
    • POST /files/upload_session/finish

Both approaches can work. In this explanation, we will look more closely at implementing multiple endpoints to handle resumable uploads.

Implementation details

Let's explore the different endpoints and data models we need to handle resumable uploads.

Initiate upload

POST /v1/upload?uploadType=resumable

KotlinSwift
data class UploadRequest
requestId: String
name: String
mimeType: String
parentId: String
size: Long
struct UploadRequest
requestId: String
name: String
mimeType: String
parentId: String
size: Int64
data class UploadResponse
uploadId: String
totalChunks: Int
chunksProcessed: Int
chunkSize: Long
uploadUrl: String
uploadExpiresAt: String
struct UploadResponse
uploadId: String
totalChunks: Int
chunksProcessed: Int
chunkSize: Int64
uploadUrl: String
uploadExpiresAt: String

The UploadResponse file guides file uploads by tracking how files are split, what has already been uploaded, and where new parts should be added. This helps the client manage uploads smoothly, whether starting new ones or resuming interrupted uploads.

Upload chunks and verify steps
Code
Content-Range: bytes start-end/total
PUT /v1/upload/`{uploadId}`/chunk
  Body: Raw binary data for a particular chunk.
  Response: 201 Created if this was the final chunk, or 308 Resume Incomplete if more chunks remain. Payload of type UploadResponse.

The Content-Range header manages the upload process by telling the backend which part of the file is being sent. When the last piece arrives, the backend assembles everything and sends a completion confirmation. The client can then verify the upload was successful based on the backend's successful response code.

Resume upload
Code
GET /v1/upload/`{uploadId}`
  Description: Returns information about an upload session.
  Body: Empty.
  Response: 200 OK. Payload of type UploadResponse.

The client can then continue uploading the remaining chunks, starting with the next chunk after chunksProcessed. If too much time has passed and the upload session has expired, the client will need to initiate a new upload from the beginning.

Cancel upload
Code
DELETE /v1/upload/`{uploadId}`

Allows clients to abort uploads and free server resources.

Mobile implementation challenges

When implementing resumable uploads in mobile apps, we need to carefully consider several platform-specific challenges. Let's explore these challenges and their solutions.

Managing background tasks

Mobile apps should handle file uploads in the background to avoid blocking the main UI thread and provide a better user experience. However, mobile operating systems may terminate background tasks at any time. To prevent losing upload progress when this happens, we need to save the pending UploadResponse objects to disk. This lets us resume uploads later when the app is running again, ensuring files still get uploaded even if they were interrupted.

During app launch, we check for partially completed uploads and resume the jobs while monitoring system conditions such as battery and network changes.

Limited storage and permissions

When handling file uploads, we need to consider two key device constraints: storage space and file access permissions.

For storage, we temporarily cache files before upload. Since mobile devices have limited space, we need a strategy for handling low storage scenarios. The app should monitor available space and provide clear options when storage runs low, such as letting users clear the upload cache, removing previously downloaded files, or showing storage usage statistics.

For file access, we can use the system file picker, which provides temporary read access to selected files or requests direct storage permissions from the user. If users deny storage permissions, the app should disable upload functionality and provide clear guidance on enabling permissions through system settings.

Error handling and recovery

While the backend handles most of the complexity around chunks, such as determining chunkSize and tracking progress, the client still needs to handle errors gracefully. Let's look at some common error scenarios and how to handle them:

  • When uploads fail due to connectivity problems, the client should save the current upload state, resume from the last successful chunk when connectivity returns, and show clear status messages to users such as "Connection lost, attempting to resume".
  • The client should restart the upload process if it detects an invalid upload state, such as an expired uploadUrl, inconsistent responses from the backend (e.g., receiving a chunksProcessed count larger than the totalChunks), or the uploadExpiresAt time being reached.

To avoid overwhelming the servers with retry attempts, implement exponential backoff, gradually increasing the delay between retry attempts. This helps manage server load while still ensuring that uploads are eventually complete.

User experience and feedback

Users should always know how their upload is progressing, even if they restart the app. To keep users informed throughout the entire upload process, clients can:

  • Calculate progress by comparing completed parts (chunksProcessed) against totalChunks.
  • Update the UI to show meaningful progress indicators.
  • Show system notifications when the app is in the background.

Architecture changes in the diagram

Let's update our architecture diagram to reflect the new components we've discussed. Figure 6 shows how we've enhanced both layers of our system. In the UI layer, we've added the File Picker and File Permissions Helper components to handle user interactions with the device's file system. The data layer now includes the File Splitter for breaking down large files, as well as the File Uploader Repository and File Uploader Service to manage the upload process in the background.

Image represents a client-side architecture for a file upload system.  The diagram is divided into two main layers: a UI layer and a Data layer. The UI layer contains a `Navigator`, a `File Picker`, a `File Upload Modal`, a `File Upload State Holder`, and a `File Permissions Helper`.  The `File Picker` initiates the upload process, interacting with the `File Upload Modal`. The `File Upload State Holder` manages the upload's progress, receiving updates from the `File Upload Modal` and potentially interacting with the `File Permissions Helper` to verify user access rights.  The `Navigator` likely controls the overall flow within the UI. Below, the Data layer uses dependency injection, connecting to a `Files DB` via a `Files DAO`.  The core upload logic resides in the `File Uploader Repository` and `File Uploader Service`. The `File Uploader Repository` interacts with the `Files Local DataSource` and the `File Uploader Service` communicates with the `Files Remote DataSource` via a `File Splitter` (likely for handling large files) and a `Network Dispatcher` (managing network communication).  Arrows indicate the flow of data and control between components.  For example, the `File Uploader Service` receives data from the `File Uploader Repository` and sends data to the `Files Remote DataSource`.

architecture diagram

Version history

Version history is essential for tracking file changes in cloud storage, letting users revert to earlier versions when needed. In this section, we'll cover how new file versions are uploaded and how conflicts are handled when changes overlap, striking a balance between usability and technical precision.

Uploading a new version of an existing file

When users modify a file and upload it, the app must handle the new version efficiently. Two methods dominate this process: full copy upload and block-level sync. Each approach affects storage, bandwidth, and app complexity differently.

Full copy upload

This method uploads the entire updated file every time a change is made.

  • Client-side: The app sends the whole file to the server, no matter how small the edit.
  • Backend-side: The server saves each version as a complete, standalone copy.
  • Advantages:
    • Straightforward to implement, no need to track specific changes.
    • Reliable, each version is fully intact for easy recovery.
  • Disadvantages:
    • Storage heavy as multiple full copies pile up, especially for big files.
    • Bandwidth intensive, as uploading everything uses more data.

Many cloud drive providers including Google Drive, Box, OneDrive, and iCloud use this approach [9] because of its simplicity and reliability.

Block-level sync

Block-level sync [10] takes a more sophisticated approach by uploading only the changed parts of a file, not the entire file.

  • Client-side: The app splits the file into blocks, detects what's different, and sends just those blocks.
  • Backend-side: The server keeps a block registry and rebuilds versions as required.
  • Advantages:
    • More efficient. It saves bandwidth and storage, ideal for minor edits to large files.
  • Disadvantages:
    • It's more complex as it needs advanced logic to manage and reassemble blocks.
    • Resource-heavy. It requires extra processing on both ends.

Dropbox pioneered this approach using their Broccoli [11] encoding system and block sync protocol. For a better comparison between these approaches, refer to the following block-level sync resource [12].

Managing file conflicts

File conflicts occur when multiple devices modify the same file concurrently, leading to inconsistencies across versions. This can happen in scenarios such as:

  • A user edits a file on one device while offline, and another device modifies the same file before synchronization occurs.
  • Two devices upload changes to the same file at nearly the same time.
  • Interruptions during synchronization leave files in an inconsistent state.

To handle these challenges effectively, the client and server need to work together to detect and resolve conflicts. Cloud storage systems typically approach this problem in two parts: conflict detection, which identifies when changes overlap, and conflict resolution, which determines how to handle competing versions.

Let's explore these aspects in more detail, focusing on a widely-used detection approach called optimistic concurrency control and common resolution strategies such as Last-Write-Wins (LWW) and version forking.

Conflict detection

Optimistic concurrency control [13] is a widely used technique to detect conflicts by validating changes against the current file version before committing updates. It assumes conflicts are rare and checks for them only during the update process. This approach is employed by cloud storage providers such as Box, which uses HTTP headers for version validation [14].

At a high level, it works as follows.

Process:

  1. Each file has a version identifier (e.g., a version number or hash) tracked by both client and server.
  2. When uploading a new version of the file, the client sends the file data along with its current version identifier (e.g., via a header such as If-Match: <version>).
  3. The server compares this with its latest version:
    • If they match, the upload succeeds, and the version increments.
    • If they differ, the server rejects the upload with a 409 Conflict error.
  4. On conflict detection, the client:
    • Retrieves the latest version using GET /v1/files/{fileId}.
    • Merges changes (automatically or with user input, depending on file type).
    • Retries the upload with the updated version.

Advantages:

  • Prevents lost updates by ensuring that only changes based on the latest file version are accepted.
  • Enables fine-grained conflict resolution, often resulting in higher data integrity.

Disadvantages:

  • Can introduce extra latency due to the need for conflict resolution flows, particularly over unstable mobile networks.
  • May frustrate users with conflict errors and require manual merging in some cases.
Conflict resolution

Cloud storage systems such as Google Drive often adopt an eventual consistency model to prioritize availability and allow updates to proceed without immediate synchronization across all devices.

In this model, conflicts are detected after updates are accepted, and resolution is handled through specific strategies. Two common resolution strategies are:

  • Last-Write-Wins (LWW) automatically makes the most recent version the current one.
  • Version forking keeps both conflicting versions, usually by adding a number to the filename (e.g., "filename.png" and "filename (1).png").
Real-world examples

The choice of conflict resolution strategy depends on your app's specific requirements. Cloud storage providers handle conflicts in different ways:

  • Dropbox keeps things simple by default. It automatically saves the newest version without asking the user, using the last-write-wins strategy.
  • Google Drive lets users decide. When it detects a conflict, it shows a dialog asking if they want to keep both files or replace the old one (see Figure 7).
Image represents a dialog box titled 'Upload options' appearing on a dark-grey background.  The dialog box informs the user that a file named 'hello.png' already exists in the upload location and presents two radio button options: 'Replace existing file' (selected by default) and 'Keep both files'.  Below these options are two buttons: 'Cancel' and 'Upload'.  The text above the radio buttons explains the situation and clarifies that replacing the file will not alter its sharing settings.  No data flows visually; the user interacts by selecting a radio button and then clicking either 'Cancel' to dismiss the dialog or 'Upload' to proceed with the chosen option.

whose name already exists in the folder

Dropbox's public API provides support for both strategies we discussed to give more flexibility to clients. Through two key parameters (strict_conflict and mode) in their upload API [15], they give developers precise control:

  • The strict_conflict parameter determines whether to enforce strict validation. When enabled, it prevents concurrent uploads by returning conflict errors.
  • The mode parameter controls what happens when conflicts occur:
    • In add mode, new uploads of existing files get automatically renamed with a unique counter.
    • In overwrite mode, new uploads simply replace existing files, following a "last-write-wins" strategy.

Data encryption

The interviewer may sometimes add extra requirements to the problem. For example, what if they ask you to support enterprise customers who need files stored on devices to be encrypted? What would you do?

Files and metadata encryption

Without encryption, a lost or compromised device could expose sensitive files if an attacker mounts its storage. To counter this, we'll use AES-256 [16], a strong, industry-standard encryption algorithm that balances security and performance on mobile hardware. We'd use it for:

  • Metadata protection: File names, folder structures, and other metadata are stored in an encrypted database.
  • File protection: Downloaded files are encrypted within the app's private storage.

This approach ensures that even if the device's storage is accessed, the data remains unintelligible without the decryption key.

Key management for offline access

To enable offline file access, encryption keys must be stored locally rather than relying solely on server-side solutions. We leverage platform-specific security features to store these keys securely:

  • On Android, the Android Keystore [17] provides hardware-backed key storage, protecting keys from extraction.
  • On iOS, the Keychain [18] offers similar protection, integrating with Apple's Secure Enclave where available.

For added protection, we can derive the key from the user's login credentials using PBKDF2 [19], a key derivation function, ensuring only authenticated users can unlock it. This ties the encryption tightly to user identity.

Mobile implementation considerations

While encryption provides essential security, it comes with unique challenges on mobile devices. Let's explore how to balance strong security with smooth performance.

Performance and resources

Encryption operations can be CPU-intensive, especially for large files. To maintain a responsive app, the encryption/decryption work happens on background threads to keep the UI smooth. Also, we should take into account that encrypted files take up slightly more space due to padding and metadata overhead and the app memory consumption when keeping encrypted and decrypted files in memory.

Memory management

Careful memory handling is crucial when working with decrypted data. We should consider implementing timeouts to automatically clear decrypted content and release cached decrypted data when memory runs low. Additionally, developers should watch for potential memory leaks in encryption/decryption cycles.

Error handling and recovery

A robust encryption system needs clear paths for handling issues. We must implement appropriate fallback behaviors when encryption keys become corrupted or inaccessible or when encryption/decryption operations fail. The app needs to inform the user and prompt them to take the right actions.

Backup strategy

Since encrypted files are tied to device-specific keys, they require special handling for backups. We should exclude encrypted files from system backups by default. If backups are needed, implement custom logic to re-encrypt data with backup-specific keys.

Device compatibility

Not all devices offer the same level of security features. In our logic, we must check for secure hardware availability during app initialization, and fall back gracefully to app sandbox storage on older devices. Additionally, we should inform users that we're operating with reduced security capabilities.

Step 5: Wrap-up

Throughout this chapter, we've designed a Google Drive client that addresses key challenges in cloud storage apps. Our solution enables users to securely store, access, and sync their files while maintaining a complete version history. We built the system around a robust REST API for core operations, complemented by SSE or WebSockets to deliver real-time updates when files change.

The architecture we developed tackles several complex challenges head-on. By carefully structuring our client components and defining clear backend interactions, we created a system that handles everything from basic file operations to more intricate features such as resumable uploads and file versioning.

If you have additional time during your interview or want to explore more advanced features, here are some compelling extensions to consider:

  • Design a selective sync system where users choose which folders and file types to store locally versus keeping in the cloud only.
  • Create a comprehensive file sharing system with granular permissions and access controls for secure collaboration.
  • Optimize data transfer and storage through compression techniques such as Google's Brotli algorithm [20], reducing bandwidth usage and storage costs.

Resources

[1] How Box Drive designed Mark for Offline: https://blog.box.com/how-we-designed-mark-offline-box-drive
[2] Google Photos resumable uploads: https://developers.google.com/photos/library/guides/resumable-uploads
[3] X (formerly Twitter) resumable uploads: https://developer.x.com/en/docs/x-api/v1/media/upload-media/uploading-media/chunked-media-upload
[4] Box chunked uploads: https://developer.box.com/guides/uploads/chunked/
[5] Dropbox resumable uploads: https://www.dropbox.com/developers/documentation/http/documentation\#files-upload\_session-start
[6] Microsoft OneDrive resumable uploads: https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession
[7] Google Drive resumable uploads: https://developers.google.com/workspace/drive/api/guides/manage-uploads#resumable
[8] Google Drive upload types: https://developers.google.com/drive/api/guides/manage-uploads
[9] Google Drive uploads entire files: https://www.pcworld.com/article/2020862/google-drive-review.html
[10] Block-level storage: https://en.wikipedia.org/wiki/Block-level\_storage
[11] Dropbox's Broccoli encoding and block sync protocol: https://dropbox.tech/infrastructure/-broccoli--syncing-faster-by-syncing-less
[12] Block-level sync vs full copy comparison: https://www.cloudwards.net/block-level-file-copying/
[13] Optimistic concurrency control: https://en.wikipedia.org/wiki/Optimistic\_concurrency\_control
[14] Box optimistic concurrency implementation: https://developer.box.com/guides/api-calls/ensure-consistency
[15] Dropbox strict conflict strategy: https://www.dropbox.com/developers/documentation/http/documentation\#files-upload
[16] Advanced Encryption Standard (AES): https://en.wikipedia.org/wiki/Advanced\_Encryption\_Standard
[17] Android Keystore: https://developer.android.com/privacy-and-security/keystore
[18] iOS Keychain: https://developer.apple.com/documentation/security/keychains
[19] PBKDF2: https://en.wikipedia.org/wiki/PBKDF2
[20] Brotli: Google's lossless data compression algorithm: https://en.wikipedia.org/wiki/Brotli

CHAPTER 09

YouTube app

09

Designing the YouTube mobile application poses a significant challenge in system architecture. With billions of hours of video streamed each day across diverse devices and network environments, delivering seamless playback on this scale requires careful planning. In this chapter, we will examine the essential components that enable the YouTube app to excel as a mobile system, guiding you through the design process step by step.

YouTube operates on an extraordinary scale, serving over 2.5 billion active users who access content in more than 80 languages [1]. Mobile devices now dominate the platform, accounting for over 70% of total watch time as of early 2024 [2]. This shift underscores the importance of optimizing the mobile experience, a priority we will address throughout our design.

Image represents a screenshot of the YouTube mobile application.  At the top, a status bar displays the time '12:30' and standard mobile icons for Wi-Fi, notifications, search, and a user profile picture. Below this, the YouTube logo and title are prominent.  A horizontal navigation bar contains buttons for 'Explore,' 'All,' 'Baking,' 'Comedy,' and a partially visible fifth option. The main content area displays two video thumbnails. The top thumbnail shows a close-up of a bowl of zucchini spaghetti being eaten, with a video duration of '9:21' displayed in the corner.  Below the thumbnail, the video title 'How to: Plant Based Vegan Creamy Zucchini Spaghetti' is shown, along with channel information ('The Korean Vegan'), view count ('12K views'), and upload date ('2 months ago'). A three-dot menu icon is also present. The second thumbnail shows a family photo with a video duration of '8:47' and the title 'Our Story & The Future of Made With Lau,' along with channel information ('Made with Lau'), view count ('26K views'), and upload date ('3 weeks ago').  A three-dot menu icon is also present. At the bottom of the screen, a navigation bar with icons and labels for 'Home,' 'Shorts,' a plus icon (likely for creating content), 'Subscriptions,' and 'Library' is visible.  The overall layout is typical of a mobile app, with clear visual separation between navigation, content, and video information.

Step 1: Understand the problem and establish design scope

Before diving into the design, we must first define its requirements and boundaries. Imagine we are discussing this with an interviewer to clarify the system's focus:

Candidate: To start, I would like to confirm the scope. Are we concentrating on the video consumption experience, such as browsing and playback, or should we also consider features for creators, such as video uploads or live streaming?
Interviewer: Please focus on video consumption: browsing and playback. Exclude content creation and live streaming.

Candidate: Very well. For consumption, I assume users need to browse recommended videos on a home feed and play them. The player should include controls such as play, pause, and seek, plus options for quality settings (e.g., 360p, 720p), subtitles, and alternate audio tracks. Does this match your vision?
Interviewer: Yes, that captures the key functionality.

Candidate: For playback, may I assume we can rely on an existing streaming solution, such as HLS or DASH, for adaptive streaming, or do we need to design that ourselves?
Interviewer: You can use an existing protocol; no need to build it from scratch.

Candidate: Excellent. To improve the experience, prefetching video recommendations for the video feed might help. Should we include that?
Interviewer: Yes, prefetching would be a valuable addition.

Candidate: Noted. Should we also incorporate features such as watch history for resuming videos or playlist management? What about social elements such as likes and comments?
Interviewer: Let's keep those outside our scope.

Candidate: Finally, for scale, how many daily active users (DAU) should we plan for?
Interviewer: Design for about 1 billion DAU worldwide.

This exchange helps us establish a clear picture of what we are building and its constraints.

Requirements

Based on this, we are creating a YouTube-like mobile app centered on video consumption, with these features:

  • Users can browse recommended videos in a home feed.
  • Users can watch videos using a feature-rich player.
  • The app prefetches video feed data to improve user experience.

As for non-functional requirements, we need to build a system that ensures:

  • Scalability: The app must handle 1 billion daily active users globally while adapting to diverse network conditions from high-speed Wi-Fi to spotty cellular connections.
  • Performance: Users should experience smooth scrolling and video playback with efficient delivery of media content across all device types.
  • Availability: The system needs to guarantee uninterrupted access to videos while supporting multiple languages and regions to serve a global audience.

UI Sketch

Consider the primary screens of our app, as shown in Figure 2. The Home screen presents tailored video recommendations, while the Video Player screen displays the selected video with its controls.

Image represents two simplified wireframes of a mobile application screen, likely depicting a before-and-after scenario of a design change.  Both wireframes show a smartphone screen displaying a list of video content.  The left wireframe shows two video thumbnails, each represented by a dark gray rectangle with a play button symbol (a triangle within a circle) in the center, positioned vertically. Below each video thumbnail is a smaller, light gray rectangle, possibly representing a description or user interface element.  A small circle precedes each video thumbnail, potentially indicating a profile picture or other user-related information. The right wireframe shows a similar layout but with only one video thumbnail at the top.  Below the video is a light gray rectangle, similar to those in the left wireframe.  However, a larger, light gray rectangle is added below, suggesting the addition of a new feature, perhaps a comment section or expanded description.  The top right corner of the right wireframe shows three small icons: a heart (likely for liking), an upward-pointing arrow (possibly for sharing), and a shopping bag (suggesting a purchase option).  No explicit data flow or connections are shown between the two wireframes; the difference is purely visual, illustrating a design modification.

With our scope set and a simple UI outlined, we can now explore how the app communicates with the backend.

Step 2: API design

Next, we design the API that connects the client to the backend.

Network protocol

For most of our client-server interactions, we will adopt a RESTful API over HTTP with JSON as our communication framework. This approach leverages REST's simple and intuitive design, while also offering wide compatibility with mobile clients and existing tools, making it a practical and efficient solution.

At YouTube's scale, however, efficiency matters greatly. JSON can produce larger payloads, which can potentially slow down performance on mobile networks. An alternative, like gRPC with Protocol Buffers, offers compact and fast serialization, making it ideal for high-performance systems.

While gRPC is highlighted as a potential optimization, it's a more specialized technology, and not every developer is familiar with it. For simplicity, we'll opt for a RESTful API in our design. However, if you're comfortable with gRPC, feel free to use it for the API design. It's a great option too.

Endpoints

Let's define the API endpoints driving the app's core functionality.

In this chapter, we will base our design on the real YouTube Data API [3]. It follows a REST-like architecture with some interesting deviations that offer valuable insights for designing mobile systems. At its core, it provides endpoints for accessing YouTube resources such as videos, playlists, and channels.

Video browsing

The videos endpoint has an interesting design choice worth looking at. The Video resource data model [4] contains rich metadata: everything from basic details to view counts and content restrictions. But not every client needs all this information at once!

The YouTube API addresses over-fetching through a flexible part parameter in their video endpoint:

Code
GET https://youtube.googleapis.com/youtube/v3/videos?
      part=snippet,statistics,contentDetails&
      id=VIDEO_ID&
      maxResults=`{results}`&pageToken=`{cursor}`

The part parameter here is key: it lets the client specify exactly which data fields to fetch, such as snippet for titles and thumbnails or statistics for view counts. This selective approach minimizes over-fetching, a critical consideration for mobile apps where every byte counts. It's not as dynamic as GraphQL, but it offers similar benefits with less overhead, keeping our design practical and efficient.

This approach offers several benefits:

  • Reduces network bandwidth usage by trimming response payloads.
  • Improves server efficiency since it only needs to fetch the requested fields.
  • Gives clients fine-grained control over data retrieval.
  • Maintains REST's simplicity while adding field selection.

πŸ“ Note:

Unlike GraphQL, which allows field-by-field selection, this approach lets you choose from predefined sets of fields. While not as granular as GraphQL, it offers a practical balance: giving clients some control over the data they receive without adding too much complexity.

When a user opens the app without signing in, we need to show trending content in their main feed. To fetch popular videos, we can use:

Code
GET /youtube/v3/videos?part=snippet&chart=mostPopular&
    maxResults=20&regionCode=US
Video details endpoint

When a user taps a video, we need to fetch its full details to enrich the viewing experience. Here's how we handle it:

Code
GET /youtube/v3/videos?id=VIDEO_ID&
    part=snippet,player,contentDetails,statistics

This call retrieves everything the app needs: metadata, such as the title and description, playback information via the player, and engagement metrics from statistics.

Video playback

Unlike third-party apps, which must use YouTube’s IFrame Player API [5], we can feed streaming manifests directly to platform-specific video player APIs since we’re designing YouTube’s internal client.

To achieve that, we can create a video playback endpoint that provides all necessary streaming information:

Code
GET https://my-internal.googleapis.com/youtube/v3/videos/playback?
      codecs=supportedCodecs&
      protocols=supportedStreamingVideoProtocols&
      language=preferredLanguage&
      subtitles=preferredSubtitles

The response delivers a streaming manifest (e.g., DASH or HLS) tailored to the device's capabilities, along with metadata such as subtitle options. We'll dive deeper into these streaming protocols later, but for now, this endpoint ensures smooth, adaptive playback.

Step 3: High-level client architecture

With the API design in place, let's shift our attention to the client-side architecture. Figure 3 sketches out the architecture of our YouTube app.

Image represents a client-server architecture for a video streaming application.  The client-side, labeled 'Client,' is divided into a 'UI layer' and a 'Data layer.' The UI layer contains a 'Navigator' component that controls the flow between a 'Home Screen' and a 'Video Player Screen.' The 'Video Player Screen' interacts with a 'Video Player UI component,' which receives data via HTTPS from a 'CDN' (Content Delivery Network).  An 'Image Loader' within the UI layer also fetches images via HTTPS from the CDN. The Data layer comprises a 'Browsing Repository,' a 'Videos Repository,' and a 'Network Dispatcher.' These components communicate with the server-side via an 'API Gateway' using HTTPS. The API Gateway interacts with a 'Backend' component, which in turn communicates with the CDN.  The 'Dependency Injection' layer facilitates communication between the UI and Data layers.  Data flows from the Backend, through the API Gateway, potentially to the CDN, and then to the client's UI components via HTTPS requests.

Server-side components

Our system relies on several external services:

  • The Backend manages video data, user interactions, and serves API requests over HTTP.
  • CDNs speed up the delivery of static assets, such as video content, thumbnails, and metadata, by caching them near users.
  • The API Gateway acts as the front door for client requests, handling routing, security, authentication, and access control.

CDNs play a crucial role in video streaming for several key reasons:

  • User experience: Users receive content from nearby CDN servers, rather than a distant central server. This significantly reduces loading times and buffering.
  • Scalability: When millions of users stream popular videos simultaneously, CDNs distribute the load across multiple servers. This prevents overloading any single server and ensures smooth playback for everyone.
  • Cost-effective delivery: Without CDNs, every video request would have to travel all the way to the origin servers, increasing both latency and bandwidth costs. CDNs optimize these costs by reducing the burden on primary infrastructure.

Despite these benefits, CDN costs can become substantial as operations scale. This has prompted major streaming platforms to explore alternatives:

  • Netflix developed its own CDN solution called Open Connect [6]. This custom approach gives them greater control over delivery costs while specifically optimizing for video content.
  • Other companies form direct partnerships with Internet Service Providers, placing servers within ISP networks. This strategy can lower delivery costs while improving streaming quality.

Client architecture

The UI layer consists of three main screens: a Home Screen for browsing recommended videos, and a Video Player screen for seeing video details and watching them via the Video Player UI component. As with other apps, these screens have their corresponding state holder associated with them that exposes UI state and handles the business logic of the screens.

The data layer manages the core functionality through the Browsing, Video, and History repositories. All of them would have their corresponding Remote Data Source that uses the Network Dispatcher to handle HTTP communication with the backend.

Step 4: Design deep dive

Now that we've established our high-level architecture, let's explore some key aspects of our system in more detail:

  • Video streaming.
  • Prefetching video feed data.
  • Enhanced user experiences.

Video streaming

When designing a video streaming app like YouTube, creating a smooth viewing experience is key. Users expect videos to start fast and play without interruptions.

Modern video streaming works differently from traditional downloading. Instead of waiting for the entire file to download, streaming breaks videos into small segments that load progressively:

  1. Playback begins after just the first few segments are downloaded.
  2. More segments load in the background as users watch.
  3. Video quality adjusts according to your connection speed.
  4. Played segments get cleared to save memory.

This approach is well-suited to mobile environments. Loading only what's needed right now uses less memory and handles videos of any length. The app can also switch video quality when your connection changes, ensuring the best possible viewing experience wherever you are.

Video streaming protocols

Modern video streaming relies on several key protocols, each designed for specific scenarios:

  • HTTP Live Streaming (HLS) [7] is Apple's widely adopted protocol that breaks videos into small chunks delivered over standard HTTPS connections. This approach ensures smooth playback even when network conditions change. While especially strong on Apple devices, HLS works well across most platforms.
  • DASH (Dynamic Adaptive Streaming over HTTP) [8] follows similar principles to HLS but offers more flexibility as an open standard. Unlike HLS, DASH isn't tied to specific video codecs, giving developers more implementation freedom. This protocol has gained significant traction, especially outside the Apple ecosystem.
  • Common Media Application Format (CMAF) [9] bridges the gap between HLS and DASH by providing a unified container format. This standardization eliminates the need for separate content versions, simplifying deployment and reducing storage costs.
  • Other notable protocols include WebRTC (Web Real-Time Communication) [10], the SRT (Secure Reliable Transport) protocol [11], and two older protocols, such as Real-Time Streaming Protocol (RTSP) [12] and Real-Time Messaging Protocol (RTMP) [13].

When selecting a protocol, we need to consider the specific needs of our YouTube app, including latency (how quickly video starts playing), platform compatibility (support across Android and iOS devices and the capabilities of their ecosystem), and security needs (protecting content during transmission). For a deeper technical comparison of these protocols, several comprehensive resources are available [14] [15].

πŸ“ Note: These protocols involve low-level technical details that you usually don't need to learn for system design interviews. What's important is understanding that different streaming protocols support different video formats and players, which influences your architecture choices.

When building video streaming apps such as YouTube, Twitch, or Netflix, HLS and DASH are the leading protocols. They both support adaptive bitrate streaming [16], automatically adjusting video quality based on network conditions and device capabilities. For applications where minimizing delay matters, low-latency versions (LL-HLS and LL-DASH) are available.

πŸ“Œ Remember! Adaptive streaming protocols such as HLS and DASH typically use adaptive bitrate streaming (ABR) to adjust video quality based on network conditions.

Most major streaming platforms implement several protocols rather than just one. This ensures wide device compatibility and optimizes for different viewing scenarios. YouTube, for example, supports HLS, DASH, RTMP, and RTMPS, allowing third-party clients to choose the protocol that works best for them [17].

Adaptive bitrate streaming

Video apps need to handle changing network conditions as users move around, switch between WiFi and cellular, or experience congestion. Adaptive bitrate streaming solves this problem through:

  1. Multiple quality versions
    • Videos are encoded at different quality levels (480p, 720p, 1080p).
    • Each version offers a different balance of quality vs. bandwidth usage.
  2. Video segmentation
    • Content is split into small chunks (e.g., 2-10 seconds each).
    • Every chunk is available in all quality variants.
    • Smaller chunks allow for faster adaptation, but they increase server requests.
  3. Real-time quality switching
    • The player constantly monitors network speed and device performance.
    • It selects the optimal quality for each upcoming segment.
    • Quality can shift up or down as conditions change.

The player makes smart decisions by tracking download speeds, buffer levels, CPU/GPU usage, and device capabilities. When the connection improves, quality gradually increases. When things get worse, quality quickly drops to prevent buffering. In practice, mobile players use buffer-based or hybrid (buffer + throughput) algorithms to decide the next segment's bitrate. Because each decision only affects the next segment, the system can respond immediately to changing conditions.

Video streaming on Android and iOS

For video streaming in mobile apps, the best options are the native solutions: ExoPlayer [18] on Android and AVPlayer [19] on iOS. These frameworks handle modern streaming formats such as HLS and MPEG-DASH while delivering smooth, reliable playback.

The native solutions offer two key advantages: deep platform integration for optimal performance, such as hardware decoding, and extensive control over playback features (adaptive bitrate, subtitles, etc.). While third-party players such as VLC, JWPlayer, or Brightcove provide consistent APIs, the native solutions typically might be more customizable for specific use cases.

Managing video content: Subtitles and audio

Modern video streaming services separate video, audio, and text components. This modular approach, used in protocols such as MPEG-DASH and HLS, creates a more flexible and efficient streaming experience. Let's look at how this works.

Subtitle and caption support

Both MPEG-DASH and HLS implement text elements, including subtitles and closed captions, through dedicated text tracks:

  • HLS uses WebVTT format text tracks referenced in the main playlist (.m3u8) file
  • MPEG-DASH supports multiple formats, including WebVTT and TTML (Timed Text Markup Language), defined in the manifest (.mpd) file

πŸ“Œ Remember!

Separate tracks are independent streams of content, such as audio, subtitles, or closed captions, that play alongside the main video. Each track contains its own data and timing information, allowing it to stay perfectly synchronized with the video playback. This separation makes it easier to handle different types of content while keeping everything in sync.

Popular platforms like YouTube follow this approach because separating text from video offers several advantages:

  • Reduces overall bandwidth usage since text takes minimal space compared to video.
  • Enables multi-language support without duplicating video streams.
  • Helps meet accessibility requirements with easily updatable captions.
  • Allows real-time subtitle updates without re-processing video content.

Thumbnails implementation

When users scrub through a video's seek bar, like in Figure 4, the small previews you see are not actually built into standard streaming protocols, such as HLS or MPEG-DASH. Video services need to create this preview functionality separately. Let's look at two common approaches.

Image represents a video player interface showcasing a recipe.  The top section displays a video preview of creamy zucchini spaghetti in a light-colored bowl, with chopsticks partially visible. A play button is centrally located, and a timer showing '0:07' indicates the video's current position. A small 'x' is in the upper right corner, suggesting a close button. Below the video preview are four smaller thumbnail images, arranged horizontally. The leftmost thumbnail is labeled 'Intro,' depicting a close-up of the spaghetti being served. The next two thumbnails show additional shots of the prepared dish. The final thumbnail on the right shows a person preparing the dish.  At the very bottom, the text 'How to: Plant Based Vegan Creamy Zucchini Spaghetti' describes the video's content. The overall arrangement suggests a social media platform or app displaying a recipe video with preview images and a title.
Sprite sheets

One popular method uses sprite sheets (also called texture atlases) [20]. This approach combines multiple thumbnail images into a single file, which reduces server requests. While this works well for web streaming, it requires maintaining separate pre-generated images alongside each video. Figure 5 shows an example of this.

Image represents a sprite sheet titled 'Sprite Sheet' showcasing various animation frames.  The sheet is arranged as a grid of 20 frames, predominantly featuring a series of animated characters in a forest setting.  These characters include a large, round, grey creature, a rabbit-like creature, and a small, flying squirrel-like creature.  The frames depict different actions and interactions between these characters, such as the large creature holding a stick, the squirrel flying, and the characters interacting with each other in various poses. The bottom four frames display text-based information. Two frames contain long lists of names, presumably credits for music and sound design by Jan Morgenstern. Another frame shows a copyright notice and a URL: www.bigbuckbunny.org. The final frame displays 'Special Thanks To' along with several logos.  The frames are uniformly sized and seamlessly arranged, suggesting they are intended for use in animation software.  The overall style is consistent, indicating a cohesive animation sequence.  A FastPix logo is present at the bottom.
Adaptive streaming

The second approach leverages adaptive streaming technology to fetch low-resolution video frames for previews. YouTube mobile apps use this method for their seek previews, and it works particularly well:

  1. When a user moves the seek bar, the app requests that specific timestamp from the server, but at a much lower resolution (typically 144p or 240p).
  2. The server quickly delivers these low-bitrate segments, enabling smooth previews during seeking.
  3. Once the user selects their position, the app switches back to full-resolution video for normal playback.

This approach offers several benefits for mobile applications:

  • Shows actual video motion rather than static images, giving better context.
  • Reuses existing streaming infrastructure, eliminating the need for separate systems.
  • Avoids storing additional thumbnail assets, saving storage space.
  • Delivers accurate, low-latency previews that match the actual content.

Adaptive streaming aligns especially well with mobile apps, providing a smooth seeking experience while efficiently using device resources.

Common video streaming challenges

Building video streaming into mobile apps presents several key challenges that directly affect the user experience. Let's explore these challenges and practical approaches to address them.

Memory management

Video playback demands significant memory, particularly challenging on mobile devices with limited resources. Mobile operating systems can terminate apps that consume excessive memory, making proper memory management critical for a reliable streaming experience.

There are effective memory management strategies we can use:

  • Adaptive buffer sizing by maintaining smaller buffers on memory-constrained devices while using larger buffers on high-end devices for smoother playback.
  • Frame recycling by reusing video frame buffers instead of continuously allocating new memory.
  • Degrade gracefully by reducing quality when memory pressure increases, preventing playback failures.
  • Handle app state transitions properly to release resources when the app moves to the background.

Memory monitoring is essential, and we should implement analytics that track memory usage patterns across different devices and playback scenarios to identify potential issues before they affect users.

Battery life optimization

Video playback is one of the most power-intensive operations on mobile devices, affecting both processor and network usage.

Some power consumption reduction techniques we can use are:

  • Adaptive quality selection by lowering resolution and bitrate when the battery is low or the device is not charging.
  • Use hardware acceleration where available to reduce CPU load.
  • Pause video playback and suspend network operations when the app isn't visible.

πŸ› οΈ Platform implementation details

Mobile-specific optimizations make a significant difference. For example, on Android, using the Battery Saver API to detect power-saving mode and adjust video quality accordingly, or on iOS, responding to low-power mode notifications to reduce playback quality.

Network handling

Mobile networks can be unpredictable, requiring thoughtful strategies to ensure a smooth user experience. To address these challenges, we can implement several robust network handling techniques:

  • Bandwidth adaptation by switching video quality based on available bandwidth using adaptive bitrate streaming.
  • Efficient retry logic by using exponential backoff with jitter to prevent network congestion during retries.
  • Allowing pre-downloading videos for offline viewing when network conditions are favorable.
  • Adjusting buffering strategies based on connection type (cellular vs. Wi-Fi).
Advanced streaming optimizations

Beyond the basics, several advanced techniques can significantly improve the mobile streaming experience:

  • Prioritize audio continuity over video during network constraints, as users are more sensitive to audio disruptions.
  • Predictive buffering by analyzing viewing patterns to pre-buffer content that the user is likely to watch next.
  • Optimize subtitle handling in mobile apps by caching text tracks for offline viewing and unreliable network conditions.

Building efficient video streaming requires carefully balancing these challenges. The key is finding the right trade-offs between smooth playback, battery life, and network resilience based on your app's specific needs and user expectations.

πŸ” Industry insights:

Reddit improved Android video playback by customizing ExoPlayer specifically for short-form videos. This involved choosing efficient formats (MP4), optimizing caching and buffering strategies, and adjusting bandwidth estimation, which significantly reduced load times, playback errors, and rebuffering [22].

Netflix enhanced its 4K streaming by implementing shot-based encoding and dynamic optimization techniques, resulting in improved video quality and reduced bitrates [23]. In another resource, Netflix explains the development of a Unicode-based pipeline to process subtitles and closed captions efficiently across diverse languages and formats [24]. In this other example, see how Netflix improved audio streaming quality by introducing high-quality sound features that aim to deliver studio-quality, perceptually transparent audio to viewers [25].

Prefetching video feed data

When users open YouTube, they expect relevant content to appear instantly. To meet this expectation, mobile apps often prefetch feed data, so that content loads immediately even before the user starts interacting.

Let's explore how to implement prefetching in a way that balances these user experience benefits with efficient resource usage.

Design options

Table 1 compares the key strategies we could use for prefetching feed data.

OptionDescriptionAdvantagesDisadvantages
Time-based prefetchingFetch recommendations at regular intervals regardless of user activity.Simple to implement and maintain. Predictable network usage patterns. Ensures content stays relatively fresh.Can result in unnecessary data usage. Doesn't adapt to actual user behavior. Could miss important updates between intervals.
Event-based prefetchingTrigger prefetch based on specific events such as app background or foreground transitions.Aligns naturally with user behavior. Reduces unnecessary network calls. More efficient resource utilization.May miss optimal prefetching opportunities. Can lead to inconsistent content freshness. Requires more complex implementation logic.
Intelligent prefetchingCombine multiple signals (e.g., device state or use patterns) to determine optimal prefetch timing.Optimizes both UX and resource usage. Adapts to individual usage patterns. Handles varying network conditions gracefully.Most complex to implement. Requires careful tuning of prefetch parameters. Needs robust monitoring to ensure effectiveness.

Table 1: Trade-offs for different prefetching options

Due to the scale we're operating at and the amount of user information we have available, we're implementing intelligent prefetching. We can decide when to prefetch content from the mobile app, taking into account signals such as device state (e.g., device charging or network type), user patterns (e.g., typical app open times), content relevance scores, and resource availability (e.g., battery levels).

This data-driven approach helps us prefetch content at the optimal times, improving the user experience while being mindful of device resources.

Implementation details

To implement intelligent prefetching, the system may need to address the following considerations.

Signal collection and analysis

The key to smart prefetching starts with understanding user behavior. The client app should gather key signals about device status and how users interact with content, enabling the backend to deliver more personalized recommendations.

We can implement a remote configuration system that allows the backend to guide client behavior. The prefetch configuration defines key parameters that control when and how clients should prefetch content, such as:

  • The number of feed data to prefetch.
  • Prefetch interval.
  • Whether prefetching is allowed on mobile data.
  • Battery and charging status considerations.
Scoring recommendations

The backend uses a scoring system to prioritize recommendations for a particular user. Taking into account both the user's viewing history and how fresh and relevant the content is, we can define each recommendation with two key components:

  • The relevanceScore measures how well content matches a user's interests, typically rated from 0-100 by the recommendation engine.
  • The ttl (time-to-live) tells the client how long a recommendation remains valid.

Content relevance isn't static. As user preferences change, the backend can update recommendation scores. When this happens, the client refreshes its cached recommendations, ensuring that users always see the most current and personalized content.

Prefetch scheduler

The client uses a background scheduler to manage prefetching operations. Guided by the backend's configuration, this scheduler optimizes content prefetching while being mindful of system resources.

πŸ› οΈ Platform implementation details

Android and iOS each provide their frameworks for handling background prefetching, though the core logic remains similar across platforms.

On Android, the WorkManager API [26] handles scheduling background tasks. It provides built-in support for constraints such as Wi-Fi availability and charging status. When specific prefetch times are needed, we can incorporate them into the WorkManager's scheduling logic.

iOS uses the Background Tasks framework [27] for similar purposes. While it lets us set preferred execution times through earliestBeginDate, we'll need to implement our own checks for conditions such as network availability and battery status.

Both platforms may delay or batch background tasks to optimize system resources. Our implementation should handle these scheduling variations gracefully to ensure reliable prefetching regardless of when the system executes the task.

Adaptive optimization

The real power comes from continuous optimization. The client monitors user interactions and reports usage patterns back to the backend. This creates a feedback loop that allows dynamic prefetching:

  • Increase frequency during high-engagement periods.
  • Reduce or pause prefetching during low-usage times.

Challenges in prefetching

While prefetching enhances the user experience by delivering instant content, it also presents challenges that must be carefully addressed.

Device resource management

Prefetching operations consume device resources and can increase data usage, especially for users on metered connections. We should:

  • Restrict recommendation prefetching to Wi-Fi by default, with opt-in for cellular networks.
  • Pause prefetching when the battery is low (e.g., below 20%).
  • On lower-end devices, reduce prefetching frequency and volume.
  • Implement adaptive refresh intervals based on content type and user engagement patterns.
Memory constraints

Feed data prefetching can consume significant memory, especially when caching thumbnails:

  • Implement size limits for the prefetch cache based on device capabilities.
  • Use efficient image loading libraries that support downsampling and memory-efficient caching.
  • Clear older cache entries when the app receives memory pressure warnings from the OS.
  • Remove stale recommendations when user preferences change significantly.
Measuring prefetch effectiveness

To ensure our prefetching strategy actually improves the user experience, we should:

  • Track how quickly users can see recommendations when opening the app.
  • Measure engagement rates with prefetched recommendations vs. non-prefetched ones.
  • Calculate what percentage of prefetched videos are actually watched.
  • Track data efficiency metrics such as bytes prefetched but never viewed to understand resource waste.
  • Monitor battery consumption patterns to balance prefetching benefits against device power usage.

This data-driven approach helps refine our prefetching algorithms over time, creating a feedback loop that continuously improves the user experience while optimizing resource usage.

Architecture updates for pre-fetching video recommendations

Let's explore how we'll modify our architecture to support video recommendations prefetching in the YouTube app. As shown in Figure 6, we're adding several key components that work together to deliver a seamless recommendation experience.

First, we introduce a new Recommendations Repository that provides curated video suggestions to the existing Browsing Repository, utilizing both local and remote data sources. To keep recommendations up to date, we're adding a Recommendations Prefetching Service component. When triggered by the operating system, it works with our new Device Monitor to check conditions such as battery level and network status. If conditions are favorable, it initiates a backend sync through the remote data source to refresh our recommendations.

Image represents a system architecture diagram for a video recommendation application.  The diagram is enclosed within a rounded rectangle labeled 'Client,' containing a gray rectangle labeled 'Dependency Injection.'  Inside this client boundary are several components: a 'Browsing Repository,' a 'Recommendations Repository,' a 'Recommendations Local DataSource,' a 'Recommendations Prefetching Service,' and a 'Device Monitor.'  The 'Videos DB' is depicted as a cylinder below these components, labeled as the 'Data layer.'  Data flows from the 'Videos DB' to the 'Recommendations Local DataSource,' which in turn feeds into the 'Recommendations Repository.'  The 'Recommendations Repository' also receives data from the 'Recommendations Remote DataSource.'  The 'Recommendations Prefetching Service' receives data from the 'Recommendations Repository' and the 'Recommendations Remote DataSource.'  The 'Recommendations Remote DataSource' interacts with a 'Network Dispatcher,' which communicates with an 'API Gateway' via 'https' to a 'Backend' component.  Finally, the 'Browsing Repository' receives data from the 'Recommendations Repository,' and the 'Client' interacts with the 'Browsing Repository.'  The 'Device Monitor' feeds into the 'Recommendations Prefetching Service.'  All components within the 'Client' boundary appear to be interconnected through the 'Dependency Injection' mechanism.

πŸ” Industry insights:

Facebook's mobile app prefetches video recommendation metadata by monitoring the in-memory pool of videos as users scroll. When the pool drops below a threshold, it sends a request with user signals to fetch and rank new personalized video candidates, which are then cached for smooth playback [28].

Facebook's Feed recommendation engine utilizes AI to personalize content by analyzing user interactions and predicting engagement. It ranks posts based on relevance scores derived from various signals, ensuring users see content most pertinent to their interests [29].

Enhanced user experiences

The interviewer may sometimes add extra requirements to the problem. For example, what if they ask you to discuss how to enhance the user experience by addressing specific mobile user needs?

In this section, we'll answer this question by focusing on three key areas: gesture-based video controls, background audio playback, and Picture-in-Picture support.

Gesture-based video controls

Traditional video player controls with visible buttons and sliders can feel clunky on mobile devices with limited screen space. Implementing gesture-based controls creates a more immersive viewing experience by allowing users to interact directly with the video content. Some common controls are swipe to seek and tap-based volume and brightness control.

From a technical perspective, we need to implement the following components:

  1. A GestureDetector component that captures and interprets touch input from the user, identifying specific patterns such as swipes, taps, and pinches.
  2. The VideoControlHandler receives interpreted gestures from the detector and translates them into commands for the video player.
  3. With visual feedback mechanisms, we can provide subtle on-screen indicators when gestures are recognized, helping users understand the result of their actions.

The implementation must carefully handle:

  • Distinguishing between similar gestures (e.g., horizontal swipe vs. diagonal swipe).
  • Conflict resolution when multiple gesture patterns overlap.
  • Accessibility considerations for users who cannot perform certain gestures.

The technical challenge lies in creating a responsive system that feels natural while properly integrating with the video player's core functionality.

Background audio playback

Many YouTube videos are valuable for their audio content, even when users aren't actively watching, such as music, podcasts, or news. Implementing background audio playback allows users to continue listening while using other apps or when their device is locked.

Mode switching logic

When users navigate away from the app or lock their device, we need seamless transitions between video and audio-only modes. We'd follow these steps:

  1. Listen for app lifecycle events to determine when the app moves to the background.
  2. When in audio-only mode, release video rendering resources while maintaining audio playback.
  3. Switch to audio-only streams when in background mode to save data usage.

πŸ› οΈ Platform implementation details

To enable background audio-only playback in our apps:

  • On Android, we can implement a foreground service [30] with media notifications to maintain playback priority using the Jetpack Media3 APIs [31].

  • On iOS, we can use the AVAudioSession [32] with the appropriate category and options to continue playback when the app enters the background.

User control and preferences

To provide a great user experience, we should respect user preferences regarding background playback. We should:

  • Allow users to enable/disable automatic background playback.
  • Provide settings for audio-only mode on cellular networks to reduce data usage.
  • Consider different behavior for premium versus free users, as background playback is often a premium feature.

Picture-in-Picture mode

Picture-in-Picture (PiP) mode [33] allows users to continue watching videos in a small, floating window while using other apps. This feature enhances multitasking capabilities and keeps users engaged with content even when they need to switch contexts. Figure 7 shows this feature in action.

Image represents a mobile phone screen displaying a video player interface. The screen is predominantly black, with a rectangular video player occupying the central portion.  The video shows a snowboarder on a snowy mountain slope.  The video player includes a close button (an 'X' in the top left corner), a full-screen button (a square with an arrow pointing out in the top right corner), a play/pause button (||) centered below the video, and two circular buttons with a '10' inside, positioned on either side of the play/pause button, likely representing a 10-second skip forward or backward functionality. A thin horizontal progress bar is visible below the video. Above and below the video player are rows of six gray, square placeholders, likely representing icons for additional features or controls, though their specific functions are not indicated. The overall arrangement is vertically oriented, with the video player sandwiched between two rows of these placeholder icons.  The phone screen itself is bordered by a slightly lighter, almost teal, background.

taken from YouTube Help pages.

πŸ› οΈ Platform implementation details

On iOS, PiP is supported natively starting from iOS 9 on iPads and iOS 14 on iPhones. PiP is managed through AVPictureInPictureController [34], which provides a straightforward API for enabling PiP playback with AVPlayerLayer or AVPlayerViewController instances.

On Android, starting from Android 8.0 (API level 26), you can call enterPictureInPictureMode with a configured PictureInPictureParams object on an Activity to enter PiP mode if supported by the device and system settings. For more details, refer to the Android developer documentation [35].

Optimizing the PiP experience

Beyond basic implementation, we can implement several optimizations to improve the PiP experience:

  • Automatically enter PiP when users press the home button or switch apps while watching a video.
  • In PiP mode, simplify controls to essential functions (play/pause, close) that are easily accessible in the smaller interface.
  • Reduce video quality in PiP mode to save bandwidth and processing power.
  • Pause non-essential animations and background tasks when in PiP mode.
  • When users tap the PiP window to return to the app, restore the exact state they left.

Coordinating enhanced user experiences

The value of gesture-based controls, background audio playback, and picture-in-picture mode lies in their integration into a unified system. Gesture functionality, for instance, should remain consistent whether the video is displayed full-screen or in PiP mode. Similarly, transitioning to background playback must preserve playback position, enabling users to resume effortlessly.

By ensuring these features interoperate harmoniously, the application delivers a coherent and adaptable experience that aligns with mobile design principles.

Step 5: Wrap-up

In this chapter, we've designed a YouTube mobile client focused on delivering high-quality video content while maintaining a responsive user experience. We built the system around a robust REST API with JSON that can handle multiple clients.

In the deep dive section, we explored how to implement adaptive streaming through protocols such as DASH and HLS, which automatically adjust video quality based on available bandwidth and device capabilities. We also developed an intelligent prefetching system that anticipates and fetches video feed data by analyzing user behavior and device resources. Finally, we explored ways to enhance the user experience through gesture controls, background audio playback, and Picture-in-Picture mode.

If time permits during the interview or if you want to showcase more advanced capabilities, here are some compelling features to explore:

  • Live streaming platform [36]: Design a low-latency streaming system with integrated chat features, including smart message handling and content moderation to maintain quality conversations.
  • Smart content management: Develop a comprehensive playlist [37] system that works offline and enables seamless sharing across platforms through deep links.
  • Unified device experience: Create a system that synchronizes viewing progress and queues across devices, allowing users to seamlessly switch between platforms.
  • Monetization integration: Incorporate an Ad service with sophisticated video preloading and video stream insertion capabilities.

Resources

[1] YouTube 2024 demographics: https://blog.hubspot.com/marketing/youtube-demographics
[2] YouTube 2024 statistics: https://whop.com/blog/youtube-statistics
[3] YouTube Data API: https://developers.google.com/youtube/v3/docs/videos
[4] YouTube Video resource definition: https://developers.google.com/youtube/v3/docs/videos#contentDetails.definition
[5] YouTube IFrame Player API: https://developers.google.com/youtube/iframe_api_reference
[6] Netflix's CDN Open Connect: https://openconnect.netflix.com/
[7] HTTP Live Streaming (HLS) protocol: https://en.wikipedia.org/wiki/HTTP\_Live\_Streaming
[8] Dynamic Adaptive Streaming over HTTP protocol: https://en.wikipedia.org/wiki/Dynamic\_Adaptive\_Streaming\_over\_HTTP
[9] Common Media Application Format (CMAF): https://www.wowza.com/blog/what-is-cmaf
[10] WebRTC: https://en.wikipedia.org/wiki/WebRTC
[11] Secure Reliable Transport (SRT): https://en.wikipedia.org/wiki/Secure\_Reliable\_Transport
[12] Real-Time Streaming Protocol (RTSP): https://en.wikipedia.org/wiki/Real-Time\_Streaming\_Protocol
[13]: Real-Time Messaging Protocol (RTMP): https://en.wikipedia.org/wiki/Real-Time\_Messaging\_Protocol
[14] Video Streaming Protocols: What Are They & How to Choose The Best One: https://getstream.io/blog/streaming-protocols/
[15] Streaming Protocols: Everything You Need to Know: https://www.wowza.com/blog/streaming-protocols
[16] Adaptive bitrate streaming: https://en.wikipedia.org/wiki/Adaptive\_bitrate\_streaming
[17] YouTube Live Streaming Ingestion Protocol Comparison: https://developers.google.com/youtube/v3/live/guides/ingestion-protocol-comparison
[18] Android's Exoplayer: https://developer.android.com/media/media3/exoplayer
[19] iOS AVPlayer: https://developer.apple.com/documentation/avfoundation/avplayer/
[20] Texture atlas: https://en.wikipedia.org/wiki/Texture\_atlas
[21] FastPix sprite sheets: https://www.fastpix.io/blog/create-video-previews-with-sprite-sheets-for-streaming
[22] Reddit's improvement to video playback: https://proandroiddev.com/improving-video-playback-with-exoplayer-7ac55e9bd0af
[23] Netflix's shot-based encodes for 4K streaming: https://netflixtechblog.com/optimized-shot-based-encodes-for-4k-now-streaming-47b516b10bbb
[24] Netflix's scalable system for ingestion and delivery of timed text: https://netflixtechblog.com/a-scalable-system-for-ingestion-and-delivery-of-timed-text-6f4287a8a600
[25] Netflix's studio quality experience with high-quality audio: https://netflixtechblog.com/engineering-a-studio-quality-experience-with-high-quality-audio-at-netflix-eaa0b6145f32
[26] Android's WorkManager API: https://developer.android.com/topic/libraries/architecture/workmanager
[27] iOS' Background Tasks framework: https://developer.apple.com/documentation/backgroundtasks
[28] Inside Facebook's video delivery system: https://engineering.fb.com/2024/12/10/video-engineering/inside-facebooks-video-delivery-system
[29] Overview of Facebook Feed recommendations: https://transparency.meta.com/features/explaining-ranking/fb-feed-recommendations/
[30] Android foreground services: https://developer.android.com/develop/background-work/services/fgs
[31] Android Jetpack Media3 APIs: https://developer.android.com/media/media3
[32] iOS' AVAudioSession API: https://developer.apple.com/documentation/avfaudio/avaudiosession
[33] Picture-in-picture mode: https://en.wikipedia.org/wiki/Picture-in-picture
[34] iOS Picture-in-Picture APIs: https://developer.apple.com/documentation/avkit/avpictureinpicturecontroller
[35] Android Picture-in-Picture APIs: https://developer.android.com/develop/ui/views/picture-in-picture
[36] Live streaming: https://en.wikipedia.org/wiki/Live\_streaming
[37] Playlist system: https://en.wikipedia.org/wiki/Playlist

Code

CHAPTER 10

Mobile System Design Building Blocks

10

This chapter covers the key building blocks that frequently appear in Mobile System Design (MSD) interviews. For each topic, we'll define the concept, explain when to use it, and explore alternative options and discuss trade-offs.

The topics covered in this section are:

  • Architecture: Common patterns, architecture layering, modularization, and testing.
  • Data management: Data storage, networking, authentication, pagination, and performance.
  • Feature development: Release strategy, force upgrading, feature flags, remote config, A/B testing, observability and analytics, localization, privacy, accessibility, push notifications, app size, and CI/CD.
  • Supporting different devices: Different form factors, and OS and SDK versions.

Architecture

A well-designed architecture serves two essential purposes in mobile development: it provides the foundation for building robust, maintainable apps, and it creates a shared language that helps developers collaborate effectively.

Mobile apps can be built using several architectural approaches. Among the most widely adopted are Clean Architecture [1] and Google's guide to app architecture [2], which emphasizes Unidirectional Data Flow [3] and the repository pattern [4]. We can also find various UI-focused architectures including familiar patterns such as MVC [5], MVVM [6], MVI, or other Redux-like patterns as well as more specialized approaches such as VIPER [7], Uber's RIBs [8], Airbnb's Mavericks [9], or Slack's Circuit [10] libraries. These UI layer architectures typically work alongside other patterns to create complete solutions.

Let's explore key architectural concepts we might encounter during interviews. We'll start with common patterns and examine how they apply to both UI and data layers, following Google's architectural guidelines. From there, we'll branch into related topics, showing how to think through and discuss architectural decisions effectively.

Common architectural patterns

While there are many ways to structure a mobile app, certain architectural principles are essential for building robust and scalable solutions. Let's explore some of the most important patterns that will strengthen our answers.

Separation of concerns

One of the fundamental principles of good architecture is keeping UI logic separate from other parts of your code [11]. Rather than cramming everything into the UI layer, each component should have a focused responsibility. UI classes should handle only what they're meant to: displaying content and responding to user interactions.

By dividing responsibilities across different components, our code becomes cleaner and more maintainable. This approach makes testing easier since we can verify each component in isolation. It also helps avoid common pitfalls related to the UI lifecycle, as the business logic lives independently from UI-specific concerns.

Unidirectional Data Flow

The Unidirectional Data Flow (UDF) pattern [3] creates a predictable way for data to move through the app by enforcing a single direction of flow. Here's how it works:

Data starts at a source (e.g., a database) and flows through the app until it reaches its final destination, typically the UI. As this data passes through different layers, each one can transform it based on business logic. When something needs to change the data, it sends events flowing through the other direction back to the source. The source exposes ways to let other parts of the app request these changes.

This one-way flow, as shown in Figure 1, brings several key benefits:

  • It makes data changes more predictable since they can only happen through defined paths.
  • It reduces bugs by limiting where and how data can be modified.
  • It simplifies debugging because we can trace exactly how data moves through the app.
Image represents a data processing pipeline.  On the left is a 'Source' component, a rectangular box labeled as such, which feeds 'State' data to a central processing unit. This central unit is depicted as a larger rectangle containing four smaller, identically sized rectangles representing individual 'Business logic transformations'.  These transformations process the incoming 'State' data.  The processed data, labeled as 'Events', is then sent to a 'Consumer' component on the right, another rectangular box with the label 'Consumer'.  A dotted line indicates a feedback loop where the 'Consumer' sends information back to the 'Source'.  The solid line from 'Source' to 'Consumer' represents the unidirectional flow of data through the transformations, while the dotted line represents a bidirectional feedback mechanism.

Reactive programming

Throughout this book, we use reactive programming [12] to implement Unidirectional Data Flow. At its core, reactive programming creates a stream of data that flows from a source to consumers who "subscribe" to receive updates. This approach has gained widespread adoption in mobile development because it elegantly handles asynchronous data and helps create responsive apps.

Modern mobile platforms offer several powerful tools for reactive programming. On Android, we can use Kotlin Flows or RxJava, while iOS developers typically work with Combine or RxSwift. For simpler cases where we just need a one-off asynchronous operation, Kotlin's suspend functions and Swift's async-await provide lighter-weight alternatives.

While callbacks and event buses can work well for specific use cases, they might complicate state management. We recommend using reactive programming's stream-based approach instead, as it offers a more straightforward way to handle data flow in our apps. This makes the code easier to maintain and reason about over time.

As previously mentioned, every solution involves trade-offs. Event buses, for instance, can help reduce coupling between components in the codebase.

Immutable data structures

When building modern mobile apps, particularly those using unidirectional data flow, immutable data structures play a key role in managing state. By making data immutable (i.e., data can't be changed after creation), we reduce bugs and make our app's behavior more predictable. This is particularly valuable in mobile apps where multiple threads often need to access the same data simultaneously.

While we might occasionally need mutable data structures for performance-critical features, these cases are rare. When we do use mutable data, it's best to isolate it from the app's main state and carefully control how it can be modified. For example, we might use mutable structures in a specialized image processing module, but keep the rest of our app's data immutable.

Single source of truth (SSOT)

In modern mobile apps, each data type needs a clear owner: a Single Source of Truth (SSOT) [13]. The SSOT has exclusive authority to modify its data and follows the Unidirectional Data Flow pattern by:

  • Receiving events that request data changes.
  • Processing those changes according to business logic.
  • Exposing the updated state as immutable data.

Let's look at how this works in practice. A local database often serves as the SSOT for offline data, while UI state holders own their screen's display state. Figure 2 illustrates these common SSOT patterns.

Image represents a simplified data flow diagram for a mobile application.  A database (DB) containing 'Business data' is connected to 'Repository 1', which also receives 'Business data'.  'Repository 2' provides 'Other data' to a 'State Holder' component.  'Repository 1' and 'Repository 2' are rectangular boxes representing data storage or retrieval mechanisms.  'Business data' flows from the DB and 'Repository 1' into the 'State Holder', a central component managing application state.  The 'State Holder' also contains 'UI state' data.  Finally, 'UI state' flows from the 'State Holder' to the 'UI' (User Interface) component, a rectangular box representing the application's visual presentation to the user.  The arrows indicate the direction of data flow.  The 'State Holder' acts as a central hub, aggregating data from different sources ('Repository 1', 'Repository 2', and implicitly, the DB) before sending the relevant 'UI state' to the UI for display.

for business or application data, and the State Holder is the SSOT for UI state.

By designating clear owners for each data type, we create a more maintainable system where data flows predictably and state changes are more predictable and traceable. This makes bugs easier to isolate and fix, and helps maintain consistent state even in multi-threaded scenarios.

Dependency Injection

As the app grows in complexity, different components need to work together to accomplish business goals. These relationships between components create dependencies that need careful management. While both Dependency Injection [14] and Service Locator [15] patterns can handle this management, we focus on Dependency Injection in this book.

Why choose Dependency Injection? Think of it as letting components receive what they need rather than creating it themselves. Instead of a component reaching out to create its dependencies, they're provided from the outside. This makes the code more flexible and easier to test since we can swap out dependencies as needed.

For example, imagine a component that needs to make network calls. Rather than having the component create its own network client, we can pass in (or "inject") that client through the component's constructor. This approach, known as constructor injection, makes it clear what each component needs to function and allows us to provide different implementations for testing or handle future changes more gracefully.

We specifically avoid the Service Locator pattern because it can mask dependencies and complicate testing if not implemented carefully. By being explicit about dependencies through injection, we create code that's easier to understand, maintain, and verify.

When reading the architecture diagrams in this book, it's important to understand that arrows show how data moves through the system, not component dependencies. Take Figure 3, where we can see the UI state flowing from the state holder to the UI component. While the UI depends on the state holder to access and subscribe to this data, we don't explicitly show this dependency with dotted lines in our diagrams to keep them clean and focused on data flow.

Image represents a simplified architecture diagram showing the data flow and dependency between two components: a 'State Holder' and a 'UI' (User Interface).  A solid arrow labeled 'Data flow' points from the 'State Holder' to the 'UI,' indicating that data is unidirectionally transmitted from the State Holder to the UI.  A dashed, curved arrow labeled 'Depends on' connects the 'UI' back to the 'State Holder,' illustrating that the UI's functionality relies on the data and state maintained within the State Holder.  The boxes representing 'State Holder' and 'UI' are simple rectangles containing their respective labels.  The diagram visually depicts a system where the UI displays information provided by the State Holder, and its operation is dependent on the data held within the State Holder.

In the high-level architecture diagrams of this book, notice we represent Dependency Injection as a box between different architectural layers. This box acts as a boundary that helps keep components loosely coupled, as shown in Figure 4.

Image represents a client-side architecture employing dependency injection.  A rounded rectangle labeled 'Client' encloses the entire system. Inside, a light-grey rectangular block labeled 'Dependency Injection' sits at the top, representing the mechanism for providing dependencies. Below this, labeled 'Data layer,' are three rectangular boxes representing data repositories: 'Hotels Repository,' 'Amenities Repository,' and 'Reservations Repository.'  Upward-pointing arrows connect each repository to the 'Dependency Injection' block, illustrating how these repositories are injected as dependencies into the client.  The overall diagram shows how the client accesses data through these repositories, managed by the dependency injection mechanism.

Decoupling components

Decoupling key components in our architecture lets them evolve independently, making the app more maintainable and easier to scale. This separation is particularly important in mobile apps not just for flexibility, but also for practical reasons such as improving testability and reducing build times when working with modules.

The main strategy for decoupling is to have components depend on interfaces or abstract classes instead of concrete implementations. This creates a "loose coupling" between components, where changes in one area don't ripple through the entire system.

This approach is especially valuable in several common scenarios:

  • When classes interact across different architectural layers.
  • When we want to avoid being tied to a specific third-party solution.
  • When we need to swap between different implementations of the same functionality.

For example, in Figure 5, MapsRepository works with a MapsProvider interface rather than any specific implementation. This flexibility allows us to use MapsTestDouble during testing, while in production we can A/B test between MapsInHouseProvider and Maps3rdPartyProvider to see which better serves our users.

Image represents a system design diagram illustrating the relationship between different map providers and a central Maps Repository.  Three distinct map providers – 'Maps TestDouble Provider,' 'Maps InHouse Provider,' and 'Maps 3rdParty Provider' – are shown at the bottom, each implementing a common interface, indicated by a dotted line labeled 'implements,' that connects them to a higher-level 'Maps Provider' component.  A solid arrow points from the 'Maps Provider' to a 'Maps Repository' component, indicating that the 'Maps Provider' sends data to the 'Maps Repository.'  The diagram showcases an abstraction layer where the 'Maps Provider' acts as an interface, allowing the system to use different map providers interchangeably without modifying the core logic that interacts with the 'Maps Repository.'

using a MapsProvider in a hypothetical app

Error propagation

Error propagation is a critical aspect of ensuring that applications are robust, reliable, and user-friendly. Proper handling and propagation of errors help in diagnosing issues, preventing application crashes, and improving the overall user experience.

The UI layer should show user-friendly error messages when things go wrong, handle simple validations such as checking form inputs, and display appropriate UI elements to indicate error states.

Other layers of the hierarchy should handle business logic errors such as invalid data, manage failures from data sources such as network or database issues, and pass relevant errors up to the UI layer when users need to know about them.

Modern mobile frameworks provide built-in tools for handling errors. For example:

  • Kotlin coroutines use structured concurrency to propagate exceptions naturally.
  • RxJava and RxSwift can handle errors in their data streams using operators such as onError.

When working with platforms that don't provide these tools, we can implement a Result type that wraps return values. This pattern lets us explicitly model success and failure cases, making error handling more predictable. However, when using modern frameworks (e.g., Kotlin coroutines), this extra layer isn't necessary since error handling is already built in.

Minimal data exposure

When data flows through the app's architecture, each component should only receive the minimum data it needs to function. This principle applies both to communication between architectural layers and when fetching from data sources such as local databases or backend APIs.

Let's look at a practical example from a chat app: When showing a conversation list screen, the UI only needs basic information like contact names, latest message previews, and timestamps. It shouldn't receive the full message history for every conversation just to display these preview cards.

This "less is more" approach offers several key benefits:

  • Better performance
    • Reduces memory usage and CPU load since there's less data to process.
    • Minimizes network bandwidth consumption.
    • Improves battery life by reducing unnecessary data processing.
  • Easier maintenance
    • Simpler systems that are easier to test and debug.
    • More straightforward scaling as the app grows.
    • Clearer data flows between components.

Data transformation usually happens at the boundaries between components. For example, when the network layer receives a response from the backend, it can map that data down to just what the repository needs, stripping out unnecessary fields. This ensures each layer only handles data relevant to its responsibilities.

Architectural choices

Let's shift from the core architectural patterns we just covered to some specific implementation choices we use throughout this book. While those foundational patterns are essential for any scalable mobile architecture, the choices we'll discuss here are more flexible. You can adapt or replace them based on your experience and preferences.

This section focuses on the key details you need to understand the Architecture decisions made in this book, though I encourage you to deeply explore these topics to develop your own perspective for interviews. Let's examine these practical architectural decisions.

πŸ” Industry insights:

Duolingo improved app responsiveness and developer productivity by adopting MVVM and using Android Jetpack libraries such as Dagger/Hilt for dependency injection [16].

Uber scaled its apps by enforcing module isolation through a plugin system and RIBs [17]. Each feature is developed as a plugin with well-defined integration points in the app that allows 200+ developers to build features in parallel without stepping on each other's code.

Notion's architecture emphasizes a flexible data model and unidirectional data flow, which brought power but also complexity to their mobile clients [18].

Google's guide to App Architecture

Let's explore our core architectural approach, which builds on Google's guide to app architecture [2]. While originally created for Android, its principles work remarkably well for iOS too, making it a solid foundation for both platforms.

πŸ“Œ Remember!

If you have experience with a particular architecture that you've found effective for mobile apps, don't hesitate to discuss it in your interview. While this book uses Google's guide to app architecture as our reference point (since it embodies many core architectural principles), your personal experience with other approaches can be equally valuable.

At its heart, this architecture uses two primary layers: UI [19] and data [20]. Think of the UI layer as what users see and interact with, while the data layer handles all the behind-the-scenes business logic and storage. Some apps also benefit from an optional domain layer [21] that sits between UI and data, making it easier to share and reuse common interactions. Figure 6 illustrates how data flows through these layers.

Image represents a three-layered architecture diagram, illustrating the flow of data in a mobile application.  The leftmost rectangle is labeled 'Data Layer,' representing the data storage and retrieval component.  A unidirectional arrow points from the 'Data Layer' to the center rectangle, labeled '(Optional) Domain Layer,' indicating that data flows from the data layer to the domain layer. The domain layer, depicted with a dotted-line border to signify its optional nature, represents the business logic and data processing.  Finally, another unidirectional arrow connects the 'Domain Layer' to the rightmost rectangle labeled 'UI Layer,' showing the flow of processed data from the domain layer to the user interface layer for presentation to the user.  The overall flow depicts a linear progression of data from storage, through optional processing, to final presentation.

by Google's Guide to app architecture

UI layer

The UI layer can also be referred to as the presentation layer. According to the documentation [19]:

"The role of the UI is to display the application data on the screen and also to serve as the primary point of user interaction. Whenever this application data changes, either due to user interaction (such as pressing a button) or external input (such as a network response), the UI should update to reflect the changes.

The UI layer is made up of two things:

  • UI elements that render the data on the screen.
  • State holders that hold data, expose it to the UI, and handle logic.

Effectively, the UI is a visual representation of the application state as retrieved from the data layer."

The UI layer relies on the data layer (or domain layer if present) as its source of data. The state holder acts as an intermediary, processing this data to create the UI state, essentially a blueprint of what should be shown on screen. As stated simply in the documentation: "if the UI is what the user sees, the UI state is what the app says users should see."

Figure 7 illustrates how this works in practice. Each screen in the app is represented as a component with its own state holder. These state holders receive their data through dependency injection from other layers of the architecture.

Image represents a client-side UI layer architecture diagram for a mobile application.  The diagram is contained within a rounded rectangle labeled 'Client' and further subdivided by a dashed line indicating the 'UI layer'.  Within this layer, three distinct functional areas are depicted as rectangular boxes: 'News Feed Screen', 'Post Detail Screen', and 'Create Post Screen'. Each screen is connected via an upward-pointing arrow to a corresponding 'State Holder' component: 'News Feed State Holder', 'Post Detail State Holder', and 'Create Post State Holder'. These state holders are also represented as rectangular boxes and are positioned below their respective screens.  The arrows indicate a unidirectional data flow, suggesting that the screens access and potentially update the data held within their associated state holders.  The overall structure illustrates a separation of concerns, where the UI screens handle presentation and user interaction, while the state holders manage the underlying application data for each screen.

Building on the architectural patterns we discussed earlier, the flow of data follows a clear path: The data and domain layers expose streams of business data, which state holders transform into immutable UI states. The UI layer then subscribes to this state to display content and sends back events to request changes. These events flow through the state holder and ultimately update both the application state and UI.

We might wonder whether this architecture follows MVVM or MVI patterns. The answer is that it could be either: this architecture implements Unidirectional Data Flow, which can align with both patterns depending on how we structure the UI state streams and event handling. Rather than prescribe one approach, we believe either pattern can work well when properly implemented. While this distinction could come up during the deep dive portion of the interview, remember to keep the focus on the overall system design rather than getting too caught up in the specifics of any single component unless the interviewer asks about it.

Data layer

The data layer implements the repository pattern, providing a clean abstraction for all data access within the application. As stated in the documentation [20]:

"The data layer contains application data and business logic. The business logic is what gives value to your appβ€”it's made of rules that determine how your app creates, stores, and changes data. The data layer is made of repositories that each can contain zero to many data sources. You should create a repository class for each different type of data you handle in your app.

Repository classes are responsible for the following tasks:

  • Exposing data to the rest of the app.
  • Centralizing changes to the data.
  • Resolving conflicts between multiple data sources.
  • Abstracting sources of data from the rest of the app.
  • Containing business logic.

Each data source class should have the responsibility of working with only one source of data, which can be a file, a network source, or a local database. Data source classes are the bridge between the application and the system for data operations."

Figure 8 illustrates how the data layer typically works in a mobile app. At its core are two main data sources: the remote data source that communicates with the backend, and the local data source that handles data storage on the device. As we saw in chapters covering case studies, apps often use a database for this local storage to efficiently manage and persist data on the device.

Image represents a client-side data layer architecture employing dependency injection.  The diagram shows a rectangular 'Client' container encompassing the data layer. Within this container, a light-grey rectangle labeled 'Dependency Injection' sits at the top, indicating the mechanism for managing data sources. Below, two rectangular 'Repository' components are shown, one labeled 'X Repository' and the other 'Y Repository'.  'X Repository' receives input from an 'X Local DataSource' and an 'X Remote DataSource'. The 'X Local DataSource' receives input from a cylindrical database labeled 'X DB'.  'Y Repository' receives input from a 'Y Local DataSource'. Arrows depict the flow of data:  'X Local DataSource' and 'X Remote DataSource' feed into 'X Repository', which in turn feeds into the 'Dependency Injection' layer. Similarly, 'Y Local DataSource' feeds into 'Y Repository', which also connects to the 'Dependency Injection' layer.  The 'Dependency Injection' layer manages the selection and injection of the appropriate data source into the repositories, allowing for flexible data access.

Since repositories serve as the main entry points to the data layer, it makes sense to define them as interfaces. This allows us to swap different implementations through dependency injection: we can use the real implementation when the app is running normally, and switch to test doubles during testing.

For most MSD interviews, we take a simplified approach to navigation. Instead of diving into complex navigation patterns, we'll assume that a global Navigator, or Coordinator, handles screen transitions and dependency management.

This Navigator is responsible for:

  • Moving between different screens in the app.
  • Providing required dependencies to each screen, including static dependencies such as state holders and platform services, and dynamic data such as item IDs needed for detail screens.

The Navigator works with our dependency injection system to create and provide these dependencies correctly. When working through interview problems, we typically won't need to design complex navigation flows unless specifically asked. Most exercises focus on core functionality rather than navigation architecture. In our diagrams throughout this book, we represent navigation as shown in Figure 9.

Image represents a client-side architecture diagram for a mobile application, likely a social media platform with news feeds and post creation functionality.  The diagram is enclosed within a rounded rectangle labeled 'Client' and shows a 'UI layer' at the top, containing a rectangular component labeled 'Navigator.'  This Navigator component has downward arrows connecting it to three distinct screen components: 'News Feed Screen,' 'Post Detail Screen,' and 'Create Post Screen.' Each screen component is further connected to a corresponding 'State Holder' component (e.g., 'News Feed Screen' connects to 'News Feed State Holder').  These state holders likely manage the data and state associated with each screen.  The arrows indicate the flow of control and data; the Navigator controls which screen is displayed, and the screens interact with their respective state holders to manage data.  The overall structure suggests a layered architecture where the Navigator manages navigation between screens, and each screen interacts with its dedicated state holder to maintain its data.

In real-world applications, navigation becomes much more complex. As apps grow to include many screens, dialogs, and user flows, carefully managing navigation becomes critical. This complexity multiplies when adding deep linking support [22], which introduces additional entry points into our app that must be properly handled.

Modularization

Modularization is a powerful way to structure mobile apps into separate, independent building blocks. While modularization offers several benefits such as easier maintenance and better scalability, its biggest advantage is faster build times. However, modularization requires careful planning. Adding more modules can increase complexity and overhead if not managed thoughtfully.

When implemented well, modularization speeds up builds in three key ways:

  • Uses build resources more efficiently.
  • Reduces how much code needs recompiling.
  • Allows for parallel compilation of different modules.

While we won't dive deep into modularization in later chapters, it's worth mentioning in interviews. The mobile industry increasingly follows the "contract module" pattern where each component has at least two modules: an API module and an implementation module. This separation means that when we change how something is implemented, modules that only depend on its API don't need to rebuild.

Let's look at a concrete example in Figure 10. Here we have several types of modules:

  • Feature modules handle the UI for specific screens (e.g., "X feature module" in the diagram).
  • API modules define the interfaces that features need (e.g., "X data layer api module" in the diagram).
  • Implementation modules contain the actual code behind those interfaces (e.g., "X data layer impl module" in the diagram).
  • Test modules provide test implementations (e.g., "X data layer test module" in the diagram).
Image represents a modular system design, likely for a mobile application.  The diagram shows five rectangular boxes representing different modules: 'app module,' 'X feature module,' 'test module,' 'X data layer api module,' and 'X data layer impl module,' and 'X data layer test module'.  Dashed arrows indicate dependencies or data flow. The 'app module' depends on the 'X feature module' (indicated by a dashed arrow labeled 'depends on'). The 'X feature module' depends on the 'X data layer api module' (indicated by a downward dashed arrow). The 'app module' also connects to the 'X data layer impl module' via a downward dashed arrow.  The 'test module' is connected to the 'app module' with a horizontal dashed arrow, indicating a testing relationship.  Finally, the 'test module' depends on the 'X data layer test module' (indicated by a downward dashed arrow).  There's also a dashed arrow connecting the 'X data layer api module' and 'X data layer impl module', suggesting an interaction or data exchange between these two data layer modules.  A final loop connects the 'X data layer test module' to the 'X data layer api module' with a dashed arrow, suggesting a cyclical testing or feedback mechanism.

When implementation details change, feature modules don't need to rebuild because they only depend on the stable API modules. But we might wonder, "how do features know which implementation to use"? This is where the app module comes in. It sits at the top level, connecting everything together by injecting the right implementations as dependencies. Similarly, when running tests, the test module provides test implementations instead of the real ones.

For a deeper dive into modularization, check out Google's guide to app modularization [23], which expands on the architectural principles we've discussed in this chapter.

πŸ” Industry insights:

Airbnb restructured its large iOS app into nearly 1,500 modules to improve team productivity [24].

Robinhood modularized its Android app to combat a β€œgiant monolith” module that was slowing builds and entangling features. They split the app into feature-specific modules which improved build times [25].

Testing

Testing should be a key part of the interview discussion, as it demonstrates how our architecture supports quality and maintainability. While testing often comes up naturally when discussing dependency injection or modularization, we should also be prepared to outline our overall testing strategy for the system we're designing.

Mobile apps typically implement several types of automated tests, though terminology can vary across teams and organizations. When discussing testing approaches in the interview, be clear about how you define each type of test to ensure shared understanding. Here are the main categories you'll likely want to cover:

  • Unit tests. The foundation of the testing pyramid, unit tests verify individual components in isolation. By replacing dependencies with test doubles, we can validate specific behaviors and code paths. These tests are fast, reliable, and help catch issues early.
  • Component, Integration, and/or Navigation tests. These tests validate how multiple components work together by using some real implementations instead of test doubles. While they provide more comprehensive coverage than unit tests, they tend to run slower and may require more maintenance. They're particularly valuable for validating critical user flows and navigation paths.
  • Screenshot tests. These tests fill an important gap in UI testing that unit tests can't easily cover: visual elements such as layouts, fonts, and styling. They work by capturing a reference image of a UI component and comparing it against future changes to catch unintended visual regressions. While simple in concept, they're powerful for maintaining visual consistency.
  • End-to-end tests. Sitting at the top of the testing pyramid, end-to-end tests verify that the app works correctly with real backend services and dependencies. While these tests provide the most comprehensive validation, they're also the most expensive to maintain and the most likely to be flaky due to network conditions or backend state. We should have fewer of these tests, focusing on the most critical user journeys.

Beyond these categories, there are specialized tests that can be applied across different layers of the testing pyramid. These include accessibility tests to verify app usability for all users, performance tests to catch slowdowns and bottlenecks, memory management tests to identify leaks, and security tests to validate data protection measures. While these tests focus on specific quality aspects rather than functionality, they're crucial for delivering a robust and inclusive mobile app.

Testing in mobile development is well-documented, with many established patterns and best practices. Here are some key resources that can help guide the testing strategy:

  • Google's fundamentals of testing apps [26].
  • The robot pattern [27].
  • Faking interactions with the backend using OHHTTPStubs [28] for iOS and MockWebServer [29] for Android.

πŸ” Industry insights:

Slack's Android team made UI tests a first-class part of development by focusing on β€œtargeted and hermetic” UI tests. Read more in their testing series [30].

Bitrise shared their recommended mobile testing strategy [31], and you can read how Netflix [32] and Dropbox [33] test their mobile apps.

Data Storage

Mobile apps need effective data storage to create great user experiences. Whether it's remembering user preferences, caching network responses, or storing offline content, how we manage data directly impacts app performance and reliability. Let's explore the different storage options available and understand when to use each one.

Before diving into storage solutions, let's look at the main types of data the app might need to handle:

  • User data such as profile information, app settings, and user preferences that customize the experience. This includes everything from theme choices to notification settings.
  • User files such as photos, videos, and documents that users create or upload in the app. These files remain on the device unless synced to servers, and the app loses access if users delete them.
  • Business data or core application data the app shows to users such as news articles, product listings, or chat messages. If users remove this data from their device, the app can typically retrieve it again from the backend.
  • Sensitive information such as authentication tokens, encryption keys, and other security-critical data that requires special protection. This data often needs secure storage mechanisms to prevent unauthorized access.
  • Cache data, that is, temporary data stored to improve app performance, such as downloaded images or pre-computed results. This data can live in memory or on disk depending on size and how long it needs to persist.
  • Temporary data such as upload file chunks, form drafts, or undo history. This data can be safely discarded when no longer needed or when the app closes.

While some data needs persistent storage on disk, other data can live briefly in memory and be released when the system needs resources. The key is choosing the right storage approach based on the data's lifetime and access patterns.

Data storage options

When our app needs to persist data to disk, choosing the right storage mechanism is crucial. The best choice depends on what type of data we're storing and how we plan to use it. Let's explore the available options.

Key-value storage

Key-value storage provides a simple way to store basic data types such as strings, integers, and booleans. Think of it as a dictionary where each piece of data (the value) is accessed through a unique identifier (the key). This storage type works well for straightforward data such as user preferences, app settings, and feature toggles.

The beauty of key-value storage lies in its simplicity. The APIs are easy to implement and use, requiring minimal setup. When working with small datasets, read and write operations are fast and efficient.

However, key-value storage isn't suitable for every scenario. It struggles with large amounts of data or complex data structures, relationships between data items, advanced querying needs, data integrity enforcement, and transactional operations. Additionally, key-value stores typically offer less security than specialized secure storage solutions unless we implement extra protection measures.

πŸ› οΈ Platform implementation details

Both Android and iOS provide native key-value storage solutions:

  • Android offers SharedPreferences [34] and DataStore [35].

  • iOS offers UserDefaults [36] and Property Lists (Plists) [37].

On-device secure storage

When handling sensitive data such as user credentials, payment details, or cryptographic keys, secure on-device storage becomes essential. This specialized storage leverages hardware security features built into mobile devices to protect sensitive information from unauthorized access.

The key benefit of secure storage is its robust encrypted data protection. This helps apps meet strict data protection requirements while providing strong security guarantees. Modern platforms such as Android's Keystore system [38] and iOS's Keychain [39] services make implementing secure storage straightforward.

However, secure storage does come with some trade-offs:

  • Implementation is more complex than basic storage options.
  • Testing and debugging can be more challenging.
  • Encryption and decryption operations add performance overhead.
  • Resource usage may impact low-end devices.
  • Storage capacity is often limited, making it unsuitable for large datasets.

On iOS, the Keychain provides an additional benefit, integration with iCloud Keychain lets us securely sync credentials across a user's devices using end-to-end encryption.

Relational Databases

When the app needs to handle complex data efficiently, relational databases provide a robust storage solution. They excel at managing large datasets, handling complex queries, and maintaining relationships between different types of data. This makes them ideal for storing both business data and cached content that needs to persist on disk.

Key advantages of relational databases include:

  • Optimized data retrieval and manipulation that scales well with large datasets.
  • Powerful indexing capabilities to speed up search operations.
  • Built-in management of relationships between data entities.
  • Strong data integrity guarantees.
  • Support for schema migrations as the data model evolves.

However, relational databases also come with some trade-offs to consider:

  • More complex setup and maintenance compared to simpler storage options.
  • Schema design requires careful planning.
  • Higher resource consumption that can impact app performance.
  • Potential latency overhead, especially for complex queries.

πŸ› οΈ Platform implementation details

For mobile development, SQLite serves as the foundation, with both Android and iOS providing native support. Google officially supports the Room persistence library [40] for Android, while Apple offers Core Data for iOS [41].

Non-relational Databases

While relational databases are commonly used in mobile development due to their native platform support, non-relational databases can be a compelling alternative depending on the data needs.

For mobile apps, Realm [42] stands out as a popular non-relational option. Its focus on simplicity and speed, combined with built-in support for reactive programming and real-time sync, makes it particularly well-suited for mobile use cases. Firebase Realtime Database [43] offers another approach, providing a cloud-based NoSQL solution with offline support that automatically syncs data when connectivity returns.

The key advantage of non-relational databases is their flexibility: they don't require predefined schemas, making them ideal for storing unstructured or semi-structured data. They also excel at high-performance read and write operations.

However, this flexibility comes with trade-offs. Non-relational databases work best when the app needs simple CRUD operations rather than complex queries. They typically lack the ACID transactions and referential integrity guarantees that relational databases provide through features such as foreign keys and constraints. This can lead to potential data consistency challenges that we'll need to handle in the application logic.

Files and Directories

Sometimes the data needs don't fit neatly into databases or key-value stores. In these cases, storing data directly in files can be a good alternative. This approach works particularly well for binary data or scenarios like note-taking apps where each note is saved as a separate text file.

The main advantage of file storage is its flexibility, we're not constrained by database schemas and can create our own custom file formats. Implementation is straightforward since we're just working with basic file I/O operations. Files are also portable across different platforms and can be included in backup systems.

The trade-off is that we lose the structure and features that databases provide. We'll need to handle everything manually: ensuring data integrity, building query capabilities, managing concurrent access, and planning for scalability.

Both iOS and Android provide native APIs for file operations. On iOS, we'll work with FileManager [44], while Android offers its File APIs [45]. These let the app read and write directly to internal or external storage, which is especially useful when dealing with large files or complex data structures that don't map well to database tables.

Storage options comparison table

Table 1 provides a comprehensive comparison of the major storage options available for mobile apps. Use this as a reference when evaluating which storage mechanism best addresses the specific requirements during an interview.

Storage MechanismAdvantagesDisadvantagesUse CasesExamples
Key-value storageSimple to implement and use, fast read and write operations for small data sets.Not suitable for large amounts of data or complex data structures, lacks advanced querying, less secure.Storing non-sensitive user data such as preferences, settings, app configuration.Android: SharedPreferences, DataStore. iOS: UserDefaults, Property Lists (Plists).
On-device secure storageSecure storage for sensitive data, encrypted at rest and in transit, complies with data protection standards.More complex to implement, performance overhead from encryption / decryption, limited storage capacity.Storing sensitive information such as user credentials, payment information, cryptographic keys.Android: Keystore. iOS: Keychain, iCloud Keychain.
Relational DatabasesEfficient management of large datasets, supports complex queries, ensures data integrity.More complex setup and maintenance, resource intensive, potential latency.Handling large datasets, maintaining relationships between data entities, performing complex queries.Android: SQLite, Room library. iOS: SQLite, Core Data.
Non- relational DatabasesFlexible schema-less data models, high performance for read/write operations.Limited support for complex queries, potential inconsistency issues.Handling unstructured or semi-structured data.Android and iOS: Realm, Firebase Realtime Database, Couchbase Lite.
Files and DirectoriesFlexible storage formats, simple implementation, easy data sharing between platforms.Lack of structure, requires manual management of data integrity, concurrency, and scalability.Handling binary data, use cases like note-taking apps where data is stored as individual files.Android: File APIs. iOS: FileManager.

Table 1: Storage options comparison

Storage selection decision tree

While the comparison table offers a detailed view of each storage option, a decision tree provides a structured approach to selecting the most appropriate storage solution based on the specific requirements.

During an interview, walking through this decision tree demonstrates a methodical approach to storage selection. Rather than simply naming a storage option, you can explain your reasoning at each decision point, showing how we've considered security needs, data complexity, relationship requirements, and access patterns.

Image represents a decision tree for choosing the appropriate data storage solution for a mobile application.  The tree starts with a question about data security: 'Is the data security-critical (credentials, tokens, keys)?' A 'YES' answer leads to 'On-device secure storage,' while 'NO' proceeds to the next question: 'Does the data have complex relationships or need advanced querying?'  A 'YES' answer leads to a question about data structure rigidity ('Is the data structure rigid and well-defined?'). A 'YES' to this leads to 'Relational Database,' and a 'NO' leads to 'Non-relational Database.' If the answer to the second question is 'NO,' the flow continues to 'Is it primarily binary data (images, audio, video)?' A 'YES' answer directs to 'Custom/Binary Storage,' and a 'NO' leads to the final question: 'Is it simple key-value pairs?'  A 'YES' answer again points to 'Custom/Binary Storage,' while a 'NO' loops back to the 'Is the data structure rigid and well-defined?' question, completing the decision process.  The entire diagram uses cloud shapes for questions and rectangular boxes for storage solutions, with arrows indicating the flow based on 'YES' or 'NO' answers.

πŸ“Œ Remember!

The comparison table and decision tree offer a simplified framework to guide storage decisions during interviews, but real applications often require more nuanced approaches.

Often, mobile apps require multiple storage solutions working together. For example, a social media app might use:

  • Secure storage for authentication tokens.

  • A relational database for structured content such as user profiles and posts.

  • Key-value storage for app settings and preferences.

  • File storage for cached images and media.

πŸ” Industry insights:

Netflix chose the Realm mobile database for storing video metadata on devices to support offline downloads [46].

Trello redesigned its mobile data layer to work offline by making the local database the single source of truth [47].

How mobile apps save and share data

While mobile apps have various storage options available, they all ultimately save data as files on the device's disk. What differs between these options is how they organize, manage, and provide access to this data through their respective APIs.

By default, mobile operating systems use a sandboxed storage model: each app gets its own private storage space that other apps cannot access. This internal storage provides a basic level of security and data isolation.

However, this protection has limitations. Users who jailbreak their iOS devices or root their Android devices can bypass these security measures and potentially access or modify any app's internal storage. For sensitive data that requires additional protection, secure storage solutions backed by hardware-level security features offer stronger safeguards against unauthorized access, even on compromised devices.

Accessing device-wide storage

Mobile platforms provide mechanisms for apps to interact with storage areas that exist outside their private sandbox. This enables important functionality such as saving photos to the device gallery or accessing documents from other apps.

On Android, shared storage areas allow multiple apps to access common files. This includes both the device's built-in storage (sometimes called "external storage" even though it's physically inside the device) and removable storage (such as SD cards). Apps commonly use these areas to store files meant to be shared, such as photos, videos, and documents.

iOS takes a different approach with more controlled access points. Instead of a general shared storage area, iOS offers specific directories for different purposes:

  • The Documents Directory stores files that users should be able to access and that get backed up to iCloud.
  • The Library directory holds data that apps need to share but shouldn't be directly visible to users.

Users can access shared files through iOS's Files app, and other apps can request access to them when needed. We can find a complete list of these directories in the FileManager documentation [48].

Working with media and files from other apps

Some mobile apps need to access content created by other apps, whether that's photos from
the camera app or documents downloaded from a web browser.

Both platforms provide system APIs specifically designed for this purpose:

  • On iOS, the PHAsset and PHImageManager APIs let apps load photos and videos from the user's media library [49], while UIDocumentPickerViewController enables users to browse and select files from anywhere on their device [50].
  • On Android, the MediaStore API provides access to media files such as photos and videos, while the Storage Access Framework creates a standardized way for users to browse and open documents from any compatible file provider. For the latest Android storage APIs, check out the data and file storage overview guide [51].

These APIs handle the necessary permissions and security checks, ensuring that users remain in control of which apps can access their personal content. When an app requests access to these files, the system will show a permission dialog, allowing the user to approve or deny the request.

Sharing data between your own apps

Developers creating multiple apps often need a way for those apps to share data with each other. Both Android and iOS provide secure mechanisms for this purpose.

Android uses Content Providers [52] for this scenario. By configuring specific permissions and signing requirements [53], developers can ensure that only their own apps can access specific shared data. This works by restricting access to apps signed with the same developer certificate.

iOS implements App Groups [54] for this purpose. When apps are part of the same development team, App Groups create shared containers that all member apps can access. This enables sharing various types of data, including UserDefaults settings [55] and files, between related applications.

Both approaches provide fine-grained control over data sharing while maintaining security boundaries that prevent unauthorized access from other developers' applications.

Networking and API design

Whether fetching data from servers or syncing information across devices, how we handle networking can make or break the user experience. Let's explore HTTP, the foundational protocol for client-server communication that we use multiple times throughout this book.

HTTP and REST APIs

When building mobile apps that communicate with backends, HTTP (Hypertext Transfer Protocol) is often the go-to choice. Its widespread adoption and robust feature set make it particularly well-suited for client-initiated requests.

HTTP offers several key advantages:

  • Universal compatibility across platforms, devices, and networks.
  • Stateless design that improves scalability since servers don't need to track session information between requests.
  • Support for persistent connections in modern versions (HTTP/1.1 and HTTP/2), allowing multiple exchanges over a single connection.
  • Flexibility to handle various data formats beyond just JSON, including HTML, XML, and binary data.
  • Built-in header system for exchanging metadata between clients and servers.
  • Strong security through HTTPS encryption, ensuring data remains private and authentic.

When the mobile app communicates with a server, each HTTP request has several key components:

  1. The HTTP verb (e.g., GET or POST) that specifies what action to take.
  2. A resource path that identifies what data we want to access or modify.
  3. Headers containing metadata about the request, such as authentication tokens or expected response formats.
  4. Optional request data, either in the body for operations such as creating new content, or as query parameters in the URL.

Let's look at a typical HTTP request:

Code
Accept: application/json
Authorization: Bearer \<token\>
GET https://my-backend-name.com/v1/news?after=timestamp\&limit=30

Breaking this down:

  • It's a secure HTTPS request to fetch news data.
  • The Authorization header contains the user's authentication token.
  • The Accept header tells the server to return JSON.
  • Query parameters after and limit help filter the results, asking for 30 posts after a specific timestamp.

Query parameters are especially useful with GET requests since they can't include data in their body. By adding these parameters to the URL, we can specify exactly what data we want while keeping the request safe and idempotent, meaning it won't modify any data on the server, no matter how many times it's called.

While HTTP/1.1 defines these basic methods [56] and their intended uses, most modern APIs build additional conventions on top of HTTP. This is where REST comes in, providing a standardized approach to designing backend services.

Designing REST APIs

When building backend services that need to scale, consistency is key, especially in how we define and structure our HTTP endpoints. REST (Representational State Transfer) has emerged as a popular architectural approach that provides standard conventions for making HTTP requests and handling responses. Its widespread adoption means we'll find excellent documentation and support across the development community [57].

There are many different styles of designing REST interfaces, so let's walk through the core REST practices we'll use throughout this book:

  • The main HTTP verbs map naturally to CRUD operations [58]:
    • GET to retrieve data.
    • POST to create new resources.
    • PUT to update existing ones.
    • DELETE to remove resources.
  • Each resource in our API should ideally have a unique URI that clearly identifies it. For example:
    • GET /users retrieves all users.
    • GET /users/1 gets details for the user with ID 1.
  • Use query parameters thoughtfully. They help filter, sort, and paginate results. For example:
    • GET /users?role=admin\&sort=lastname_asc\&page=2\&limit=30
  • Choose clear resource names.
    • Use nouns to represent resources
      • e.g., GET /posts instead of GET /query-posts.
    • Use plural forms consistently
      • e.g., GET /posts/{postId} instead of GET /post/{postId}.
  • Structure nested resources. For related resources, use sub-resource paths:
    • Good: GET /v1/posts/{postId}/attachments/{attachmentId}
    • Avoid: GET /v1/posts/{postId}?attachment=attachmentId
  • Version the API consistently. Use global versioning to keep our API structure clean:
    • Good: GET /v1/posts/{postId}/attachments
    • Avoid: GET /posts/{postId}/v1/attachments

REST also defines standard HTTP response codes that help clients understand the result of their requests. Here are the most common ones we'll encounter:

  • 200 OK:
    • The request succeeded.
    • Typically returned for successful GET and PUT operations.
  • 201 Created:
    • A new resource was successfully created.
    • Common response for POST requests that create items.
  • 400 Bad request:
    • Client side error. The server couldn't process the request due to invalid syntax or missing data.
  • 403 Forbidden:
    • Authentication succeeded but the client lacks permission for this resource.
  • 404 Not found:
    • The requested resource doesn't exist on the server.
  • 500 Internal Server Error:
    • Server side error. Something went wrong on the server while processing the request.

By following these standardized REST conventions, we create APIs that are intuitive, predictable, and scale well as the app grows.

Data encoding format

When transferring data over HTTP, we have flexibility in how that data is formatted. HTTP itself doesn't care about the data format, it just needs the client and server to agree on what format they're using. They establish this agreement through HTTP headers such as Content-Type and Accept.

We can choose from several common formats such as JSON (application/json), XML (application/xml or text/xml), Protocol Buffers (application/x-protobuf), and CSV (text/csv). The best choice depends on our specific needs around readability, efficiency, platform support, and how easy it is to work within the codebase.

JSON (JavaScript Object Notation) has emerged as the most popular choice for modern REST APIs, and for good reason. It's human-readable, works efficiently, and integrates smoothly with most programming languages since it closely matches how they structure data. The fact that JSON is so widely supported means we can parse it easily using standard library functions, without needing extra dependencies. Table 2 compares the different formats we could use and their use cases.

FormatAdvantagesDisadvantagesUse cases
JSONHuman-readable, easy to use and parse, widely supported across platforms and languages, compact in size compared to XML.Less efficient in terms of size and parsing speed compared to binary formats such as Protocol Buffers, no built-in schema enforcement.Lightweight data exchange. Common for Web APIs and Mobile apps.
XMLHuman-readable, supports complex data structures and metadata through attributes. It's widely supported and supports a robust schema validation with XSD.Verbose, larger file size, slower parsing compared to JSON and Protocol Buffers, more complex to write and maintain.Data interchange in enterprise applications, document-centric data, cases requiring extensive metadata.
Protocol BuffersCompact and efficient binary format, faster serialization and deserialization, strict schema enforcement, backward and forward compatibility.Less human-readable, requires defining .proto files and using code generation tools, more complex to set up.Performance-critical applications, large-scale data transmission, systems requiring strict schema enforcement and versioning.

Table 2: Trade-offs for encoding data over the network

πŸ” Industry insights:

Lyft evolved its mobile networking from ad-hoc REST+JSON to a unified IDL (Interface Definition Language) using Protocol Buffers. Lyft's system can handle both gRPC calls (for new, streaming or binary-optimized APIs) and traditional HTTPS+JSON (for backward compatibility) [59].

Reddit migrated from a monolithic backend API to GraphQL to better serve mobile clients [60], and Airbnb too [61].

Snapchat has integrated the QUIC (Quick UDP Internet Connections) protocol to enhance its networking performance, particularly in mobile environments [62].

Authentication

For mobile apps managing sensitive or personalized data, robust authentication is critical. We must ensure that users are verified as legitimate before accessing protected resources.

Token-based authentication

Token-based authentication is widely adopted in mobile apps due to its efficiency and flexibility. Unlike traditional session-based methods, it offers:

  • Stateless operations: No server-side session storage, enhancing scalability.
  • Cross-platform compatibility: Seamless use across diverse devices.
  • Granular access control: Tokens embed permissions and expiration details.
  • Reduced backend overhead: Validation occurs without frequent database queries.

The most common implementation uses JSON Web Tokens (JWT) [63], which are compact, self-contained tokens that securely transmit information between parties as a JSON object.

Authentication flow

  1. Initial login: Clients submit user credentials (e.g., username/password) to the backend Authentication service, which validates them against registered user data.
  2. Token generation: Upon successful verification, the backend issues two tokens:
    • An access token (JWT): Short-lived (e.g., 15-30 minutes), containing user identity and permissions.
    • A refresh token: Longer-lived, used to obtain new access tokens without requiring the user to log in again.
  3. Secure token storage: The app securely stores both tokens using platform-specific secure storage mechanisms.
    • Android provides the Android Keystore system [64] to create and store cryptographic keys that never leave the device's secure hardware. Apps can encrypt tokens before storing them in SharedPreferences or DataStore.
    • iOS offers the Keychain Services API [65] with multiple protection classes to secure tokens, ensuring they're encrypted at rest and only accessible when the device is unlocked.
Image represents a system architecture diagram for user authentication in a mobile application.  A user initiates the process by clicking 'Authenticate,' leading to a 'Login Screen.'  Successful login updates the 'Login State Holder.'  The 'Login State Holder' interacts with the 'Authentication Repository,' which in turn communicates with the 'Authentication Remote DataSource' (labeled '1').  The 'Authentication Repository' also receives information labeled '3' from the 'Token Repository,' which stores tokens retrieved from secure storage.  The 'Token Repository' persists data in 'Secure Storage.'  A 'Backend Authentication Service' (labeled '2') interacts bidirectionally with both the 'Backend' and a 'Third Party Identity Provider' (within a dashed box labeled 'Third Party'), suggesting integration with an external authentication service.  The arrows indicate the flow of information and authentication data between the components.  The numbers (1, 2, 3) are labels highlighting key interactions.

πŸ“ Note:

To simplify the backend and minimize the complexity of key management, companies can leverage an external Identity Provider for token issuance and validation, as illustrated in Figure 12. This provider offers a JWKS (JSON Web Key Set) endpoint, enabling systems to retrieve public keys for validating JWTs securely without exposing private signing keys.

Authenticated requests and token renewal

Once authentication is complete, the app uses the tokens for secure API access.

To make authenticated requests, the app includes the access token in the Authorization header of each API request: Authorization: Bearer <access_token>. API gateways then use these public keys to authenticate incoming JWTs, ensuring requests are verified before being forwarded to backend services.

When the access token expires, the app uses the refresh token to request a new one:

  1. The app detects an expired access token (either proactively by checking its expiration or reactively when receiving a 401 Unauthorized response).
  2. It sends the refresh token to a token renewal endpoint.
  3. If the refresh token is valid, the server issues a new access token (and sometimes a new refresh token too).
  4. The app saves these new tokens and retries the original request using the new access token. If the refresh token is invalid, the backend returns an error and the user should be logged out from the app.

Enhanced security options

To further strengthen authentication, consider implementing:

  • Multi-factor authentication (MFA): Require additional verification beyond passwords, such as SMS codes, authenticator apps, or biometrics.
  • Biometric authentication: Leverage fingerprint or facial recognition for a more user-friendly yet secure experience.
  • Certificate pinning: Ensure that authentication requests are sent only to legitimate servers, preventing man-in-the-middle attacks.
  • Trusted device registry: The backend maintains a list of known devices for each user.
  • Device fingerprinting: We collect non-sensitive device attributes to create a unique signature.
  • New device alerts: Users receive notifications when their account is accessed from a new device.
  • Suspicious activity detection: The system flags unusual patterns, such as logging in from a new location or device in close succession.

By implementing robust token-based authentication, mobile apps can ensure that only authorized users access protected resources while maintaining a smooth, secure user experience.

πŸ” Industry insights:

Uber recently rolled out passkey support in its mobile apps, embracing a passwordless login experience [66].

Many apps, such as Robinhood, mark devices as "trusted" when they have successfully completed Multi-Factor Authentication. After that, subsequent logins from the same device may be streamlined so that users can log in without providing the second authentication factor. This balances security with convenience by not asking for 2FA at every single login on the same phone [67].

Content Delivery Network (CDN)

A Content Delivery Network (CDN) distributes servers across different geographical locations to efficiently deliver static content to users. Think of it as a global network of content storage points, each serving users in its nearby area.

In mobile apps, CDNs excel at delivering content that stays relatively stable and often requires significant bandwidth, such as app icons and images, video content, HTML pages, and app assets and resources.

Why use a CDN? By storing frequently accessed content closer to users, CDNs significantly reduce the load on our main backend servers. Instead of every request going to the backend, users receive content from the nearest CDN server. This approach offers several key benefits:

  • Faster load times since content comes from nearby servers.
  • Reduced latency as there's less distance for data to travel.
  • Better user experience through quicker content delivery.
  • Lower backend costs by offloading heavy content delivery.

When should you consider adding a CDN? Let's examine the trade-offs in Table 3.

CDN AdvantagesCDN Disadvantages
Better performance: Reduced latency and faster load times due to content being stored closer to users.
Efficient traffic management: Handles large volumes and sudden spikes, preventing bottlenecks.
Reliability and scalability: Offers redundancy and easy scaling.
Reduced origin server load: Caches static content such as images, videos, and HTML pages.
Cost: Can become expensive as usage increases.
Added complexity: Requires configuration, cache management, and client integration.
External dependency: Your app's performance partly relies on the CDN provider's uptime and performance.
Content freshness challenges: Ensuring up-to-date content requires careful cache management.

Table 3: CDN advantages and disadvantages

Despite the disadvantages, CDNs are a great solution for apps that have a worldwide user base. Figure 13 illustrates how a CDN fits into our overall system architecture.

Image represents a simplified architecture diagram showing the interaction between a client, a CDN (Content Delivery Network), and a backend server.  A rectangular box labeled 'Client' represents the user's mobile device or application.  Two arrows, labeled 'https,' connect the Client to a rectangular box labeled 'CDN,' indicating that the client retrieves content via HTTPS requests from the CDN.  Another rectangular box labeled 'Backend' is connected to the CDN via an upward-pointing arrow also labeled 'https,' showing that the CDN pulls content from the backend server using HTTPS.  Finally, a second 'https'-labeled arrow connects the Backend to the Client, indicating that the client can also directly request content from the backend server, likely as a fallback if the CDN doesn't have the requested content.  The overall flow suggests a system where the CDN acts as a cache, serving content to the client more quickly than directly accessing the backend, improving performance and reducing load on the backend server.

Pagination

Pagination is a fundamental pattern we encounter frequently in MSD interviews. Any time we're working with lists of data, pagination should be one of your first considerations. Whether it's a social media feed showing posts or a messaging system handling attachments, breaking large datasets into manageable chunks is crucial for scalability.

Whenever we encounter a list in your system, we need to ask ourselves two questions:

  1. Is there a natural limit to how many items should be displayed at once?
  2. Would sending the entire dataset in a single response strain network or device resources?

If sending the full dataset would be resource-intensive, pagination is likely the right solution. By loading data in smaller batches, you improve both performance and scalability while reducing memory usage on mobile devices.

When implementing pagination, we'll typically choose between two main approaches: offset pagination and cursor-based pagination. Let's explore how each approach works and when to use them.

Offset pagination

Let's explore offset pagination, where we request data by specifying which page we want and how many items should be on each page.

Let's consider this example request: GET /v1/feed?page=3&limit=30.

Here, we're asking for the third page of results, with 30 items per page. The backend would skip the first 60 items (2 pages Γ— 30 items) and return the next 30 items.

When using offset pagination, the backend typically includes metadata about the overall dataset. This helps clients understand where they are in the full list of results. Here's what a response might look like:

Code
{
  "feed": { … },
  "paging": {
    "page": 3,
    "total_pages": 12,
    "total_items": 1000
  }
}

Offset pagination shines when working with stable, fixed-size datasets. It's straightforward to implement since it maps naturally to database queries, and it lets users jump directly to any page they want. However, it has some important limitations.

As your dataset grows larger, performance starts to suffer. The database needs to count and skip over more rows each time, which gets increasingly expensive. It's also problematic for frequently updated data, by the time a user reaches page 3, the content from pages 1 and 2 might have changed completely.

You could try to handle these issues on the client side by detecting and removing duplicates, but this creates new problems: extra processing overhead, wasted bandwidth, and the need for additional requests to fill in gaps.

This approach works well for applications with:

  • Moderate-sized, stable datasets.
  • Simple navigation needs.
  • Predictable content, such as educational materials or e-commerce catalogs.

Cursor-based pagination

We can think of cursor-based pagination like using a bookmark in a long book. Instead of jumping to a specific page number, you use the bookmark (cursor) to track where we left off and continue reading from there.

Here's how it works in practice. When requesting data, the client includes a cursor that points to their current position: GET /v1/feed?after=<cursor>&limit=30.

For the very first request, there's no cursor yet, so that field stays empty. The backend's response includes not just the data, but also cursors for moving forward and backward through the dataset:

Code
{
  "feed": { … }
  "paging": {
    "size": 20,
    "next_cursor": "bmV4dF9jdXJzb3IK",
    "prev_cursor": "Y3Vyc29yIF8gcHJldg=="
  }
}

To load the next set of items, the client simply uses the next_cursor value:
GET /v1/feed?after=bmV4dF9jdXJzb3IK&limit=30.

A key advantage of cursor-based pagination is its flexibility with page sizes. Unlike offset pagination, we can request different amounts of data with each call. The cursor itself typically maps to a unique identifier in the data, such as a primary key or timestamp. While sequential IDs are common, what matters most is having a consistent way to order the records.

For example, in a news feed system, we might use the postId as the cursor. These cursors are often encoded (commonly with Base64) before sending them over the network. This encoding helps standardize the format and prevents potential issues with special characters in URLs.

This approach works particularly well for frequently changing datasets such as social media feeds, event logs, and systems with continuous updates. Companies such as Meta [68] and Slack [69] use this pattern in their APIs. The main limitation compared to offset pagination is that users can't jump directly to a specific page, they need to navigate through the data sequentially.

Choosing the right limit number

The limit parameter controls how many items we fetch in each request, and its optimal value depends on the content we're dealing with. For lightweight content, we can use higher limits, while heavier content requires lower limits to maintain good performance.

The ideal limit value varies based on your app and content type, so it's worth experimenting to find the right balance for your specific use case. However, we can broadly evaluate different limit options:

  • 10 to 20 items
    • Ensures quick response times and minimal data transfer per request. Suitable for highly dynamic feeds with frequent updates.
    • May require more frequent requests, potentially leading to more loading indicators and a less smooth experience.
  • 30 to 50 items
    • Provides a good balance between data transfer and the need for frequent requests. Users can scroll through posts for a longer period before needing more data.
    • Slightly larger data payload per request but usually manageable for most modern mobile networks.
  • 50+ items
    • Reduces the frequency of data fetches, offering a smoother scrolling experience. Suitable for less dynamic feeds where content does not update as frequently.
    • Larger data payloads can be slower to load on slower networks or older devices. This could be a concern depending on the scale of the app.

Instead of using a fixed limit number, we could further enhance the solution by implementing adaptive pagination, which dynamically adjusts the limit based on factors like network conditions or user behavior:

  • For users who scroll quickly, we might increase the limit for a smoother experience.
  • For users who spend more time on the content, we could lower the limit to reduce unnecessary data transfer.

πŸ“Œ Remember: Selecting the right limit value in pagination is important as it directly affects both system performance and user experience. The optimal value typically depends on content weight. Generally, a limit between 20 and 50 items strikes a good balance between user experience and efficient data loading.

Pagination comparison table

Table 4 compares key pagination approaches used in mobile apps. Use this reference to quickly evaluate which strategy aligns with your specific requirements and constraints.

Pagination MethodAdvantagesDisadvantagesUse Cases
Offset paginationSimple to implement, easy to navigate specific pages, maps well to database queries.Performance degrades with large datasets, can be inaccurate with frequent updates, and may cause duplicates.Apps with moderately sized datasets requiring straightforward navigation such as educational apps, e-commerce platforms, forums.
Example: GET /v1/feed?page=3&limit=30
Cursor- based paginationEfficient for large-scale datasets, handles frequent updates well, flexible limit per request, stateless backend.Cannot jump to a specific page without traversing previous pages, may require encoding for security.Real-time and large-scale applications with high-frequency updates such as social media feeds, event logs.
Example: GET /v1/feed?after=bmV4dF9jdXJzb3IK&limit=30

Table 4: Pagination options comparison

Pagination selection decision tree

When determining which pagination approach to implement, several factors come into play. The decision tree in Figure 14 offers a structured path through these considerations, guiding us to an appropriate pagination strategy based on the app's specific needs and data characteristics.

Image represents a decision tree for choosing between different pagination strategies in database design.  The tree starts with a cloud-shaped node asking 'Is the dataset frequently updated or dynamic?'. A 'YES' answer leads to a rectangular node labeled 'Cursor-based pagination'. A 'NO' answer proceeds to another cloud-shaped node asking 'Need random access to pages?'.  A 'YES' response directs the flow to a rectangular node labeled 'Offset pagination'. A 'NO' answer leads to a final cloud-shaped node asking 'Is scalability and performance for large datasets a priority?'.  A 'YES' answer connects back to the 'Offset pagination' node, while a 'NO' answer also connects to the 'Offset pagination' node, completing the decision tree.  Arrows indicate the flow of decision-making based on the answers to each question.  The final choice of pagination method depends on the answers to these three questions regarding dataset characteristics and performance requirements.

⚠️ Disclaimer!

While this decision tree and comparison table provide structured guidance for selecting storage options, they intentionally simplify complex considerations.

Real-world implementations often involve additional factors such as backend architecture constraints, existing API patterns, and specific performance characteristics of your data. Use these tools to frame your interview discussion, but be prepared to acknowledge the nuances that might influence your final decision.

πŸ” Industry insights:

Slack transitioned from offset-based to cursor-based pagination to efficiently handle large datasets and ensure consistent data retrieval [70].

Discord uses virtualization for infinite lists to handle pagination smoothly. The result was a 14% drop in memory usage on startup for heavy users and faster load times [71].

Shopify moved to relative cursor pagination using a last-seen item ID to fetch the next page [72].

Performance

Performance optimization forms a critical pillar of mobile app quality that directly impacts user satisfaction, retention, and commercial success. As apps grow in complexity and user expectations rise, maintaining responsive experiences becomes increasingly challenging, yet more essential than ever.

🎯 When to bring it up

Performance considerations show our technical depth in creating responsive, efficient applications. We can mention this when discussing data-intensive features, animations, background processing, or systems that need to work on lower-end devices. This topic is universally relevant but becomes critical when designing apps that handle large datasets, complex real-time requirements, or need to operate in challenging network environments.

Poor performance can seriously impact user retention, Google even downranks underperforming apps in the Play Store [73]. Users commonly uninstall apps that suffer from crashes, excessive data usage, battery drain, and UI jank [74].

Holistic performance approach

Effective performance optimization requires a systematic approach that spans the entire app architecture:

  • UI responsiveness ensures the app feels immediate and fluid. The primary thread should remain free for user interactions by offloading heavy operations to background threads.
  • Network efficiency minimizes both latency and data consumption.
  • Memory management prevents crashes and background crashes while enabling smooth multitasking experiences.
  • Battery optimization extends effective usage time by minimizing power-intensive operations. Location services, continuous network activity, and wake locks are particularly costly for battery life.

Measurement-driven optimization

Performance optimization must be grounded in empirical data rather than assumptions or theoretical bottlenecks:

  • Baseline metrics through comprehensive performance testing provides objective data against which to measure improvements.
  • Continuous monitoring via production analytics reveals how your application performs in real-world conditions across diverse devices, networks, and usage patterns.
  • Targeted instrumentation helps identify specific bottlenecks in critical paths.
  • Incremental validation after each optimization confirms actual improvements and helps avoid regressions. Small, focused changes with measurable outcomes are preferable to large, speculative refactorings.

During MSD interviews, pay special attention to performance implications when discussing background components. Whether we're designing pre-fetching services or location-based features, we should be prepared to explain how we'll optimize their resource usage to maintain a smooth user experience. The best designs balance functionality with efficient resource consumption.

πŸ” Industry insights:

Instagram improves UX by prefetching data in the background, ensuring seamless content delivery even under poor network conditions [75]. Also, they launched features such as Data Saver mode to let users on limited plans control data usage [76].

Robinhood implemented Server-Driven UI to speed up feature delivery [77].

Spotify implemented batched metadata downloads stored on the device's disk, along with pre-computed sorting to reduce startup and view-load times [78].

Lyft achieved a 21% reduction in ANRs by optimizing disk read operations and implementing memory caching strategies [79].

Uber developed a sophisticated failover handling mechanism within its mobile networking infrastructure to ensure reliable and low-latency communication for its apps [80].

Feature development

While solid architecture creates a strong foundation for our app, thoughtful feature deployment strategies ensure users actually experience our innovations smoothly. This section explores practical approaches to releasing and managing features at scale.

While these topics may not be central to every interview question, they demonstrate valuable perspectives that can set candidates apart when discussing how they'd implement a large-scale mobile solution. Interviewers are often looking for candidates who understand not just how to build features, but how to deliver them safely to users. Being prepared to discuss these operational aspects shows we've thought beyond pure implementation to consider the entire product lifecycle.

Gradual release strategy

When deploying new app versions or significant features, a gradual rollout approach helps identify and address potential issues before they affect your entire user base. We can think of it as a safety net, we can spot and fix problems while they're still manageable, rather than having them impact all our users at once.

🎯 When to bring it up

Discussing gradual release strategies demonstrates your understanding of risk management in mobile development. We can mention this when explaining how we'd roll out complex features or significant architectural changes that could potentially impact user experience. It's particularly relevant for apps with large user bases or when designing features that might strain backend resources if released to everyone simultaneously.

Both major app stores support gradual rollouts, though they implement the concept differently:

  • Google Play Store offers staged rollouts [81] that provide granular control over both timing and audience percentage. You can start with as little as 1% of your user base and incrementally increase availability as confidence grows.
  • Apple's App Store provides phased releases [82] on a fixed 7-day schedule with predetermined daily user percentages. While less flexible than Google's approach, this system still provides valuable protection against widespread issues.

A crucial difference between platforms concerns rollback capabilities. If serious problems emerge, Google Play allows developers to halt distribution and revert affected users to the previous version. Apple's platform currently lacks this safety feature; once an update is delivered to a user, they remain on that version until a new update is released and installed.

If we need even more controlled testing before a wide release, both platforms provide additional options:

  • Beta programs (TestFlight for iOS [83], Play Store Beta testing for Android) allow you to distribute builds to registered testers outside your organization.
  • Internal testing tracks enable team members to validate release candidates using the same distribution channels as production builds.
  • Employee dogfooding programs formalize the practice of having team members use pre-release versions in their daily lives to catch usability issues and edge cases.

When implementing a gradual release strategy, focus on:

  • Monitoring capability: Ensure you have robust analytics and crash reporting configured to quickly identify problems during the staged rollout.
  • Severity thresholds: Define clear metrics that would trigger a rollout pause or rollback (e.g., crash rate increases above 1%).

πŸ” Industry insights:

Notion's mobile team has taken an approach of weekly releases on both iOS and Android [18].

Force upgrading

At times, mobile applications need to sunset older versions to ensure security, maintain performance, or deliver critical functionality improvements. Force upgrading is a strategic technique that prompts users on outdated app versions to update before they can continue using the app. This approach balances user experience with technical and business necessities.

🎯 When to bring it up

Force upgrading shows our awareness of maintaining app stability and security across our user base. We can mention this when discussing API versioning strategies, security-critical updates, or scenarios where supporting multiple client versions would significantly increase backend complexity. It's especially relevant when the system design includes breaking changes that would make older app versions incompatible with new backend implementations.

Force upgrades should be implemented with clear justification, as they create friction in the user experience. The most compelling reasons to implement this pattern include:

  1. Backend compatibility changes: When significant API changes or deprecations occur, maintaining backward compatibility with all prior app versions becomes increasingly costly and complex. After verifying the new backend works reliably, you can retire old APIs and require affected users to upgrade.
  2. Security vulnerabilities: When critical security flaws are discovered in older app versions, upgrading users becomes a priority to protect both their data and your systems. In highly regulated industries, security-driven force upgrades may even be a compliance requirement.
  3. Regulatory compliance: New regulations, particularly in healthcare, finance, or privacy domains, may introduce requirements that older app versions cannot satisfy. In these cases, force upgrades help maintain legal compliance across your user base.
  4. Analytics consistency: As your analytics strategy evolves, fragmenting your user base across multiple app versions can compromise data integrity and decision-making. Consolidating users on newer versions could create a more consistent analytics foundation.

Implementation approaches

Force upgrading exists on a spectrum of enforcement, with varying levels of urgency:

  • Hard upgrades completely prevent app usage until the user updates. This approach is appropriate for critical security vulnerabilities or major backend incompatibilities. The UI typically consists of a modal screen explaining the need to upgrade, with a prominent call-to-action that directs users to their platform's app store.
  • Soft upgrades encourage updating while still allowing limited app functionality. This approach balances user experience with technical requirements. Users typically see a dismissible prompt when launching the app, with persistent reminders during their session. While less disruptive, this approach takes longer to migrate users to newer versions.
  • Tiered grace periods offer a compromise by starting with soft prompts that gradually transition to hard requirements after a defined timeframe. This gives users adequate warning while ensuring eventual compliance.

Technical implementation

The standard implementation follows a straightforward flow:

  1. During app initialization, the client sends its version information to a dedicated version-checking endpoint.
  2. The backend evaluates this version against current policies.
  3. Based on this evaluation, the backend returns a response indicating whether an upgrade is required and the type of upgrade (hard or soft).
  4. The client presents the appropriate UI based on the response, either blocking further access or displaying dismissible prompts.

Practical considerations

When implementing force upgrading, several practical factors require attention:

  • Network conditions: Users in areas with poor connectivity may struggle to download updates. Consider implementing offline grace periods that allow temporary access even when updates are required.
  • Device constraints: Some users may have older devices that cannot support your newest app version.
  • Staged enforcement: For non-critical updates, consider rolling out the force upgrade requirement gradually, similar to how you might roll out a new app version. This prevents overwhelming app store infrastructure and support channels.

By thoughtfully implementing force upgrading capabilities, we maintain greater control over our app ecosystem while ensuring users benefit from important improvements and protections. When used well, this approach helps both users and development teams by reducing fragmentation and technical debt.

πŸ” Industry insights:

Uber implemented a force-upgrade process to help manage the challenges of scaling their mobile app infrastructure [84].

Feature flags, remote config, and A/B testing

Modern mobile apps need to evolve rapidly while maintaining stability, a challenge that calls for dynamic control systems. Feature flags, remote configuration, and A/B testing form a powerful toolkit that enables controlled experimentation and deployment without requiring app store updates.

🎯 When to bring it up

This topic demonstrates our knowledge of modern, data-driven development practices. We can mention it when discussing how to validate design decisions with real users, implement gradual feature rollouts, or manage complex features across different user segments. It's particularly valuable when explaining how our system could support experimentation without requiring constant app updates through the app stores.

At its core, this approach separates feature availability from code deployment. When a user launches the app, it connects to backend services to determine which features should be activated based on various criteria:

  • User characteristics (e.g., demographics, subscription tier, or usage patterns).
  • Device capabilities (e.g., hardware specifications or operating system version).
  • Environmental factors (e.g., geographic location, or network conditions).
  • Experimental groups (e.g., random assignment or user cohorts).

Product teams typically manage these rules through dedicated management interfaces (e.g., Content Management System (CMS)) that provide visualization and control without requiring engineering intervention for each update.

Feature flags enable effective A/B testing by showing different variants to different users. The backend controls not just whether a feature is enabled, but which version users see. By tracking analytics for each variant, teams can measure effectiveness and make data-driven decisions.

Strategic applications

This toolkit serves multiple essential functions in mobile apps:

  • Risk management: By limiting new functionality to a subset of users initially, we can contain the impact of potential issues.
  • Staged rollouts: Features can be introduced gradually to increasing percentages of users, allowing us to monitor performance impacts, server load, and user reception before committing to full deployment.
  • Performance optimization: A/B testing provides empirical data about which implementations deliver better performance metrics, helping teams make evidence-based decisions.
  • Infrastructure updates: When migrating to new backend systems or third-party services, feature flags allow us to control traffic flow between old and new implementations, enabling seamless transitions.

Implementation considerations

While powerful, feature flags and A/B testing require careful management:

  • Code complexity. Each flag adds branching logic and potential interactions. This complexity grows as we add more flags.
  • Technical debt. Flags that remain after features are fully launched create unnecessary complexity. Regular cleanup is essential.
  • Testing challenges. Testing all possible flag combinations becomes increasingly difficult as the system grows.
  • UX understanding. Multiple active flags make it harder to understand exactly what different users experience.

When properly implemented, feature flags, remote configuration, and A/B testing transform how mobile applications evolve, enabling data-driven development, controlled experimentation, and rapid response to emerging issues or opportunities. This approach has become standard practice at companies operating at scale, where the ability to move quickly while maintaining stability creates significant competitive advantage.

πŸ” Industry insights:

Netflix is famously data-driven, running A/B tests on nearly every product change. They built an internal Experimentation Platform (codename β€œABlaze”) that lets any team define an experiment, target a subset of users, and track metrics [85].

Duolingo attributes a lot of its product success to extensive A/B testing. On the engineering side, this means building an experimentation framework into the app [86].

Observability, analytics, monitoring, and reporting

Mobile apps require comprehensive visibility into their performance, user behavior, and error states. A robust measurement strategy helps engineering teams deliver stable experiences while providing product teams with insights to drive decision-making.

🎯 When to bring it up

Discussing these elements shows we understand that successful systems require ongoing measurement and optimization. We can mention these when explaining how we'd validate that your design meets performance requirements, identify potential bottlenecks, or track user engagement with key features. This topic is especially relevant when designing systems that need to scale significantly or when proposing complex architecture that would benefit from robust monitoring.

A complete visibility system addresses multiple dimensions of app performance and usage:

  • Technical performance monitoring tracks system-level metrics such as response times, resource utilization, and error rates. This data helps identify bottlenecks, optimize code paths, and ensure the app remains responsive across varying device capabilities and network conditions.
  • User behavior analytics captures how people interact with the application, including navigation patterns, feature usage, and conversion metrics. These insights inform product decisions by revealing which aspects of the app deliver value and where friction points exist.
  • Error tracking identifies crashes, exceptions, and non-fatal errors that impact user experience.
  • Business metrics reporting connects technical performance to commercial outcomes by measuring key performance indicators that align with organizational goals, such as engagement, retention, and monetization metrics.

Error classification and management

When implementing error tracking, it's crucial to differentiate between different types of failures:

  • Fatal errors cause the app to crash completely.
  • Non-fatal errors allow the app to continue running but may prevent specific features from functioning correctly.
  • Degraded states occur when the app continues functioning but with reduced performance or capability.

Each error type requires appropriate instrumentation to ensure visibility. While crash reporting frameworks capture fatal errors automatically, non-fatal errors and degraded states require explicit instrumentation in the codebase.

Architectural considerations

From an architecture perspective, analytics and monitoring systems should be designed for minimal performance impact while delivering maximum insight:

  • Sampling strategies reduce data volume while maintaining statistical validity by collecting comprehensive data from a representative subset of users or sessions rather than exhaustively tracking everything.
  • Efficient data collection typically involves batching events, compressing payloads, and being selective about collection frequency.
  • Robust offline handling ensures that analytics data isn't lost when the device lacks connectivity.
  • Contextual information automatically adds relevant metadata to events, such as device information, app version, and user segments. This context makes analysis more powerful by enabling multidimensional filtering and segmentation.

In high-level architecture diagrams, analytics components typically appear as cross-cutting concerns that integrate with multiple subsystems. Figure 15 shows how the Analytics repository is often added to the app high-level architecture diagram.

Image represents a client-side data layer architecture employing dependency injection.  The diagram is enclosed within a rounded rectangle labeled 'Client,' containing a light-grey rectangular area at the top labeled 'Dependency Injection.' Below this, the 'Data layer' is depicted.  Two main components are shown: an 'Analytics Repository' and an 'X Repository.' The 'Analytics Repository' receives data from two sources: 'Analytics Local DataSource' and 'Analytics Remote DataSource,' indicated by arrows pointing towards it.  Similarly, the 'X Repository' receives data from an 'X Local DataSource,' also shown with an arrow.  Arrows pointing upwards from both repositories connect to the 'Dependency Injection' layer, illustrating how these repositories are injected into the client application.  The overall structure showcases how the client accesses data through repositories, which in turn abstract away the specifics of data sources, enabling flexible data access and maintainability through dependency injection.

πŸ” Industry insights:

Pinterest built an end-to-end JSON logging system for their iOS and Android clients. The idea was to make logging β€œschemaless” and easy [87].

Slack developed a bespoke tracing system, SlackTrace, to monitor the flow of notifications across their infrastructure, aiming to standardize data formats and simplify debugging [88].

Signal takes a unique stance on analytics: it collects virtually none in order to protect user privacy. The Signal app does not have the typical mobile analytics or crash-reporting SDKs that most apps use [89].

Localization

Adapting your application for global audiences involves much more than simply translating text. Comprehensive localization transforms every aspect of your app to feel native to users across different regions, languages, and cultural contexts. When implemented thoughtfully, localization significantly expands your potential user base while improving engagement and retention in international markets.

🎯 When to bring it up

Localization demonstrates our awareness of developing for global audiences. We can mention it when designing apps intended for international markets or discussing text-heavy interfaces that would need translation. It's particularly relevant when explaining how our architecture handles variable content lengths, right-to-left languages, or region-specific features that might affect our core design decisions.

Beyond text translation

While text translation forms the foundation of localization, a complete strategy addresses multiple dimensions of cultural adaptation:

  • Number and currency formatting varies dramatically across regions.
  • Date and time representation follows different conventions worldwide.
  • Pluralization rules differ across languages, often in complex ways that go beyond simply adding 's' for plurals.
  • Text direction requires special consideration for right-to-left (RTL) languages such as Arabic, Hebrew, and Persian.
  • Visual and cultural elements including colors, symbols, and imagery may carry different meanings or connotations across cultures.

While Android and iOS share similar localization concepts, they handle implementation differently: iOS offers built-in support for exporting translations [90], whereas Android implements localization through its resource system [91].

Localization challenges

Implementing comprehensive localization presents several technical challenges:

  • Adaptable UI design must accommodate text expansion and contraction across languages. German and Finnish translations typically require 30-40% more space than English, while Chinese often uses significantly less.
  • Dynamic language switching allows users to change their preferred language without restarting the application. This requires careful management of resource loading and UI refreshing to ensure a smooth transition between languages.
  • Resource optimization becomes critical as localization assets multiply. Each supported language increases app size, potentially affecting download conversion rates and device storage requirements.
  • Testing complexity increases exponentially with each supported language.
  • Backend API localization ensures that content delivered from the backend appears in the user's preferred language. This typically involves including language preference parameters in API requests and designing backend systems to return appropriately localized responses.

By approaching localization as a fundamental aspect of our architecture rather than an afterthought, our apps can deliver truly global reach while maintaining excellent user experiences across diverse markets and cultures.

πŸ” Industry insights:

Netflix, available in 190+ countries, built a sophisticated pipeline to ingest, translate, and serve UI strings for all their apps [92].

Netflix even uses pseudo-localization (artificially elongating text and adding accented characters) as a testing step to ensure UIs will accommodate other languages and scripts [93].

Privacy

Privacy considerations have evolved from optional features to essential requirements in mobile apps. As regulations tighten globally and user awareness grows, implementing robust privacy measures is crucial for both legal compliance and maintaining user trust. A thoughtful privacy strategy must address regulatory requirements, technical safeguards, and transparent user experiences.

🎯 When to bring it up

Privacy considerations show we understand the regulatory and ethical dimensions of mobile development. We can mention this when discussing features that handle sensitive user data, systems that require cross-device synchronization, or designs targeting regions with strict privacy laws like Europe (GDPR) or California (CCPA). It's especially important when explaining data storage decisions, authentication mechanisms, or user consent flows.

Mobile applications often operate across multiple jurisdictions, each with unique privacy frameworks that dictate how user data must be handled:

  • The European Union's General Data Protection Regulation (GDPR) [94] established a comprehensive framework that fundamentally changed how organizations approach data privacy.
  • The UK follows the Data Protection Act (DPA) [95] for data protection standards.
  • Health Insurance Portability and Accountability Act (HIPAA) [96] regulates protected health information in the United States.
  • Australian apps need to follow the Privacy Principles (APPs) [97].

The regulatory landscape continues to evolve rapidly, with new privacy laws emerging regularly around the world. Staying current with these developments is essential for maintaining compliance across global markets.

Technical implementation strategies

Implementing privacy protection requires a layered approach that secures user data throughout its lifecycle:

  • Data minimization forms the foundation of modern privacy practice. Collect only the information necessary for your app's core functionality.
  • Secure data transit protects information as it moves between client and server.
  • Protected storage safeguards data at rest on both device and server. For example, we should encrypt sensitive information using industry-standard algorithms such as AES-256, with proper key management practices to maintain security.
  • Access controls ensure that users can only access data they're authorized to view.
  • Data anonymization and pseudonymization reduce privacy risks by separating personal identifiers from usage data.
  • Security testing conducts regular penetration testing to identify vulnerabilities, and test both client and server-side security measures.

Beyond these regional requirements, it's essential to stay current with mobile security best practices. The OWASP [98] mobile security risks list, updated annually, provides a comprehensive overview of emerging threats and recommended protections.

πŸ” Industry insights:

Meta has been exploring privacy-preserving machine learning to improve products without compromising user data. One cutting-edge approach is Federated Learning with Differential Privacy (FL-DP) [99].

Accessibility

Creating truly inclusive mobile apps means ensuring everyone can use your product effectively, regardless of their abilities or how they interact with their devices. Accessibility isn't just about compliance with regulations, it's about expanding our application's reach and building products that work well for all users.

🎯 When to bring it up

Discussing accessibility demonstrates our commitment to inclusive design principles. We can mention this when explaining interface design decisions, navigation patterns, or interaction models that need to work for all users. It's particularly relevant when designing public-facing applications, especially those subject to legal accessibility requirements such as government, education, or healthcare apps.

Leading platforms provide comprehensive accessibility guidelines that help you build more inclusive apps: Web Content Accessibility Guidelines (WCAG) [100], Apple's Human Interface Guidelines for iOS [101], and Google's Material Design Accessibility Guidelines for Android [102].

Platform implementation techniques

Both Android and iOS provide comprehensive accessibility frameworks that help developers create inclusive apps:

  • Screen readers (VoiceOver on iOS, Talkback on Android) support enables users with visual impairments to navigate applications through audio feedback.
  • Dynamic text sizing allows users to adjust text display to their needs.
  • Color and contrast considerations help users with color blindness or low vision.
  • Touch targets should be sufficiently large to accommodate users with motor control challenges.

Testing and validation

Comprehensive accessibility testing combines automated checks with human evaluation:

  • Automated testing tools such as Accessibility Scanner (Android) and Accessibility Inspector (iOS) identify common issues such as missing labels, insufficient contrast, and small touch targets.
  • Manual testing with assistive technologies provides deeper insight into how real users might experience your application. Navigating the entire app using only VoiceOver or TalkBack reveals usability issues that automated tools might miss.
  • User testing with people with disabilities delivers the most valuable feedback.

By integrating accessibility considerations throughout your development process rather than treating them as an afterthought, you build more robust applications that work better for everyone.

πŸ” Industry insights:

The BBC has led by example with comprehensive Mobile Accessibility Guidelines [103].

Push notifications

Push notifications enable apps to reach users even when they're not actively using the application. This capability is invaluable for sharing important updates, delivering time-sensitive information, or re-engaging users with your app. While powerful, push notifications have inherent limitations that you should understand when incorporating them into your mobile system design.

πŸ› οΈ Platform implementation details

Both iOS and Android implement push notifications through platform-specific services: iOS uses Apple Push Notification Service (APNs) [104], while Android relies on Firebase Cloud Messaging (FCM) [105].

These services act as intermediaries between your backend and user devices.

The typical flow for push notifications follows these steps:

  1. Our app registers with the platform's notification service during installation.
  2. The service provides a unique device token to identify that installation.
  3. Our app sends this token to the backend.
  4. When the backend needs to notify a user, it sends the message to the appropriate platform service.
  5. The platform service attempts to deliver the notification to the user's device.

🎯 When to bring it up

Push notifications demonstrate our understanding of user engagement strategies beyond the app itself. If the interviewer is interested in this topic, they usually add it as a functional requirement. If that's not the case, you can mention push notifications when discussing systems that deliver time-sensitive information, require user re-engagement, or include asynchronous interactions. It's particularly relevant when designing messaging apps, social platforms, or any application where users benefit from being notified about events even when they're not actively using the app.

Push notifications delivery challenges

Several factors can prevent successful notification delivery:

  • Device constraints
    • Notifications queue when devices are offline or in airplane mode.
    • Platform servers eventually drop queued notifications that can't be delivered.
    • Battery-saving modes may delay or block notification delivery.
  • Platform limitations
    • Both APNs and FCM implement throttling mechanisms to protect system resources.
    • Android manufacturer customizations can affect background process handling differently.
    • Platform-specific payload size limits restrict the amount of data you can include.
  • User controls
    • Users can disable notifications through system settings.
    • They may deny notification permissions during the first app launch.
    • Do Not Disturb settings and focus modes can temporarily block delivery.

Given these limitations, push notifications should serve as a supplementary communication channel rather than your primary method for delivering critical information. They're excellent for enhancing engagement, but your system design should include alternative mechanisms for essential updates.

Chapter 4: Design a Chat app covers push notifications in detail. Figure 16 shows the push notifications-related components in its high-level architecture diagram.

Image represents a system architecture diagram for push notifications.  The diagram shows a 'Data layer' containing a 'Messages Repository' box.  Below this is a 'Push Notifications Client' box, connected to the 'Messages Repository' via an upward-pointing arrow, indicating data flow from the client to the repository.  To the right, a 'Backend' box communicates with a 'Push Notifications Provider' box (labeled 'Third Party') via an arrow labeled 'https,' signifying an HTTPS connection.  The 'Push Notifications Client' and 'Push Notifications Provider' are connected by a line labeled 'Persistent TCP connection,' illustrating a persistent TCP connection between the client and the third-party provider.  The dashed lines around the 'Data layer' and the 'Push Notifications Provider' visually group related components.  The overall flow suggests that the client sends push notification data to the repository, the backend interacts with the third-party provider (likely for sending the notifications), and the client maintains a persistent connection with the provider for receiving notifications.

πŸ” Industry insights:

Slack extended their notification system to send broader notification types and not just direct messages and mentions [106].

Duolingo built a high-scale notification system to handle its massive user base and engagement campaigns [107].

App size

App size directly impacts both user acquisition and retention. App size influences user behavior in measurable ways. Google's research shows that each additional 6MB in APK size reduces installation conversion rates by approximately 1% [108]. This effect becomes even more pronounced in markets with limited data plans or on devices with storage constraints.

🎯 When to bring it up

App size considerations show our awareness of practical download and installation constraints. We can mention this when discussing applications targeting regions with limited bandwidth, apps containing substantial offline content, or designs requiring numerous third-party libraries. It's especially relevant when our target audience includes users with storage-constrained devices or limited data plans.

Two primary contributors typically influence app size:

  1. Third-party libraries add functionality but often come with significant size overhead. Each library should justify its footprint by providing essential capabilities that would be impractical to build in-house.
  2. Media assets such as images, videos, and audio files enhance user experience but can quickly inflate app size. When addressing asset management in your interview, discuss strategies such as:
    • Using vector graphics where appropriate to maintain quality across device resolutions.
    • Implementing on-demand asset downloading for infrequently accessed features.
    • Leveraging server-side image optimization based on device capabilities.

When discussing app size optimization in interviews, demonstrate an awareness of platform-specific approaches.

πŸ› οΈ Platform implementation details

Both Android [109] and iOS [110] offer comprehensive tools and documentation for reducing app size.

  • For Android, we can mention techniques like R8 code shrinking, resource shrinking, and App Bundles that deliver only the code and resources needed for specific device configurations. -

  • For iOS, we can highlight app thinning, on-demand resources, and Swift's whole module optimization.

By integrating thoughtful app size considerations into your mobile system design, we demonstrate awareness of the practical constraints that affect real-world app distribution and adoption.

πŸ” Industry insights:

Instagram built an Instagram Lite Android app with an initial size of just 573 KB [111].

CI/CD

Mobile apps depend heavily on automation for consistent, reliable delivery. Continuous Integration and Continuous Delivery/Deployment (CI/CD) practices streamline the testing and release process, ensuring high quality across development stages.

🎯 When to bring it up

CI/CD knowledge demonstrates our understanding of the complete development lifecycle. We can bring this up when discussing how our design would be implemented in practice, especially for complex systems requiring frequent updates or when explaining how to maintain quality across multiple platforms. It's particularly relevant when describing how your architecture supports testability or when proposing designs that would involve complex integration requirements.

Continuous Integration (CI)

Continuous Integration focuses on frequently merging code changes into a shared repository. Each time developers push changes, automated pipelines perform these essential functions:

  • Building the app to catch compilation errors early.
  • Running unit and integration tests to ensure functionality.
  • Analyzing code quality through static analysis tools.

This creates a valuable feedback loop where issues are detected immediately rather than accumulating over time. Tools such as Fastlane [112], GitHub Actions [113], or Bitrise [114] handle these automated checks, ensuring consistent quality without manual intervention.

Continuous Delivery (CD)

While CI focuses on code quality, Continuous Delivery automates the preparation and distribution of releases. Once code passes CI checks, CD pipelines handle:

  • Configuring app signing and credentials.
  • Setting environment-specific variables.
  • Generating release builds.
  • Distributing builds to testing environments.

πŸ› οΈ Platform implementation details

Mobile apps have unique CD requirements around code signing:

  • Android uses keystore files to verify app authenticity.

  • iOS requires provisioning profiles and certificates, often managed through tools such as Fastlane Match [115].

Each environment (e.g., development, QA, and production) also needs its own configuration:

  • Android uses build.gradle flavors and buildConfigField.

  • iOS uses Xcode build configurations and schemes.

Continuous Deployment

In its purest form, Continuous Deployment automatically pushes changes to production after they pass all quality checks. Unlike web or backend deployments, releases aren't instantaneously available to users because app store review processes can introduce unpredictable delays.

The key benefit of CI/CD is consistency! It removes manual steps that could introduce errors while ensuring every release follows the same proven process. Knowledge on this area shows interviewers we're not just focused on architectural excellence, but also on the practical aspects of delivering high-quality software reliably and efficiently.

πŸ” Industry insights:

Duolingo drastically improved its mobile CI/CD pipeline, cutting Android and iOS build times by 68% [116].

Pinterest's iOS team transitioned to using Bazel as their build system resulted in a 21% reduction in clean build times [117].

Security

Security forms an essential foundation for any mobile app. At its core, mobile security involves protecting user data, ensuring system integrity, and preventing unauthorized access. All while maintaining a smooth user experience.

🎯 When to bring it up

Security considerations show your awareness of protecting user data and system integrity. Mention this when discussing features that handle sensitive information, authentication workflows, or data storage decisions.

It's particularly relevant when designing finance, healthcare, or enterprise applications, or when explaining how your architecture safeguards against common mobile vulnerabilities.

Rather than diving deeply into implementation details, let's explore the main security domains that mobile devs should be familiar with. While security is a complex topic that would require specialized expertise to fully address, a general understanding of these areas will help you navigate MSD interviews effectively.

Device security

Mobile devices operate in varied and often unpredictable environments. Key considerations include:

  • iOS Jailbreak [118] / Android Root [119] detection: Identifying compromised operating systems that bypass security controls.
  • App integrity verification: Ensuring the application hasn't been tampered with.
    • On Android, the Play Integrity API [120] verifies the app's authenticity and flags modifications.
    • On iOS, App Attest [121] ensures the app runs as intended, unaltered by external interference.
  • Secure local storage: Properly handling sensitive data stored on the device.
  • Code obfuscation: Making it harder for attackers to reverse-engineer your application.
    • On Android, tools such as R8 [122] can automatically scramble your code, changing the names of classes and methods while keeping the app's functionality intact.

Network security

Protecting data in transit requires multiple security layers:

  • Transport Layer Security (TLS): Encrypting all client-server communications.
  • Certificate pinning [123]: Preventing man-in-the-middle attacks by validating server certificates.
  • API security: Defending against common web vulnerabilities like injection attacks.
  • Secure offline operations: Maintaining security even when connectivity is intermittent.

Data security

Even when stored locally, sensitive data requires protection:

  • Encryption at rest: Securing stored data using strong encryption algorithms.
  • Secure key management: Properly handling encryption keys.
  • Sensitive data handling: Minimizing exposure of PII [124] and confidential information.
  • Data minimization: Collecting and storing only essential information.

Balancing security and user experience

While robust security is essential, it shouldn't come at the expense of usability. Consider these principles:

  • Apply stricter security measures to high-risk operations, using contextual security.
  • Provide clear explanations when requesting sensitive permissions.
  • Implement security measures proportional to the sensitivity of protected data.
  • Use security measures that align with platform conventions and user expectations.

Remember that client-side security is just one layer of defense. A comprehensive security strategy requires both strong mobile protections and a well-secured backend working together.

πŸ” Industry insights:

Signal's entire system design centers on user privacy. All messages and calls are end-to-end encrypted, meaning that not even Signal's servers can read user content [125].

Supporting different devices

Building for a wide range of devices and platforms introduces unique challenges that can strongly influence your design choices.

In MSD interviews, demonstrating awareness of device fragmentation shows depth of understanding that extends beyond purely functional concerns. The ability to build solutions that work consistently across different hardware capabilities, screen sizes, and operating system versions reveals a mature perspective on mobile development challenges.

While these topics might not dominate interview discussions, they could emerge during the deep dive phase as interviewers assess how thoroughly candidates consider the practical implementation challenges of the design. Being prepared to address these concerns demonstrates both technical insight and product awareness.

Phone, tablets, and other form factors

Supporting multiple device types isn't just a nice-to-have feature, it's often a core business requirement that can significantly expand your app's reach. While phones remain the primary platform for most apps, tablets, foldables, wearables, and even TVs offer unique opportunities to engage users in different contexts.

🎯 When to bring it up

Discussing multi-form factor support shows our ability to design adaptable interfaces and architecture. We can mention this when explaining how our system would accommodate different screen sizes, input methods, or device capabilities. It's particularly relevant when designing applications that need to provide consistent experiences across phones, tablets, foldables, or other specialized hardware.

The key to a successful multi-form factor strategy lies in strategic modularization. The app's core business logic and data layer can generally remain consistent across devices, as these components operate independently of screen size or input methods. It's primarily the UI and navigation layers that require device-specific adaptations to deliver optimal user experiences.

Consider how different form factors change user expectations and interaction patterns:

  • Tablets benefit from larger screens that can display more content simultaneously. A tablet interface might show both a list of messages and their content side-by-side, while a phone would present these as sequential screens.
  • Foldables present a hybrid challenge, functioning as both phones and tablets depending on their state. Well-designed apps adapt dynamically as the device transforms between folded and unfolded states.
  • Wearables demand extremely focused interfaces with glanceable information and simplified interactions due to their limited screen real estate.
  • TVs require interfaces that work well with remote controls rather than touch, with larger visual elements optimized for viewing from a distance.

Modern UI frameworks such as Jetpack Compose for Android and SwiftUI for iOS have made developing adaptive interfaces substantially easier. These declarative UI systems allow developers to create responsive layouts that automatically adjust to different screen dimensions and orientations.

Beyond the UI layer, certain data layer optimizations may be necessary for different form factors. For example:

  • Adjusting caching strategies based on available device memory.
  • Implementing form factor-specific image resolutions to balance quality with performance.
  • Tailoring prefetching algorithms based on expected connectivity and usage patterns.

For more information about building for different form factors, check out Android [126] and Apple [127] documentation.

πŸ” Industry insights:

Spotify adapts their app to different screen sizes and resolutions, ensuring that users have a seamless experience regardless of the form factor [128].

Google Photos enhanced user engagement on large screens by implementing responsive layouts tailored for tablets, foldables, and ChromeOS devices [129].

Microsoft enhanced the user experience of Outlook, Teams, and Office on large-screen Android devices by optimizing layouts and incorporating multi-window and multi-instance capabilities, resulting in increased active users and retention [130].

OS and SDK versions

Supporting multiple OS versions poses a challenge that involves balancing user reach with development complexity. Each decision in this space involves trade-offs between backward compatibility and leveraging the latest platform capabilities.

🎯 When to bring it up

OS and SDK version support demonstrates our understanding of platform evolution and backward compatibility challenges. We can mention this when discussing feature implementation strategies that might depend on newer platform capabilities, security considerations across different OS versions, or approaches to maintain functionality on older devices. It's especially relevant when targeting platforms with fragmented version adoption such as Android or when designing features that leverage cutting-edge platform capabilities.

Minimum OS version support

The minimum OS version establishes which devices can install and run the app. This decision directly impacts the potential user base: choosing a higher minimum version may exclude users with older devices, while supporting very old versions can significantly increase development and testing complexity.

When selecting a minimum version, we should consider not just market share statistics but also the specific capabilities our app requires. For example, features such as augmented reality, or certain security protocols may only be available from specific OS versions forward.

This decision should be data-driven, weighing several factors:

  • Current OS distribution among your target market.
  • Critical platform capabilities needed for core functionality.
  • Testing resources available to validate across multiple versions.
  • Security and performance considerations of older OS versions.
Target OS version

While the minimum version defines backward compatibility, the target version determines which newer platform features the app can fully utilize. This is typically set to the most recent stable OS release.

Regularly updating your target version allows us to:

  • Implement performance improvements offered by newer SDKs.
  • Access enhanced security features and privacy protections.
  • Utilize modern APIs that may replace deprecated functionality.
  • Offer users the best possible experience on current devices.

The gap between the minimum and target versions determines how much version-specific code your app will need to maintain. Wider gaps could potentially require more conditional logic and compatibility layers, increasing complexity and potential for runtime issues.

API deprecation management

Platform evolution inevitably leads to API deprecation as operating systems improve and replace older functionality. Proactively addressing deprecation warnings prevents technical debt from accumulating and reduces the risk of critical failures when APIs are eventually removed.

Effective deprecation management involves:

  • Regularly reviewing compiler warnings and deprecation notices.
  • Creating a prioritized migration plan for affected code.
  • Testing thoroughly on both older and newer OS versions.
  • Maintaining clear documentation of version-specific implementations.
Strategic testing approach

Testing across supported OS versions requires a systematic approach to ensure consistent behavior. For example, rather than testing every feature on every version, we could focus on:

  • Full coverage testing on minimum, target, and one middle version.
  • Smoke testing on all supported versions.
  • Deeper testing of features that rely on version-specific implementations.
  • Prioritizing test scenarios based on real-world usage analytics.

πŸ“Œ Remember!

OS update adoption rates vary significantly between platforms. iOS users typically upgrade more quickly than Android users, who may be constrained by manufacturer limitations or device age.

The OS version strategy should evolve over time. We should regularly reassess minimum version requirements as user analytics shift, using each major app update as an opportunity to potentially drop support for older versions that represent a diminishing percentage of the user base.

Resources

[1] The Clean Architecture https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
[2] Guide to app architecture: https://developer.android.com/topic/architecture
[3] Unidirectional Data Flow: https://en.wikipedia.org/wiki/Unidirectional\_Data\_Flow\_(computer\_science)
[4] The repository pattern: https://www.geeksforgeeks.org/repository-design-pattern/
[5] Model View Controller architecture: https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/MVC.html
[6] Model View ViewModel architecture: https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel
[7] VIPER architecture: https://www.techtarget.com/whatis/definition/VIPER
[8] Uber's RIBs https://github.com/uber/RIBs/wiki
[9] Airbnb's Mavericks https://airbnb.io/mavericks
[10] Slack's Circuit architecture library: https://slackhq.github.io/circuit/
[11]: Separation of concerns: https://en.wikipedia.org/wiki/Separation\_of\_concerns
[12] Reactive programming: https://en.wikipedia.org/wiki/Reactive\_programming
[13]: Single Source of Truth: https://en.wikipedia.org/wiki/Single\_source\_of\_truth
[14] Dependency Injection: https://en.wikipedia.org/wiki/Dependency\_injection
[15]: Service Locator pattern https://en.wikipedia.org/wiki/Service\_locator\_pattern
[16] Duolingo improvements to architecture https://android-developers.googleblog.com/2021/08/android-app-excellence-duolingo.html
[17] Uber's plugin system and RIBs: https://www.uber.com/blog/plugins
[18] Notion architecture insights: https://newsletter.pragmaticengineer.com/p/notion-going-native-on-ios-and-android
[19] UI layer: https://developer.android.com/topic/architecture/ui-layer
[20] Data layer: https://developer.android.com/topic/architecture/data-layer
[21] Domain layer: https://developer.android.com/topic/architecture/domain-layer
[22] Deep links: https://en.wikipedia.org/wiki/Mobile\_deep\_linking
[23] Guide to Android app modularization: https://developer.android.com/topic/modularization
[24] Airbnb iOS modularization story: https://medium.com/airbnb-engineering/designing-for-productivity-in-a-large-scale-ios-application-9376a430a0bf
[25] Robinhood modularization story: https://medium.com/robinhood-engineering/breaking-up-the-app-module-monolith-the-story-of-robinhoods-android-app-707fb993a50c
[26] Google's fundamentals of testing Android apps: https://developer.android.com/training/testing/fundamentals
[27] The robot pattern: https://jakewharton.com/testing-robots/
[28] OHHTTPStubs: https://github.com/AliSoftware/OHHTTPStubs
[29] MockWebServer: https://github.com/square/okhttp/tree/master/mockwebserver
[30] Slack's Android automation testing series: https://slack.engineering/android-ui-automation-part-1-building-trust/
[31] Bitrise's mobile testing pyramid: https://bitrise.io/blog/post/mastering-the-mobile-testing-pyramid
[32] Netflix's mobile testing strategy: https://medium.com/androiddevelopers/netflix-app-testing-at-scale-eb4ef6b40124
[33] Dropbox's mobile testing strategy: https://medium.com/androiddevelopers/how-dropbox-leverages-testing-to-maintain-high-level-of-trust-at-scale-b7b1bdf6161e
[34] Android's SharedPreferences: https://developer.android.com/training/data-storage/shared-preferences
[35] Android's DataStore: https://developer.android.com/topic/libraries/architecture/datastore
[36] iOS' UserDefaults: https://developer.apple.com/documentation/foundation/userdefaults
[37] iOS' Information Property List: https://developer.apple.com/documentation/bundleresources/information-property-list
[38] Android Keystore system: https://developer.android.com/privacy-and-security/keystore
[39] iOS Keychain services: https://developer.apple.com/documentation/security/keychain-services
[40] Android's Jetpack Room: https://developer.android.com/training/data-storage/room
[41] iOS' Core Data: https://developer.apple.com/documentation/coredata/
[42] Realm non-relational database: https://www.mongodb.com/docs/atlas/device-sdks/
[43] Firebase Realtime Database: https://firebase.google.com/docs/database
[44] iOS' FileManager: https://developer.apple.com/documentation/foundation/filemanager
[45] Android's data and file storage overview guide: https://developer.android.com/training/data-storage
[46] Netflix usage of the Realm database: https://netflixtechblog.com/netflix-downloads-on-android-d79db40f1732
[47] Trello's offline-first approach: https://www.atlassian.com/blog/atlassian-engineering/sync-architecture
[48] iOS' available File directories: https://developer.apple.com/documentation/foundation/filemanager/searchpathdirectory
[49] iOS' fetching assets: https://developer.apple.com/documentation/photokit/fetching-assets
[50] iOS' UIDocumentPickerViewController: https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller
[51] Android's File APIs: https://developer.android.com/training/data-storage/shared/documents-files
[52] Android's Content Providers: https://developer.android.com/guide/topics/providers/content-provider-basics
[53] Content Providers with special permissions: https://developer.android.com/guide/topics/providers/content-provider-basics#Permissions
[54] iOS' App Groups: https://developer.apple.com/documentation/xcode/configuring-app-groups
[55] Configuring App Groups on iOS https://developer.apple.com/documentation/Foundation/UserDefaults/init(suiteName:)
[56] HTTP/1.1 RFC: https://www.rfc-editor.org/rfc/rfc7231
[57] What is REST: https://www.codecademy.com/article/what-is-rest
[58] CRUD operations: https://en.wikipedia.org/wiki/Create,\_read,\_update\_and\_delete
[59] Lyft usage of Protocol buffers and gRPC: https://eng.lyft.com/lyfts-journey-through-mobile-networking-d8e13c938166
[60] Reddit usage of GraphQL: https://blog.bytebytego.com/p/reddits-architecture-the-evolutionary
[61] Airbnb usage of GraphQL: https://medium.com/airbnb-engineering/how-airbnb-is-moving-10x-faster-at-scale-with-graphql-and-apollo-aa4ec92d69e2
[62] Snapchat QUIC: https://eng.snap.com/quic-at-snap
[63] JSON Web Token (JWTs): https://en.wikipedia.org/wiki/JSON\_Web\_Token
[64] Android Keystore system: https://developer.android.com/privacy-and-security/keystore
[65] iOS Keychain services: https://developer.apple.com/documentation/security/keychain-services
[66] Uber and passkeys: https://www.uber.com/blog/use-passkeys-wherever-you-sign-in-to-uber
[67] Robinhood's trusted devices: https://robinhood.com/gb/en/support/articles/trusted-devices/
[68] Cursor-based pagination at Meta: https://developers.facebook.com/docs/graph-api/reference/page/feed/
[69] Cursor-based pagination at Slack: https://api.slack.com/apis/pagination
[70] Slack's migration to cursor-based pagination: https://slack.engineering/evolving-api-pagination-at-slack/
[71] Discord and its virtualization for infinite lists:
https://discord.com/blog/supercharging-discord-mobile-our-journey-to-a-faster-app
[72] Shopify's relative cursor implementation: https://shopify.engineering/pagination-relative-cursors
[73] Google downranks poorly performant apps in the Play Store: https://www.theverge.com/2017/8/3/16078644/android-apps-play-store-ranking-machine-learning
[74] Why users uninstall apps: https://appsamurai.com/blog/10-reasons-for-why-people-uninstall-your-mobile-app/
[75] Instagram's prefetching solution: https://instagram-engineering.com/improving-performance-with-background-data-prefetching-b191acb39898
[76] Instagram's Data Saver mode:
https://instagram-engineering.com/instagram-data-saver-mode-ffb01fd5a6bd
[77] Robinhood's server-driven UI approach: https://newsroom.aboutrobinhood.com/how-server-driven-ui-is-helping-frontend-engineers-scale-impact
[78] Spotify client architecture modernization with batched metadata downloads:
https://engineering.atspotify.com/2020/05/spotify-modernizes-client-side-architecture-to-accelerate-service-on-all-devices
[79] Lyft's reduction of ANRs:
https://eng.lyft.com/prioritizing-app-stability-cb7b4cf65d50
[80] Uber's failover handling mechanism: https://eng.uber.com/eng-failover-handling/
[81] Google Play Store's staged rollouts: https://support.google.com/googleplay/android-developer/answer/6346149
[82] Apple's App Store phased updates https://developer.apple.com/help/app-store-connect/update-your-app/release-a-version-update-in-phases/
[83] Apple's TestFlight builds: https://developer.apple.com/testflight/
[84] Uber's approach to force-upgrade:
https://bitrise.io/blog/post/q-and-a-on-building-apps-at-scale-part-1
[85] Netflix's A/B testing approach:
https://netflixtechblog.com/its-all-a-bout-testing-the-netflix-experimentation-platform-4e1ca458c15
[86] Duolingo's approach to A/B testing: https://blog.duolingo.com/unique-engineering-problems
[87] Pinterest's logging schemaless solution: https://medium.com/pinterest-engineering/build-an-end-to-end-json-logging-system-for-clients-apps-58ad008a1ce9
[88] Slack's push notifications tracing:
https://slack.engineering/tracing-notifications
[89] Signal's unique stance on no analytics: https://signal.org/blog/signal-is-for-everyone
[90] Apple's export localizations for translation: https://developer.apple.com/documentation/xcode/exporting-localizations/
[91] Android's guide to localization: https://developer.android.com/guide/topics/resources/localization
[92] Netflix's localization approach: https://netflixtechblog.medium.com/now-streaming-in-your-language-the-technology-behind-netflixs-global-interface-e0c732c69b16
[93] Netflix's pseudo-localization for testing purposes:
https://netflixtechblog.com/pseudo-localization-netflix-12fff76fbcbe
[94] General Data Protection Regulation (GDPR): https://gdpr-info.eu/
[95] Data Protection Act (DPA) https://www.gov.uk/data-protection
[96] Health Insurance Portability and Accountability Act (HIPAA): https://www.cdc.gov/phlp/publications/topic/hipaa.html
[97] Australian Privacy Principles (APPs): https://www.oaic.gov.au/privacy/australian-privacy-principles
[98] Top mobile risks from OWASP: https://owasp.org/www-project-mobile-top-10/
[99] Meta's Federated Learning with Differential Privacy (FL-DP):
https://engineering.fb.com/2022/06/14/production-engineering/federated-learning-differential-privacy
[100] Web Content Accessibility Guidelines (WCAG): https://www.w3.org/TR/WCAG21/
[101] Apple's Human Interface Guidelines: https://developer.apple.com/design/human-interface-guidelines
[102] Google's Material Design Accessibility Guidelines: https://m3.material.io/foundations/overview/principles
[103] BBC's accessibility guidelines:
https://www.bbc.co.uk/accessibility/forproducts/guides/mobile
[104] Apple Push Notification service: https://en.wikipedia.org/wiki/Apple\_Push\_Notification\_service
[105] Firebase Cloud Messaging: https://en.wikipedia.org/wiki/Firebase\_Cloud\_Messaging
[106] Slack's notification system: https://slack.engineering/into-the-clouds/
[107] Duolingo's notification system: https://www.infoq.com/presentations/duolingo-high-scale-notification
[108] Shrinking APKs, growing installs blog post: https://medium.com/googleplaydev/shrinking-apks-growing-installs-5d3fcba23ce2
[109] Reduce Android app's size: https://developer.android.com/topic/performance/reduce-apk-size
[110] Reduce iOS app's size: https://developer.apple.com/documentation/xcode/reducing-your-app-s-size
[111] Instagram Lite app: https://www.theverge.com/2018/6/28/17512874/instagram-lite-app-developing-world-low-data-usage
[112] Fastlane: https://fastlane.tools/
[113] Github Actions: https://github.com/features/actions
[114] Bitrise: https://bitrise.io/
[115] Fastlane Match: https://docs.fastlane.tools/actions/match/
[116] Duolingo CI/CD improvements: https://blog.duolingo.com/sped-up-android-ios-builds
[117] Pinterest CI/CD improvements with Bazel:
https://medium.com/pinterest-engineering/developing-fast-reliable-ios-builds-at-pinterest-part-one-cb1810407b92
[118] iOS jailbreaking: https://en.wikipedia.org/wiki/IOS\_jailbreaking
[119] Rooting on Android: https://en.wikipedia.org/wiki/Rooting\_(Android)
[120] Android Play Integrity APIs: https://developer.android.com/google/play/integrity
[121] iOS apps integrity: https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity
[122] R8 Android code shrinking tool: https://developer.android.com/build/shrink-code
[123] HTTP Public Key Pinning: https://en.wikipedia.org/wiki/HTTP\_Public\_Key\_Pinning
[124] Personally Identifiable Information (PII): https://en.wikipedia.org/wiki/Personal\_data
[125] Signal's approach to privacy:
https://transcend.io/blog/industry-perspective-signal
[126] Android form factors docs: https://developer.android.com/guide/topics/large-screens
[127] Apple form factors docs: https://developer.apple.com/design/human-interface-guidelines/layout
[128] Spotify across screens developer story: https://www.youtube.com/watch?v=9_vbo6BZ0VU
[129] Google Photos for large screens: https://developer.android.com/stories/apps/google-photos
[130] Microsoft products optimised for large screens: https://developer.android.com/stories/apps/microsoft

CHAPTER 11

Quick Reference Cheat Sheet for MSD Interview

11

This appendix provides a helpful reference list of topics to consider during your MSD interviews. They're organized by domain, making them easier to navigate.

Network

CategoryKey considerations
Core protocolsβ€’ Communication protocols: REST, GraphQL, protocol buffers, or gRPC.
β€’ Real-time updates: HTTP polling, SSE, WebSockets.
β€’ Request management: idempotency keys, priority queuing, rate limiting, request throttling.
Data flow patternsβ€’ Pagination: offset vs. cursor-based.
β€’ Offline capabilities: offline-first design, optimistic UI updates, conflict resolution.
β€’ Network resilience: connectivity handling, exponential backoff.
Securityβ€’ Authentication and authorization: token lifecycle, biometric auth, recovery flows.
β€’ Security infrastructure: encryption in transit and at rest, certificate pinning, compliance requirements.

Data management

CategoryKey considerations
Architectureβ€’ Core data flow: patterns for data flow, layer responsibilities, error handling, error propagation.
Storage and cachingβ€’ Storage options: key-value, relational/non-relational DB, custom/binary stores, secure storage.
β€’ Caching: in-memory vs. disk trade-offs.
β€’ Eviction policies: TTL, size-based eviction, priority-based eviction.
β€’ Privacy: PII handling, GDPR compliance, data retention policies.
β€’ Synchronization: sync strategies, conflict resolution, delta sync.
Data performanceβ€’ Pre-fetching: predictive fetching, resource usage trade-offs, prioritize critical paths.
β€’ Background processing: task efficiency, battery impact, OS background execution limits.
UI & Interactionsβ€’ UI states: loading, empty, error, content.
β€’ Input handling: validation on client and server side, sanitization, feedback for errors.
β€’ Search: local vs. server-side search, indexing, typo tolerance.

Feature development

CategoryKey considerations
Version managementβ€’ Force upgrading: soft vs. hard upgrades, when to require updates, graceful prompts, version compatibility policies.
β€’ Safe rollout: phased rollouts, feature flags, rollback procedures.
β€’ CI/CD: automate build, test and deployments, code quality and security checks, ensure reproducible builds.
Dynamic configurationβ€’ Remote config: feature control without app updates, default values for offline scenarios.
β€’ A/B testing: experiments with clear metrics, user segmentation.
β€’ Analytics: performance and business metrics, crash monitoring, logging.
Code architectureβ€’ Modularization: separation of concerns, clear interfaces, balance granularity with practical maintenance.
β€’ Dependency injection: testability, object lifecycle management, minimize global state and singletons.
β€’ Third-party libraries: security and maintenance evaluation, size impact.
User experienceβ€’ Localization: text expansion/contraction, cultural and regional differences.
β€’ Accessibility: screen readers, contrast, touch targets, test with accessibility tools.
β€’ Crash reporting: automated collection, issue prioritization by impact and frequency, ensure right PII handling.

Performance

CategoryKey considerations
User experienceβ€’ Pre-fetching: predictive fetching, resource usage trade-offs, prioritize critical paths.
β€’ App startup: optimize initialization, defer non-critical tasks, measure cold/warm start times.
β€’ Stability: prevent crashes, implement error boundaries.
Resource managementβ€’ Battery & CPU: minimize background processing, batch operations.
β€’ Network efficiency: compress payloads, minimize requests, adapt quality on connection type.
β€’ App size: optimize assets, use app bundles, remove unused code.
Technical optimizationsβ€’ Caching strategy: cache appropriate responses, invalidation policies, multi-level caching.
β€’ Lazy loading: on-demand components and data loading, defer heavy processing until needed, prioritize visible content first.
β€’ Concurrency: proper threading models, avoid blocking main thread, manage thread contention and race conditions.
β€’ Hardware acceleration: GPU usage for animations and rendering, specific hardware optimizations.
Monitoringβ€’ Observability: performance metrics, proactive alerting, traces for complex operations.
β€’ Business metrics: connect performance to business outcomes, track user engagement, user funnels, identify bottlenecks affecting revenue.

Team & Organization

CategoryKey considerations
Technical infrastructureβ€’ Design system: consistent visual language, reusable components.
β€’ Development efficiency: optimized builds, coding standards, effective code review processes.
Process & qualityβ€’ Code quality: static analysis, automated testing, technical debt.
β€’ Risk management: early identification, contingency plans.
Team & cultureβ€’ Developer experience: efficient workflows, documentation.
β€’ Team growth: onboarding, knowledge sharing, continuous learning.
Company contextβ€’ Available resources: infra constraints, team size and expertise, budget, in-house development vs. 3rd party investments.
β€’ Business priorities: objectives alignment, target markets, monetization strategies, short-term needs vs. long-term platform health.
β€’ Outage handling: escalation procedures, recovery playbooks, appropriate redundancies.
β€’ Tech stack: integration with existing systems, platform consistency, future ecosystem evolution, standardization vs. specialized tools.

πŸ“Œ Remember: This list isn't meant to be a checkbox exercise where you cover everything. Strategic selection is key! Focus on the considerations most relevant to your specific design challenge, and be ready to explain why those topics are important. Your ability to make thoughtful trade-offs based on the system's unique requirements demonstrates stronger design thinking than attempting to cover every possible topic.