tldr;
We will cover the core concepts of SSR, how it works with Service Oriented Architecture (SOA), and the more advanced concepts of using async code splitting with SSR. To begin there will be a small primer on the terminology we will be using. Anyone who is new to SSR needs to have the Isomorphic/Universal talk. For those familiar with the subject, feel free to move right along to the SSR section.
SSR can be hard to grasp initially (I know it was for me), but it provides considerable benefits to your clients. They use less round trips for data, and get more data earlier in the client app loading process. Using async code splitting with SSR lets developers have the best of both SSR and on demand asset loading, but has a reputation of being extremely difficult, and that is true at first.
One of the main goals of this writing is to break down the complexities of SSR and async loading into more digestible chunks to take some of the difficulty out of getting into the SSR world and make the subject matter a little less intimidating.
Moving right along we have our obligatory Isomorphic/Universal speech as promised. Those familiar with or those who just like to avoid the issue, the good stuff is right after. Enjoy!
Isomorphic and Universal are both used when describing code that produces the same result when executed in different runtime environments. The terms are also used to describe the processes of executing said code, as well as the composition of code units and entire applications.
While incredibly boring, it may be worth it in this case to break out
some dictionary definitions.
Isomorphic
Universal
Isomorphic describes kinds of mimicry and different entities forming
similar or identical shapes, whereas Universal has an emphasis on sameness and equality of entities. Following the definitions, neither covers all the concepts involved. There are some pieces of code that just are not able to run on every platform, so the definition of universal cannot be applied to all concepts all the time, but Isomorphic fails to acknowledge that some concepts and constructs are truly universal and valid on any platform, so it does not fit all conditions either.
The developer community is somewhat split on the issue. Charlie Robbins and Spike Brehm use Isomorphic in their influential writings from the earlier part of the decade. Michale Jackson proposed using just universal to make it easier for people new to the concept. Gert Hengveld splits the difference and proposes Isomorphic when referring to functional aspects and universal when referring to code itself.
No guidance will be provided on which is best in this article. There is plenty of evidence that the issue is dependent on context to a large degree, and what developers are comfortable with. Universal JavaScript does sound a bit better than Isomorphic JavaScript. Isomorphic renderer is a bit more pleasing to some than universal renderer. It’s one of those subjects that depends on the person and the day.
With all that being said, the remainder of this article will use the term Isomorphic instead of universal. If you prefer, just pretend it says universal when you see Isomorphic. The experience will be roughly the same.
Individual statements, functions, and applications can be said to be Isomorphic if they can be executed on both server integrated JavaScript runtime environments and client JavaScript runtime environments without error.
For an individual statement to qualify as Isomorphic, it has to be able to execute without error in both client and server JavaScript runtime environments.
Example: Isomorphic statement
const foo = 10;
Example: Non Isomorphic statement
const jsonpArray = window[“webpackJsonp”];
To qualify as Isomorphic, a function, route, or application does not have to guarantee all branches of execution run without error, but must guarantee at least 1 path will execute without error in a given JavaScript runtime environment.
Example: Isomorphic function
const extractDataWhenIso = (someData) => {
if ( typeof window === 'undefined' ) {
return null;
}
return someData;
};
Example: Isomorphic Application
Isomorphic JavaScript/applications can be executed using server integrated JavaScript runtime environments, on hosts that are collocated with service hosts and resources for low latency access to data needed for execution.
The rise of SOA gave us sprawling networks of services that provide us any data we could want, and being collocated in the same data centers makes it possible to run data fetch sequences in reasonable time. SSR leverages SOA by applying it to webpage request serving much like service API’s handle data requests.
Example: Non SSR SOA
Example: SSR SOA
Being able to access data collocated in the same data center reduces latency of service and other API calls. Moving low latency data fetches to the server removes multiple page load blocking round trip fetches from a client apps loading flow, allowing partial and full first request page loads.
Example: Non SSR SOA timings
Example: SSR SOA timings
Results from the data rendering pass for a requested route can be used to compute the initial DOM state as it would appear in a client’s browser. DOM management frameworks like React can apply specialized rendering methods called hydration that forego running DOM computation on the client and directly set the entire DOM from the server rendered version embedded in the initial HTTP response.
SSR has 3 characteristics that remain constant regardless of implementation
A page can be considered Server Side Rendered if characteristic 1 and either characteristic 2 or 3 are present in a server applications implementation.
1) Server Integrated JavaScript Runtime Environment
A server will execute the client JavaScript that will be run for a page being requested, and will obtain the result of the execution for HTML injection.
2) Data Rendering
Calls for external data in executing JavaScript are handled by the server integrated JavaScript runtime environment and the results are stored for later use in execution and/or HTML injection.
3) DOM Rendering
Execution of client JavaScript on the server for a route will render a DOM representation of the route as it will appear on the client, and the representation will be returned from the renderer for HTML injection.
Data and DOM rendering are not mutually exclusive, and by their nature can be used independently of each other, or in unison.
Complete
Both Data and DOM rendering are used to render a requested routes final DOM representation for HTML injection that embeds the DOM representation into the HTML page that is sent to the client.
Example
When a user requests a page, the sever renders the requested route as it would be run on the client. Data fetch calls are executed when encountered in the execution path, and a DOM rendering pass is executed which uses the results of the Data rendering pass to initialize and configure persistent storage systems like Redux and/or DOM affecting components. The rendering result is then embedded in the HTML page that is severed to a requesting client.
Example: Complete SSR
Partial
The result of server side JavaScript rendering does not necessarily have to contain both Data and DOM rendering results, and the two modes can be run independently.
Data only
The result of Data rendering will be used to initialize a data store or component states on the client when configuring the client DOM on HTML request response loading.
Example
Results of Data rendering for a requested route are used to initialize default state of persistent storage management systems like Redux for later client use in flux systems, or to be accessed directly by the application. Data does not have to be used in the initially rendered path, and may be used latter in the application without incurring any loading penalties by accessing the data embedded in the HTML page.
Example: Partial SSR (Data only)
DOM only
The DOM representation produced as a result of rendering will be used to initialize the DOM on a client when performing HTML request handling.
Example
Rendering a requested routes DOM representation can be completed without using any Data rendering pass results, or without having done any data rendering at all. Components with static content like help or FAQ pages can be server rendered without having to obtain any external data.
Example: Partial SSR (DOM only)
ARR is performed by client applications when an async route is encountered in execution. When encountered, the client will call for the external resources needed to complete rendering of a route.
Example: Async page loading
In contrast to a SSR implementation, clients do not have direct access to assets. Any data or assets the client needs beyond what is provided in the HTML response for a requested route has to be obtained from an external source, causing clients to block and preventing meaningful user interaction until all needed assets are retrieved.
SSR implementations need to be able to render requested routes with the resources available to them through the server integrated JavaScript runtime environment. While possible, most implementations of server integrated JavaScript runtime environments do not have a window object, having to rely on pollyfills and the runtime environments fetch emulation capabilities, which makes it difficult to load multiple JavaScript files like a client would.
SSR capable implementations access all needed resources directly from their local file system, making async characteristics ineffective in SSR implementations. Direct access to resources negates the benefits of and eliminates the need for async resource caching like clients require.
ARR characteristics are incompatible with the characteristics of SSR due to AAR’s asynchronous asset loading strategies. SSR performs optimally with all resources directly accessible, AAR is predicated on not having all resources at the time of execution.
IAR allows implementation of AAR characteristics inside a SSR implementation using a characteristic called Isomorphic async containment.
To meet the required characteristics of SSR, AAR components need to yield a renderable result at time of execution. When running on a server integrated JavaScript runtime, the renderer result cannot be a promise of an async external resource loading requests resolution, it must be a promise to resolve a concrete resource.
The following is a simplified diagram of how to employ IAC in a IAR based client. There is a lot more involved in the full build chain in regard to SSR boilerplate, but to clearly demonstrate the concepts of IAC/IAR the example focuses on the async portions of code.
app.js / steps 1 thru 3 are where IAC is employed by leveraging the build tools aliasing capability to keep async commands out of the SSR path.
Example: IAR build and run time
Build time
1) Build tool starts the build process
2a) Standard routes are consumed (async components allowed)
2b) SSR safe routes are consumed (no async components allowed).
3) The app consumes the correct routes for the build variant using the build tools import aliasing feature which allows specifying an alternate name to use for import statements that is defined in the build variants configuration.
4a) Build tool outputs artifacts intended for direct and indirect client consumption.
4b) Build tool outputs artifacts intended for webserver consumption.
5) Artifacts directly consumed by clients are sent to CDN
Run Time
#) Webserver consumes HTML that has been embedded with src loading instructions for all needed artifacts on the CDN
*) Webserver consumes SSR build artifacts to render the client app on the server
1) Client requests yoursite.com
2) Webserver uses request as input for rendering the requested route with the server integrated JavaScript renderer
3) Rendering result is embed in the HTML and returned to the client
4) Client executes the HTML response and requests the client specific build artifacts
5) CDN returns the requested build artifacts
6) Client is fully loaded and usable
The mechanism that allows the client render to match what the sever has rendered despite one set of build artifacts resolving to async loading commands and one resolving to concrete components is JavaScript’s Promise object.
The server renders a promise for async components but does not generate any asynchronous loading code, generating resolutions to concrete components instead. The client hydrates the rendered response using the async build artifacts whose promises resolve to an async loading operation.
The only condition the clients async version of the app needs to meet for hydration to succeed is a promise that will be resolved is present in both the server rendered result and the async version of the code the client has. What the promise resolves to and how it resolves does not affect hydration parity.
Example: Async and SSR routes
// routes.js
const AsyncAboutComponent = asyncComponent({
loader: () => import('./components/about/index')
});
<Route
path="/about"
component={ AsyncAboutComponent } />
// ssrRoutes.js
import About from './components/about/index';
const AsyncAboutComponent = asyncComponent({
loader: () => Promise.resolve(About)
});
// route usage
<Route
path="/about"
component={ AsyncAboutComponent } />
When a user requests an async route, the sever does not render the route, and only renders a promise to be resolved in its place. Since the rendered result does not explicitly say what has to result from the promise resolution, hydration succeeds and the client is free to do whatever it likes after hydration without worrying about exactly matching the SSR version of the code.
First and foremost, thanks to you the reader! I hope everyone got a little something from this and wasn’t bored.
I will leave you with some links to materials that were really helpful in learning about SSR and how how to implement an SSR capable sever, as well as resources for async components.
Additional Reading
Isomorphic/Universal JavaScript
Author: Gert Hengeveld
Link: https://medium.com/@ghengeveld/isomorphism-vs-universal-javascript-4b47fb481beb
Overview: Concise opinion piece on the terms Isomorphic and Universal in regards to JavaScript
Author: Michael Jackson
Link: https://cdb.reacttraining.com/universal-javascript-4761051b7ae9?gi=f9925180d7cf
Overview: Advocates for the use of universal over Isomorphic on the grounds that
Author: Spike Brehm
Link: https://medium.com/airbnb-engineering/isomorphic-javascript-the-future-of-web-apps-10882b7a2ebc#.4nyzv6jea
Overview: Rundown of the history of Isomorphic rendering as of 2013. Focuses on Isomorphic as a term form rendering and server architectures.
Author: Charlie Robbins
Link: https://blog.nodejitsu.com/scaling-isomorphic-javascript-code/
Overview: Early definition of the JavaScript language itself as Isomorphic (with some notable or exceptions), with focus on the code itself being Isomorphic.
SSR
Author: Oleg Lebedev
Link: https://github.com/olebedev/go-starter-kit
Overview: Implementation of a Go based webserver that uses the Goja JavaScript renderer and a React/Redux based front end.
AAR
Author: Arpy Vanyan
Link: https://medium.com/front-end-hacking/loading-components-asynchronously-in-react-app-with-an-hoc-61ca27c4fda7
Overview: Great breakdown of a React HOC that facilitates async loading.
Author: Anomaly Innovations
Link: https://serverless-stack.com/chapters/code-splitting-in-create-react-app.html
Overview: Another async HOC with a deep look at integration with React Router v4
IAR
Author: James Gillmore
Link: https://github.com/faceyspacey/react-universal-component
Overview: react-universal-component is a unified async/SSR compliant higher order component that provides an out of the box IAR wrapper. To accomplish IAR, react-universal-component uses webpack-flush-chunks to swap out chunks with async calls for their SSR compliant counterparts when executed server side.