Existing solutions for server-side rendering your single-page application are full of compromises. These compromises affect the performance of your application (affecting the time until your user sees content) and the maintainability of your application (affecting how quickly you can iterate and bring more value to your user).
Having experience with these compromises in the past, the DoneJS team set out to solve the issue in a low-level way, and can-zone was born.
As a brief refresher, Zones (implemented in can-zone) are a technology that tap into the JavaScript event loop so that you can define behavior that happens before and after asynchronous code is run.
In a previous article we saw how we could use this abstraction to create a performance monitoring plugin to time function calls. If you haven't yet, you might want to read the introduction to can-zone. It introduced the ideas behind Zones and how they are generally useful. In this article we'll go over one general problem that Zones can help to solve: server-side rendering. In outline:
- Why server-side rendering is important.
- Existing solutions and where they fail.
- What makes server-side rendering hard.
- How Zones provide a way to simplify things.
Why Server-Side Rendering is Important
For most classes of applications, server-side rendering can improve the perceived performance of your application. Amazon found that for every 100ms of latency it cost them 1% of sales.
One of the primary reasons we write single-page applications is to decrease the time it takes to transition from one section of the app to the next. The same reasoning applies to the initial page load; the quicker you can get content to your users the more likely they are to stay on your site. Even if your application is not yet warmed up (as JavaScript and CSS are fetched) the user is still able to see the content and start making choices about where they will go next.
Existing Solutions
To work around the difficulties of rendering a SPA, there are a couple of existing solutions.
Headless Browser
A headless browser, like PhantomJS, is a browser with full rendering capabilities and a JavaScript engine, but without the “head” of the browser; the part that paints to the screen. Instead they provide a JavaScript API that allows you to control the browser in the same way we normally do from the GUI; loading a single web page. PhantomJS has been used for server-side rendering because it gives you an environment that is identical to the web browser your application was written for. Most implementations:
- Create a new browser (or tab) instance for each request.
- Wait some delay (say 2000ms) so that asynchronous requests can complete.
- Serialize the document state to string and return that as the response.
As the diagram below shows, using a delay is wasteful as rendering often completes way before the timeout occurs.
While effective, the headless browser solution hasn't stuck because it:
- Consumes a lot of memory by creating a new browser window for each request. Imagine serving 1000 simultaneous requests as having 1000 browser tabs open and you can see how this will be a problem.
- Is wasteful. Most implementations using Phantom use a delay before considering rendering complete. This wastes memory as rendering might be complete within 100ms but we are waiting 1000ms before returning the response. For this reason Phantom instances are pooled to handle simultaneous requests.
- Because we are waiting so long for rendering to be complete we need to have a pool of Phantom instances to handle simultaneous requests. This adds additional development and maintenance costs as you must carefully control the number of workers in your pool and add new servers to load balance.
- Hasn't kept up to date with changing browser APIs. As impressive as headless browsers like Phantom are, they are essentially side projects for the maintainers, and with an ever-evolving specification, you need full-time employees to keep a browser up to date (the same way browser vendors employee full-time engineers). Phantom in particular had a slow transition to Phantom 2.0, and for years didn't support JavaScript features most developers take for granted, like
Function.prototype.bind
.
Application Conventions
An example is taken from the canonical Redux SSR example:
fetchCounter(apiResult => {
// Read the counter from the request, if provided
const params = qs.parse(req.query)
const counter = parseInt(params.counter, 10)
|| apiResult || 0
// Compile an initial state
const initialState = { counter }
// Create a new Redux store instance
const store = configureStore(initialState)
// Render the component to a string
const html = renderToString(
)
// Grab the initial state from our Redux store
const finalState = store.getState()
// Send the rendered page back to the client
res.send(renderFullPage(html, finalState))
})
Here fetchCounter
performs an API request before the Redux store is ever created. This sort of duplicate logic for every route in your application will quickly add up. Using Zones would allow you to move the asynchronous behavior into a reducer, which would be shared code between the client and server.
The problem
Virtual DOM frameworks do not provide a solution to the async problem but rather leave it up to you. Although no "winner" technique has emerged yet, most solutions revolve around strict application conventions like moving all application logic outside of components and into the state container (usually a Flux implementation like redux). These have drawbacks such as:
- All application state must be in its final form before rendering takes place, because React rendering is immutable and synchronous.
- Components can't maintain their own (asynchronous) state effectively making them simple views. Because of this you can't easily share components between applications as they are tied to the application state's behavior.
- Even when state is moved out of components it still must be "kicked off" using Flux actions, so special server code is still needed that is aware of the behavior needed for each route.
What Makes Server-Side Rendering Hard
The root of the problem with SSR, and why most frameworks are struggling to integrate it, is that client-side JavaScript is oriented towards the browser, which is single user, and servers are conversely multi user. For the sake of maintainability writing your code browser-first is extremely important.
This is where Zones come in. They bridge the browser (single user) and server (multi user) environments by providing a common context for all asynchronous code, effectively making it single user. By context I mean that asynchronous code is contained within the Zone so that when you create a new XHR request, for example, it's callback will occur within that same zone.
Zones as a state container
By tracking asynchronous tasks triggered within a function call (the function provided to Zone.prototype.run
) a Zone provides context within all code that is kicked off by that function. When writing a plugin, you can add to a Zone's data by providing a function as the container for your plugin:
var myZone = function(data){
return {
created: function(){
data.foo = “bar”;
}
};
};
When the Zone's Promise resolves the data is returned as the Promise value:
new Zone().run(function(data){
data.foo;
// -> "bar"
});
This allows you to contain state within a Zone. An example of state that you might want to keep is a document
that you modified during rendering, or if using a Flux layer like Redux it would be the Flux store that was asynchronously updated.
A world of multiple zones
So far in all of our examples there has only been a single Zone used. The power of Zones as a state container comes into view when there are multiple Zones at play.
In this example there are two Zones, each running its own asynchronous code. Inside of the Zone's run function Zone.current always refers to that Zone. This is where the Zone acting as a common context comes into play. All code executed within a Zone:
- Share common globals. Using beforeTask and afterTask a ZoneSpec can override globals (ensuring that code within a zone that uses globals gets their correct values). \
- Shares common metadata. Each Zone has a
zone.data
object that plugins can add values to. A plugin could track a certain type of (non-critical) error within a zone and attach this to the zone's metadata.
The ability to create multiple Zones is important for server-side rendering. The following example simulates what happens in server-side rendering:
- A request comes in and a new Zone is created.
- New
document
andlocation
objects are created as part of the requests Zone. - The zone's
run
function is called. Within the zone it seesdocument
which is always the document created for the zone (same for the location). - An AJAX request occurs for a user and when it returns a
<span>
is added to the document.
This is what happens for each request, but remember that on the server requests are overlapping. Using Zones allows us to isolate each request into a common context.
Next Steps
Now that you know the benefits of Zones to help with the request-isolation problem in server-side rendering you'll want to try it out for yourself. No matter what type of framework you are using, Zones can be used with minimal (if any) changes to your app's core code. Check out our two example apps to get you started:
- jQuery app
- Mercury app (showing one possible use with a virtual-dom library)