#node #ssr #vuejs #vuecember2020

Global state in SSR with Vue and Node.js

When building universal apps instead of Single-Page Applications (SPAs), there are a lot of things to consider. The code is now not only executed in the browser, but also on a Node.js server. This means for example, that we need to write Universal Code in order to avoid errors like “Uncaught ReferenceError: window is not defined”.

We also need to worry about Client Side Hydration, Build Configuration, Caching and many more, which are all in some part covered in the official Vue SSR Guide.

From my experience, one of the most error-prone topics is the process of Client Side Hydration. Sven Wagner and I have already written a blog post about how to understand and solve hydration errors in Vue.js. Alex Lichter wrote a guideline about what do when Vue hydration fails.

The other category of frustrating and hard-to-solve errors arise on the server itself during the process of Server-Side Rendering.

While the browser usually only handles one application, the server handles multiple requests in parallel, as well as successively. In a Node.js environment, these requests are not processed in isolated threads, but within a single thread with one global state. As a result, errors arise when state is not correctly separated between requests. Errors also arise, when the global state is repeatedly extended on every request.

In this blog post I‘d like to give a deeper understanding of the server side lifecycle and the relationship between different execution contexts to help you identify and fix common issues.

The Browser

The friendly Browser environment

In a browser environment there is usually exactly one app, one VueRouter instance and one Vuex instance. Nav, Router etc. are Components that are children of the root component aka app.

This makes a lot of things quite simple such as getting the Vuex instance from within a global beforeEach hook from VueRouter:

// store.js
export const store = new Vuex.Store(%7B
  state: %7B
    authenticated: false
  %7D,
%7D)

// router.js
export const router = new VueRouter(%7B /* ... */ %7D)
router.beforeEach((to, from, next) => %7B
  if (to.name !== 'Login' && !store.state.authenticated) %7B
    next(%7B name: 'Login' %7D)
    return
 %7D
  next()
%7D)

The reason we can use stateful singletons is that there is exactly one instance at a time and it is not shared another request.

In Server-Side Rendering application, things look quite different.

The Server

Contexts in a Server-Side Rendering application

The global context behaves similar to what we are used to from a browser environment. In each node.js process, there is one global, just as there is one window a browser environment. Each loaded JavaScript module is a stateful singleton in this global context.

// counter.js
const list = []
module.exports = %7B 
  list,
  add: entry => list.push(entry)
%7D

// foo.js
const %7B list, add %7D = require('./counter.js')
add('foo')
console.log(list) // [ 'foo' ]

// index.js
const %7B list, add %7D = require('./counter.js')
require('./foo.js')
add('index')
console.log(list) // [ 'foo', 'index' ] - there is only *one* list

Instances of stateful objects per request

Without changing the structure of our code this would mean that all requests share the same stateful singletons. According to the example above, all concurrent requests would always share the same authentication state.

Note: in most applications authentication will not be handled in Server-Side Rendering.

Requests rendering concurrently might interfere with each other, whereas subsequent requests would not start with a “fresh” state, but whatever was left behind.

Broken application with shared apolloProvider

We once experienced the latter with VueApollo which is a Vue plugin for GraphQL integration.

In this case we did not create an apolloProvider for each request and the apollo InMemoryCache kept growing and growing which had two major side effects.

The first one was that we sent a growing payload with the full cache to the browser for Client Side Hydration.

On top of that, the pre-filled cache hid the fact that we did not correctly prefetch all external data on each page load — which was before the serverPrefetch hook was introduced. When the server restarted and the cache was effectively emptied, most pages where empty as well, as most data could not be fetched in time.

The Vue SSR Guide explains how to change the source code structure to avoid stateful singletons. In NuxtJS applications, the “context” helps to access instances of stateful objects that would otherwise be inaccessible, because they can’t be simply “imported”.

However, this is not the only trap one could fall into: there are more stateful singletons around. Such as Vue itself.

Global stateful objects

Vue is a stateful object itself. It stores installed plugins, options from mixins and assets like components and decorators. This can lead to problems if this stateful object is not only changed on application startup, but on each request.

Compare these three cases:

function createPlugin() %7B
  return (vue) => vue.mixin(%7B
    mounted() %7B
      console.log('hi there')
   %7D
  %7D)
%7D

// case 1:
Vue.use(createPlugin())
export const createApp = () => %7B

%7D

// case 2:
const plugin = createPlugin()
export const createApp = () => %7B
  Vue.use(plugin)
