Welcome back! If you missed Part 1 and/or Part 2, follow those links to check 'em out!

2. Large-Scale UI Design

Here, large-scale means data rich. In this section, I will discuss some common patterns used in large-scale UIs.

2.1. Asynchronous Calls

JavaScript asynchronous calls could cause many complexities:

The number of cases is exponential to the number of promises. When you have multiple promises, you need to consider the cases that some of them succeeds, and some of them fail, which is exponential.

Multiple promises could also cause race conditions.

Zombie promises could cause a problem. Consider the following scenario:

Component A creates Promise A to asynchronously fetch some data.

Component A is destroyed before Promise A is resolved. At this point, Promise A becomes a zombie. It could still be resolved when the asynchronous call returns.

Then a new component A is created and a new Promise A is created.

If the old zombie Promise A is resolved after the new Promise A is created or resolved, it could create many potential errors.

Zombie promises could also cause memory leaks if its handler contains some large object references.

Since adding asynchronous calls into synchronous functions could cause a lot of issues, we should either:

Restrict the usage of asynchronous calls to very limited cases:

A component’s UI can be separately updated by the asynchronous call without affecting other components to minimize the promise’s impact.

Some background data related tasks.

We should cache all promises so that they can be canceled on component disposal to avoid zombie promise problem. We could convert a library API’s asynchronous return to a synchronous syntax by using an await/async call. By limiting the use of asynchronous calls, we could reduce the above complexities as much as possible.

Use promise as a function return type as much as possible, at least in your main infrastructure function’s return type, and build general infrastructures to handle all promises' rejections and cancelations. This pattern could provide an open door to implement some framework level solution for exponential cases, race conditions, and zombie promises, which would make the component developer’s life easier when they use promises.

2.2. Pagination

Every type of data could grow to be very large as time elapses including, even some pre-defined constant data. So every data list or data table UI should use pagination by default. If you don’t want to use pagination, you should provide strong justification.

2.3. List and Table UIs and Their CRUD Operations

Lists and tables are frequently used in UIs to display a set of data of same type especial when the data size is very large. CRUD stands for Create, Read, Update, and Delete.

List/table UIs usually have the following features:

Pagination

Sort by different fields

Search or filter by data’s fields’ values

Create, update, and delete rows.

One big challenge is list/table’s load, search, and refresh performance, especially when data size is very large.

In the backend, cache the list data to improve the read, sort, and search performance. The cache should only contain the columns displayed in the list. It shouldn’t contain the other columns of data which are not displayed in the list/table so that it could reduce memory usage of the cache. Since this is only a front-end technique article series, I won’t discuss the backend cache design but I do think it is doable as long as don't need too many columns to be shown in the list/table.

In the list/table, only show the minimum number of columns of a data, which are usually logical keys of that data, to reduce the load and complexity of the backend cache.

If the user wants to see more detailed data, they can click that data to see a detailed view. Note that it is better to hide the list at the same time to reduce complexity.

The backend could also maintain an LRU cache to store some data’s full detail.

The CRUD operations add another layer of complexity on table/list UI. To simplify that we should:

When the user edits details, hide or disable as many buttons and clickable items as possible to reduce complexity and prevent incorrect user actions.

After the user creates a new record, to assure the user that the new record is inserted into the list, we can keep the current search filter and sort order and navigate to the page containing the new record if the new record is not filtered out by the search filter. You can also change the current filter or sort order to show only the new record or show the new record on top of the list. But that would change user’s original filter or sorting order. If the user wants the previous filter and sorting order, they have to redo the search or sorting, which is extra trouble for the user. With a configurable component mentioned earlier in this article, the developer could easily refresh the list to show the page containing the new record.

2.4. Data Relationship

In data-rich applications, sometimes the data model is very complicated. Tens of types of data could have a relationship with each other. The relationship is either a reference field pointing to other data or a relationship table consisting of multiple types’ logical keys. So the challenge is how to make the UI easy for users to navigate through the relationship, research the relationship, and view multiple related data sets at the same time.

We can’t present all relationships corresponding to one piece of data on the same page because:

There could be too many related data sets to present on one page.

It makes data points hard to find and focus on a particular relationship.

Therefore, for each data type and its relationships, we should figure out the most important relationships for the user and then present these relationships on the same page. For other relationships, we can put a reference link in the detail view to allow the user to click it and view it in another window or page.

The following UI features could help better show data relationships:

For each data object, only show its minimum identifiers on the page and show its details in a separate window or pop-up window. To make it easy for the user to identify individual data objects, we could display minimum identifiers on the table or list. Users could click the data to see the full detail of it in a pop-up window or separate window. In this way, we can display more data and related data in one page and not make it look too crowded.

