In the previous article, we introduced our frontend system design framework, discussed the different types of frontend design problems, and covered the best practices for component architecture, designing the state of components, and ended up defining how to manage the data API of your frontend components.
In this article, we will discuss the rest of the items in our framework and talk about API design, managing the data store, accessibility, performance optimization techniques, and more.
So without further ado, Let's get it started.
Foundations
[3.5] API Design
The first point we need to discuss is the API and network design and how we will manage the communication with the BE.
Here, we have multiple options and topics that we need to be aware of, varying from REST API, GraphQL, WebSockets, Server Send Events, and more.
Important Note:
An important note to know is that our choice here for selecting the API pattern totally depends on the application we are designing, So there is no "one-size-fits-all" solution here.
Real-Time Updates
If we are working on a real-time application like a chat app or stocks-app, so getting the real-time updates is very important, In this case, we have multiple options as follows:
[1] Client Pull (Short/Long Polling)
Polling is a technique by which the client continuously asks the server for new data and checks if anything has changed.
In the polling, we have two approaches, the first one is the Short-Polling and the second one is the Long-Polling and both of them are HTTP requests (XHR).
[1.1] Short Polling Technique:
Short polling is an AJAX-based timer that calls at fixed delays and here is how it works:
- Client requests the server
- Server can respond either with an empty object or with the data object
- After the client receives the response from the server, it will wait for a couple of seconds and then repeat the above process.
Challenges:
In short-polling, Making repeated requests wastes the resources because:
- New connection must be established
- HTTP header must be passed
- Query for the new data must be performed
- Response for the query must be generated and delivered
- Connection must be terminated
- Resources must be cleaned
[1.2] Long Polling Technique:
Unlike Short-Polling, Long-Polling works differently. here is how it works:
- Client requests the server
- Server can respond in two ways:
- If it has any new data, it can respond right away.
- If it doesn't have anything new data, it will keep that connection open until it receives new data, and then it will respond with updated data.
Important Note:
To avoid connection timeout, Long-Polling set a limit for the maximum duration after which the server will respond anyway even if it has nothing to send. After that, the client can start a new request
[2] Server Push (WebSockets)
WebSocket API is a cutting-edge technology that allows a two-way interactive communication session to be established between a user's browser and a server.
You can use this API to send messages to a server and receive event-driven responses instead of polling the server for a response like what we see in the Client Pull.
Advantages:
WebSocket comes with a lot of optimization over the Long-Polling, here are some of them:
WebSockets maintain a single connection while removing the latency issues that Long-Polling causes.
Unlike Long-Polling, which is far more resource-intensive on servers, WebSockets have a small server footprint.
The security model of WebSockets is superior (origin-based security model).
[3] Server Push (Server Send Events)
SSE is a server push technology that allows a client to get automatic updates from a server through an HTTP connection, and it describes how servers can start sending data to clients once an initial client connection has been established.
Other Patterns
[1] Backend for FrontEnd (BFF)
BFF is a pattern where we create separate backend services to be consumed by specific frontend applications or interfaces. This pattern is useful when you want to avoid customizing a single backend for multiple interfaces.
Backend for Frontend patterns works as follows:
- Call the relevant microservices APIs and obtain the needed data
- Format the data based on the frontend representation
- Send the formatted data to the frontend
[2] GraphQL
GraphQL is a new API standard that provides a more efficient, powerful, and flexible alternative to REST API
GraphQL's core feature is declarative data fetching, which allows a client to declare exactly what data it requires from an API. A GraphQL server only exposes a single endpoint and answers with exactly the data a client requested, rather than numerous endpoints that return predetermined data structures.
[3] HTTP2
HTTP/2 provides us with many new mechanics that will mitigate HTTP/1.1 issues and make our applications faster, simpler, and more robust. here are some of its new features:
- Enable the server to send multiple responses for a single client request
- Enable content caching for the get APIs
- Eliminate the limited number of concurrent connections
[3.6] Data Store
In the previous article, we talked about Data-Enitites and the best practices for designing the state of the component.
The simple representation of the "one-way data flow" consists of three main parts (State, View, Action)
- State is the source of truth that drives the app
- View is the declarative mapping of the state
- Actions is the set of ways that we can change the state with
Breakdowns:
Unfortunately, this simplicity quickly breaks down when we have a shared state between multiple components. in other words:
- We have multiple views that depend on the same piece of state
- We have multiple actions from different views that need to change and mutate the same piece of state
So, what about extracting the shared state out of the components, and managing it with a global singleton. This is the basic idea behind the state management pattern
Currently, most of the modern frontend libraries and frameworks provide some sort of centralized store for all the components with many rules to ensure that the state can only be mutated in a predictable fashion such as Recoil, Redux, NgRx, Vuex, and more.
We will not talk a lot about the state management pattern, but we will highlight one advanced pattern for designing the store which is the Normalizing State Shape pattern, and will discover more patterns in the upcoming parts of the series.
Normalizing State Shape
Sometimes we work on applications that deal with nested or relational data. For example, the Facebook newsfeed that we talked about in part one could have several Posts, each of which may have many Comments, and both Posts and Comments may be written by a User.
For this type of application, data could be something like that:
const feeds = [
{
id: 'post1',
date: '1652078961',
author: { username: 'user1', name: 'User 1' },
body: '.....',
comments: [
{
id: 'comment1',
date: '1652078192',
author: { username: 'user2', name: 'User 2' },
comment: '.....',
},
],
},
// and repeat many times
];
Drawbacks:
As we saw in the above code snippet, the structure of our data is a bit complex and we have repetition in the data which may cause several problems:
- When a piece of data is duplicated in multiple places, it becomes more difficult to ensure that it is updated correctly.
- Because of the hierarchical nature of the data, the associated reducer logic must be more nested and hence more complex. Trying to edit a deeply nested field, in particular, can quickly become very nasty.
Designing a Normalized State
Here is the main idea of normalizing the state:
- Each type of data will gets a separate "table" in the state.
- Each "data table" will hold the individual items in an object as keys and values, with the item IDs serving as keys and the objects themselves serving as values.
- Any references to the individual items should be done by storing the item's ID.
- We should use arrays of IDs to indicate ordering.
So the above state of the Facebook newsfeed will be something like that:
{
posts: {
byId: {
post1: {
id: 'post1',
author: 'user1',
body: '......',
comments: ['comment1', 'comment2']
},
// and repeat many times
},
allIds: ['post1', 'post2']
},
comments: {
byId: {
comment1: {
id: 'comment1',
author: 'user2',
comment: '.....',
},
// and repeat many times
},
allIds: ['comment1', 'comment2']
},
users: {
byId: {
user1: {
username: 'user1',
name: 'User 1',
},
// and repeat many times
},
allIds: ['user1', 'user2']
},
}
Benefits:
this state structure is much flatter overall. Compared to the original nested format, this is an improvement in several ways:
Because each item is only defined in one place, we don't have to try to make changes in multiple places if that item is updated.
The reducer logic doesn't have to deal with deep levels of nesting, so it will probably be much simpler.
The logic for retrieving or updating a given item is now fairly simple and consistent. Given an item's type and its ID, we can directly look it up in a couple of simple steps, without having to dig through other objects to find it.
[3.7] Performance Optimization
In general, Frontend performance optimization refers to optimizing both Speed and Smoothness. for example optimizing the loading speed, how fast the UI responds to user interactions, memory space required, and more.
Optimization Factors
[1] Loading Speed
Optimizing the javascript involves optimizing the size of the code as the less JavaScript the component contains, the less JavaScript the browser has to download to load the component and the lower the network request time.
So It's very important to modularize the components and allow users to download only the necessary JavaScript modules needed for their use case.
[2] Responsiveness to User Interactions
Mainly this factor concerns optimizing the time between the user interaction with the page or the component and the UI updating of that interaction.
As we all know, JavaScript in a browser is single-threaded and the browser can only execute one line of code at a time so the less work (JavaScript execution, DOM manipulation) the component has to do when a user does something or interact with the page, the faster the component can update the UI to respond to the changes.
[3] Memory Space
Increasing the amount of memory your component allocates will degrade the browser performance and the experience will feel sluggish.
For example, if your component must render hundreds or even thousands of elements (e.g. the number of posts in a newsfeed, the number of images in a carousel), memory space may become an issue, causing the user experience to suffer.
Optimization Tips
There are a lot of performance optimization tips that we can apply to enhance the speed and the smoothness, but we will not be able to cover all of these topics in the article and only will mention the title of the topics and will try to cover them in our cases studies such as Facebook Infinite Scroll or separate articles in the series.
[1] Page Performance
Here are some of the approaches and patterns that you should have in your performance toolbox to help you improve the performance of any page:
- Preloading and Prefetching of the resources
- Bundle Splitting and Route Based Splitting
- Caching and using CDN
- Optimize loading third-parties
- Optimize your loading sequence
- Compressing JavaScript
- Dynamic Imports
- Import on Visibility
- Import on Interaction
- List Virtualization
- Precaching, lazy loading, and minimizing roundtrips
- Tree Shaking
- Service worker/offline
[2] Images Performance
Optimizing the performance of the images includes many steps starting from choosing the right format for your images and ending by laying loading these images.
Here is a set of the topics that you should consider in optimizing your images:
- Choose the right image format
- Choose the correct level of compression
- Use Imagemin to compress images
- Replace animated GIFs with video for faster page loads
- Serve responsive images
- Serve images with correct dimensions
- Use WebP images
- Use image CDNs to optimize images
[3] Rendering Performance
Besides the Network performance, there are many rendering performance patterns and techniques that may help you achieve better page load and better user experience such as:
- Server-side Rendering
- Client-side Rendering
- Streaming Server-Side Rendering
- Progressive Hydration
- Optimizing the Critical Rendering Path
- CSS Architecture s and Naming Conventions such as BEM
[4] Metrices
Multiple matrices help you set criteria for evaluating the performance of your page which you need to check while auditing your application and here are some of them:
- Time to First Byte (TTFB)
- First Contentful Paint (FCP)
- Largest Contentful Paint (LCP)
- First Input Delay (FID)
- Time to Interactive (TTI)
- Total Blocking Time (TBT)
- Cumulative Layout Shift (CLS)
[3.8] Accessibility
Accessible frontend development ensures people with different abilities can access, understand, and navigate web content, regardless of how they’re accessing it
Here are some of the ideas that you should be aware of to design an accessible frontend system:
- Accessibility tree and what it means?
- Use semantic HTML elements
- Use the
a
tag for internal navigation and outwards link - Add keyboard navigation and hotkeys
- Add a link to go back to the top of the page
- Add the aria attribute to elements with a description
- Add the aria attribute to exclude elements not relevant to the screen reader
- Add alt text for all images
- Add foreground colors that have sufficient contrast from the background colors
- Provide media accessibility
In the this link, you will find a full checklist of items that you need to cover to make sure that your website is fully accessible.
[3.9] Testing Strategy
Selecting the testing strategy for your system depends on the project constraints as some tests are more time consuming than others (e.g. end-to-end tests)
Also, Some of the testing strategies are valuable in some cases and harmful in some cases, for example, the visual testing can be valuable if the project is stable and can be premature optimization if the project scope and UI change fast.
Here is a high-level listing of the types of tests that you can add to your project, so make sure to understand each one of them to be able to select the right one for your project:
- Linting or any test that enforces best practices during development
- Typechecking something like PropTypes in React
- Unit test which tests a single function or a service
- Snapshot test which tests the visual difference of the page over the time
- End to End Test which tests the interaction between multiple components, usually from point of view of a user (Cypress)
- Performance test which tests how the app performs in different environments
[3.10] Security
The frontend is the main gate to your web application, and its security is a very important part of the system design.
Here are the most common security vulnerabilities and risks you should be aware of:
- Clickjacking
- Cross-Site Scripting (XSS)
- Cross-Site Request Forgery (CSRF)
- Content security policies (CSP)
- A target="_blank" rel=noopener
- Third Party Assets
- Man-in-the-Middle
Next Steps
Finally, we have covered all the ten items of our framework that we will follow in any frontend system design problem.
At first, we discussed the API design and how to choice the right method based on your application. then we moved to design the data store and saw a common pattern for it, after that, we dived deep into Performance, Accessibility, Testing strategies, and Security.
In the next part of this series, we're going to start selecting and designing some of the real-life applications we use every day and see how we can design them using the framework we settled. Stay tuned.