Performance
Performance is almost always regarded as a feature. While different kinds of applications may be satisfied with different levels of performance, especially for end-user-facing applications, the general mantra is faster is better. Amazon claims that every 100 ms of additional latency costs them about 1% in sales. This is a massive number and should not be underestimated.
Resource caching
As we saw in Chapter 1, there are multiple kinds of websites these days, such as the following:
- Static websites
- Dynamic websites (server-side rendering or SSR)
- Dynamic websites with AJAX
- Single-page applications (SPAs)
Every kind has its own techniques to boost performance and stay scalable. For instance, static websites will use an in-memory cache to avoid reading the page from the disk on every request. This simple feature can be already used in SPAs; however, it must be implemented by hand for SSR.
Most languages and frameworks that deal with SSR have no idea what kinds of dependencies are required to create a response for a given request. As such, introducing an automatic cache for these requests seems very difficult. We can assist these frameworks by giving hints.
There are multiple layers of caching that we can apply to help not only our server but also the browser to improve performance with a cache. Ideally, pretty much all frontend assets are uniquely named and can be cached indefinitely in the client.
Important note
There are several HTTP headers dedicated to properly communicating caching. While we focus on the strongest caching guarantees here, in general, more fine-grained approaches can be very useful, too. A full discussion on caching would certainly go beyond the scope of this book. See the following MDN link: https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching.
At some point, you may be tempted to aggregate multiple assets into a single asset. For instance, if three micro frontends all produce a JavaScript file, you may want to bring this to a single JavaScript file. While this can be beneficial, the complexity and its outcome might not justify this investment. There are multiple reasons for this:
- HTTP2 streams requests and makes serving multiple resources almost as efficient as serving a single resource
- Caching becomes more complex and is less fine-grained, leading to more cache misses
- The individual scripts lose their context – for example, to automatically resolve URLs correctly
- One erroneous script can abort the execution of all scripts in the aggregation
Consequently, even consuming resources per micro frontend directly in the browser is usually better than a single large resource containing all micro frontends.
Bundle size
For SPAs or JavaScript-heavy pages in general, we need to be very cautious with the used JavaScript code. In today’s landscape, it’s far too easy to use multiple dependencies carelessly. Monitoring the resulting size of the JavaScript bundles and making sure that these bundles remain small is vital to great performance.
Especially for SPA-based micro frontends, we will use a bundler such as webpack or Vite to produce one or more JavaScript files for each micro frontend. Here, it is crucial to leverage bundle splitting to keep each JavaScript file rather small. A classic example would be to create a separate JavaScript file for each page in a SPA.
So, instead of writing code such as the following, you leverage the import
function to introduce bundle splitting by lazy loading another JavaScript fragment:
import * as React from 'react'; import { Switch, Route } from 'react-router-dom'; import MyPage from './page'; export const Routes = ( <Switch> <Route path="/my-page" component={MyPage} /> </Switch> );
In React, we can use the lazy
function together with a Suspense
component to allow lazy loading for parts of the application.
Updating the example to use lazy loading, we get the following:
import * as React from 'react'; import { Switch, Route } from 'react-router-dom'; const MyPage = React.lazy(() => import('./page')); export const Routes = ( <React.Suspense fallback={<div>Loading...</div>}> <Switch> <Route path="/my-page" component={MyPage} /> </Switch> </React.Suspense> );
For micro frontends, bundle splitting may be even more crucial than for non-micro frontend web applications. Since scalability is one of the core principles of most web applications, the only way to ensure a fluent user experience is to create smaller assets that are only loaded on demand.
Important note
It always makes sense to be aware of what dependencies contribute to the final bundle size. For webpack, the webpack-bundle-analyzer
package visualizes this quite nicely. Also, websites such as https://bundlephobia.com help to identify the cost of including dependencies quite early.
In general, we should try to introduce the lazy loading of individual code as much as possible. As seen in the previous example, in SPAs, this can be as easy as lazy loading every component representing a page. Also, dependencies that are only used in “exotic” scenarios should not be part of the main bundles. Using modern Promise
infrastructures helps a lot here.
Request optimizations
Micro frontends are all about modularization. As a result, the number of HTTP requests will definitely grow. After all, instead of having everything integrated into a monolith, we now have a system where the different technical parts are still very much separated – either on a server or in the browser.
Quite often, many modules will need to request the same or similar data. If we are not careful, this may lead to a lot of stress on the API servers. Even worse, aggregating the results will take longer, leading to a worse user experience. It can, therefore, be useful to mitigate this by introducing micro-caching for API requests. If a request to /api/user
is cached for 1 s, then other execution units get the result immediately from the cache. Importantly, on the server side, this cache has to be exclusive to the page’s request context.
Another factor to think about is request batching. Especially with a microservice backend, there may be many requests needed to get some required information aggregated. If we introduce a technology such as GraphQL on the API gateway, we could run a single request with multiple queries. The backend then resolves all the different queries – using a much faster and more direct way than the client could. Here, the backend may cache the queries, too.
Now that we’ve touched upon request optimization, with its potential security challenges, it’s time to look a little into the security sector in more detail.