Show data relationships in a graph. This is only suitable for the relationship graph which has moderate depth and breadth. If a data set has 1 million related data points, we should show these data points in a separate table with pagination instead, because there would be no space to show the whole graph.

For less important relationships or the relationship between large data sets, show them in a separate window, table, or list.

2.5. Tree UI

Tree UIs are a special type of data relationship which is both simple and frequently used in different applications.

There are two main types of tree UI:

Show the whole tree in one table with a collapse and fold structure.

Pros

Clear tree structure.

Single table, simple and straight forward, saves space.

Easy to support a single search on all tree nodes.

Cons

Hard to do pagination.

No good way to show 1 million children under the same parent.

Makes the UI very complicated if children and parents have different columns.

UI example:

A list of lists. Each list represents one level of nodes and all lists are placed in vertical order. Inside each list, tree nodes of the same level and the same parent are placed in horizontal order.

Pros

Easy to support pagination on each level so that we can divide 1 million children into pages.

Easy to support search filters on each level.

Parent and child could have different columns.

Cons

Takes up too much vertical space especially when we want to show multiple columns in each list.

Hard to support a single search on all tree nodes.

UI example:

2.6. Concurrency and Consistency

Concurrency and consistency are two big concerns in data-rich UIs.

Concurrency could happen when users perform CRUD operations. Two users could update the same record at or around the same time. Two users could also create new records with the same identifier at or around the same time. One user could update one record and another user could delete that record at or around the same time.

To avoid confusion and conflicts when concurrency happens, we need to provide some level of consistency.

But concurrency and consistency are hard to guarantee at the same time. So we need to evaluate different requirements and do a trade-off.

Requirements

Type 1: Consistency is most important, with a low chance of concurrent edits and a low amount of concurrency on the same record. This could happen on an application which is used by business customers.

Solutions:

Lock the data being edited. When one user wants to edit one data set, they need to lock it first so that other users cannot edit the data at the same time. Since, in this requirement type, there are a low amount of concurrencies on the same record, the lock shouldn’t be too expensive.

Push data updates to other users. Use WebSocket’s message event to push updated data from the server to all clients displaying that data. It might be hard to track exactly which clients are displaying the data. But the server doesn’t need to know which clients are displaying the data. It could just push the updates to all clients which are active and requested the data before. When a client receives the updated event, it could either ignore or handle it. The update events shouldn’t take too much server bandwidth since we assume low concurrency on the same record.

Type 2: High chance and amount of concurrency, consistency is less important. This could happen on an application which is used by individual customers.

Solutions:

Client polls the latest records. Since concurrent client amounts are large, maintaining a list of active clients for each record on the server could be very expensive. So instead of a server pushing updates to clients, we should make the client poll the latest records. And on the server side, we could use multiple servers to serve polling requests and do load balancing.

Servers store multiple copies of the same records. Store multiple copies of the same records on multiple servers to minimize concurrent request latency.

No resource lock. It is expensive with multiple servers and high concurrency.

3. UI Automation Testing

Automation testing is very important for UI because there are too many test cases to be covered by human testers. The number of test cases is exponentially related to the number of clickable components in a UI.

The easiest way to do UI automation testing is deploying all your front-end components and backend components to a separate environment and writing scripts to test the whole application. But this way usually produces flaky test results.

The flakiness is usually caused by timing uncertainty in the network, browser, database, server, and/or operating system. In our test code, we usually wait for something to happen to verify the test result. The test framework could also fail the test if the test doesn’t finish after some timeout value. The timeout failure could either be expected or unexpected. It could be expected when we design it to fail if nothing happens after some input. It could be unexpected because of timing uncertainty. So timing uncertainty could cause false negative (flaky) test results and a lot of trouble for the developer to develop automation tests.

To minimize the flakiness and timing uncertainty, we could:

Test each component separately. When testing one component, we could create a simple mock of other components. For example, when testing only the front-end, we could mock backend APIs and data.

Use a mock platform in integration test containing multiple components. If we can find some mock platforms, such as a mock browser or mock operating system which allows your test framework to control its timing, it would greatly reduce the integration test’s flakiness. For browsers, we can override the setTimeout()and setInterval()functions in JavaScript to control the timing. But we cannot control CSS animation’s timing, which makes it hard to verify CSS animation in an easy way.

Full integration tests with real platforms are also useful but should only be used as a system stability metric and should not waste too much of a developer’s time to figure out why it fails or why it is flaky.