%7D

// case 3:
export const createApp = () => %7B
  const plugin = createPlugin()
  Vue.use(plugin)
%7D

In the first case, a plugin is installed — that’s it. There is one change of global state and it stays that way for all requests to come.

In the second case, a plugin is created once, but installed on every request. This could be an issue, but Vue.use checks if a plugin is already installed and therefore the plugin is effectively only installed once.

If, however, the plugin is created per request, the check implemented in Vue.use cannot identify the already installed plugin and installs the new plugin additionally.

The plugin in this example then installs a global mixin, merging with the current options. This can lead to all kinds of regressions from performance issues to memory and as I experienced even a “Maximum call stack size exceeded error”.

If you are aware of this lifecycle and install plugins, mixins and assets at the right time you are already pretty safe. NuxtJS helps with that as long as you don’t use the callback that is provided to NuxtJS plugins to change the Vue singleton.

The last thing that could get in your way, especially in a development setup, where code is recompiled and re-executed, is the bundleRenderer.

The bundleRenderer

The bundleRenderer is what actually does the rendering to an HTML string. This could be done to generate static pages or for processing an HTTP request.

There is one setting which affects which context stateful objects are created in: runInNewContext. It can be set to false , 'once' or true — each resulting in a different behaviour of the application.

Contexts depending on bundleRenderer.runInNewContext

If runInNewContext is set to false, there is nothing to worry about. There is no additional “box”.

If the bundleRenderer is configured to runInNewContext: 'once', there are some caveats which are described in the official docs. However, they are unrelated to what was discussed above.

If runInNewContext is set to true, which is the default setting for a NuxtJS Development Server, the behaviour is quite different:

[…] for each render the bundle renderer will create a fresh V8 context and re-execute the entire bundle.

This means that what seems like a global object from inside the bundle is re-created for every request. This does not only come with some performance cost, but also changes the behaviour of the application.

To understand how the behaviour will change, the first question is: What is part of the server bundle?

By default, webpack bundles everything that is imported in some way by the entry. In most applications, nodeExternals is used to reduce the size of the bundle. This removes everything inside the node_modules folder from the bundle, as it will be available on the server anyways.

NuxtJS does this by default. So every module inside of node_modules will be executed in the global context — and every other module (aka the app) will be re-executed in the bundleRenderer context on every request.

Now consider the three examples discussed above — the createApp function and how behaviour of the application changes depending on where plugins are created and where they are installed. Everything that is part of the bundle, now behaves as if it where inside of the createApp function.

The following examples are based on a NuxtJS application.

Example 1:

// ~/plugins/i18n.js
import Vue from 'vue'
import VueI18n from 'vue-i18n'

Vue.use(VueI18n)

Everything works like a charm. Vue and VueI18n both come from the global context, since both are imported from node_modules. VueI18n will only be installed once effectively.

Example 2:

// ~/plugins/log-$root-created.js
import Vue from 'vue'
Vue.mixin(%7B
  created() %7B
    if(this === this.$root) %7B
      console.log('$root has been created')
    %7D
  %7D
%7D)

If you run a NuxtJS development server with a plugin like this, you will notice that the statement will be logged one additional time with each request. On the 1st request, the statement will be logged once. On the 50th request, the statement will be logged 50 times.

If runInNewContext is set to false, this behaviour disappears!

// nuxt.config.js
export default %7B
  render: %7B
    bundleRenderer: %7B
      runInNewContext: false,
    %7D,
  %7D,
%7D

… but the statement will still be logged once more whenever the code is recompiled after a change, since we’ve got a fresh bundle all together.

Example 3:

// ~/plugins/log-$root-created-once.js
if(!Vue.logRootCreatedInstalled) %7B
  Vue.mixin(%7B
    created() %7B
      if(this === this.$root) %7B
        console.log('$root has been created')
      %7D
    %7D
  %7D)
  Vue.logRootCreatedInstalled = true
%7D

By keeping track of whether we have already performed the installation in the global context, we can avoid the reinstallation even in this case. This fixes our issue independently of the bundleRenderer settings.

Vue 3

In Vue 3, the global API had been changed as “some of Vue’s current global API and configurations permanently mutate global state”.

Bottom line

This topic took me a long time to understand. I hope the images, descriptions and examples given can help you to avoid errors in Server-Side Rendering Applications and understand their behaviour.

❤ You can share your feedback and questions in the accompanying medium blogpost. ❤