in development

Hot Reloading React app with server-side rendering

Hot Reloading is a tricky thing. The configuration is very fragile and only senior webpack developer can set it up. That’s why there’re a lot of guides and example repos with working setup. We’ve encountered a little bit exotic case: Hot Reloading for React application in monorepo for both client and server-side code. Example repos brought us to this quest.

TLDR

The best way to hot-reload universal React application is to use the webpack Hot Module Replacement (HMR) API for server-side code and React-Hot-Loader (RHL) for client entry. The client configuration is pretty simple. Just follow instructions from RHL repo. To reload server bundle on the fly look here.

Initial Hot Reloading approach

We bumped into the issue with our initial approach for hot-reloading server-side. It was done via re-requiring server build result on each source file change and updating Koa middleware handler with the received module. Specifically, we used require-from-string package from NPM to do that.

This is how it worked step by step (code is simplified a bit):

  1. Create Koa (or some other node.js) server that will serve your application.
    // Application middleware pointer that will be updated on each build
    let serverMiddleware
    
    const app = new Koa().use((ctx: Koa.Context, next: Koa.Middleware) => {
      serverMiddleware(ctx, next)
    )
  2. Create server webpack compiler.
    const serverCompiler = webpack(serverWebpackConfig)
  3. Start webpack watch mode. Calling the watch method triggers the webpack runner, but then watches for changes (much like CLI: webpack --watch)
    const { outputPath, outputFileSystem: memoryFs } = serverCompiler
    
    compilerServer.watch({}, (error: Error, serverStats: Stats) => {
      // Build location example: serverStats.toJson().assetsByChunkName.main
      const serverPath = getServerPath(serverStats, outputPath);
      const serverBuffer = memoryFs.readFileSync(filename)
    
      // Delete previous version of the server module from cache
      delete require.cache[require.resolve(serverPath)]
    
        const updatedServerMiddleware = requireFromString(
        buffer.toString(),
        filename
      ).default
    
      // Point `serverMiddleware` to the updated server module
        serverMiddleware = updatedServerMiddleware
    });
  4. In watch callback, receive path to a fresh server build, created by webpack after some source file change.
  5. Read this file using memoryFs into a buffer.
  6. Get the updated server module using require-from-string.
  7. Use this module as the main application middleware in your server.

Webpack watch handler will be executed on each source-file change resulting in hot-loading of the updated server module. Boom! We don’t need to completely restart the server on each change to get the updated behavior. Quite simple, right?

Strange memory leaks

This approach worked flawlessly while we had a small application. But in a couple of months, we noticed that the server started crashing in development mode after some running time. It looked like an issue with poor integration of react-loadable from our side first, because there were React warnings in the console about updating state in an unmounted component. It was inconvenient, but not the end of the world. Once in an hour, we needed to do manually restart the server. But a month later it got much worse. After 5-8 edits server crashed — literally every 5 minutes. We couldn’t ignore the issue anymore.

Memory leaks in other libraries

We started tackling the issue by debugging memory leaks in the whole application without thoughts specifically about hot-reload. We used Chrome Devtools to debug memory allocation. Specifically, JS heap snapshots were very helpful.

  1. First, start application in debug mode and open Chrome Devtools attached to it. Type about:inspect in Chrome url and click inspect under your target app.
    node --inspect-brk start-dev-server.js
  2. Navigate to the Profiles tab in opened Devtools, and click the “Take Snapshot” button of the profiling type panel.
  3. Once you do, the heap snapshot will be created, and you’ll be presented with the summary view.
  4. Then work a little bit in the application. Do stuff that may cause memory to grow (navigate some pages, click around). Then create another heap snapshot. Now we have two snapshots to compare. Thanks to Chrome Devtools using Comparison view, we can see exactly what’s changed since we took the first snapshot.
  5. Sort comparison view by retained size to see where the bottleneck is. Objects in constructor column may be expanded to show instancescreated using this constructor. Try to figure why they’re not garbage collected via looking at the Objects retaining tree. Usually, they are attached to the global scope somewhy, which prevents them from being destroyed between server rerenders. Then memory grows after each request to the server.

We found a bunch of memory leaks. Our app was bleeding heavily. Here’re some links in case it might help somebody with the same issue: styled-components, apollo-link-rest, koa-webpack, react-apollo, react-final-form. After fixes things got better. Now server crashed once in 10-15 mins. Yay! And still terrible DX.

Culprit memory leak

Spoiler alert! Server hot-reloading created the main issue for our dev environment. Memory grew significantly after each require of the updated server bundle. Despite require.cache cleanup, some objects of the previous server bundle were still linked to the node process context. That’s why requiring module, again and again, resulted in the duplicated modules in memory.

Here’s the example of leaked retaining tree:

Lodash util _baseIsNative is required multiple times. It was caused by preserving the previous context of each server bundle. It’s done by lodash wrapper named runInContext which is used to create lodash instance. If context is not provided global context is used which results in this of main node process.

// Create a new pristine `lodash` function using the `context` object.
var lodash = _.runInContext();

var runInContext = (function runInContext(context) {
    context = context == null ? root : _.defaults(root.Object(), context, _.pick(root, contextProps));

    /** Built-in constructor references. */
    var Function = context.Function

    /** Used to resolve the decompiled source of functions. */
    var funcToString = funcProto.toString;

    // ...

Simplified code example:

// In Lodash closure
var funcToString = global.Function.toString;

This assignment links lodash closure to the main process global which prevents it from being garbage collected even if we delete the previous server bundle from require.cache. And lodash closure exists in the closure of each server bundle which means that removing the link to it from the cache we didn’t free our memory from it. It will here forever. And because we were developing fast, a number of modules stedily grew. A number of bytes allocated per each server bundle grew too. That’s why we experienced worsening situation where we encountered out of memory crash more frequently each week.

HMR to the rescue

So, we’ve tried to find a way to clean up the memory using the same re-require approach but failed. We needed a way to update our server bundle only with what’s changed without re-requiring the whole thing. We wanted to patch it with updates. And that’s exactly what webpack Hot Module Replacement API is for.

After some tumbling around with many vague ideas we’ve found HMR approach implemented in React starter-kit. It took some time to understand the whole thing.

So here’s the breakdown of how it works:

  1. Create Koa (or any other node.js) server that will serve your application
    // Store object with pointer to the middleware that will be updated
    let serverStore = {
      middleware: null,
      hotApi: null,
    }
    
    const app = new Koa().use((ctx: Koa.Context, next: Koa.Middleware) => {
      serverStore.middleware(ctx, next)
    )
  2. Enable webpack HotModuleReplacement plugin.
    // webpack.config.server.ts
    plugins: [new HotModuleReplacementPlugin()],
  3. In the server entry file export hot module API that HotModuleReplacementPlugin enabled.
    // Export hot module API to use it later for hot-reloading
    export const { hot } = module;
    export { default } from './Server'
  4. Create webpack compiler using server webpack config.
    const serverCompiler = webpack(serverWebpackConfig)
  5. Start webpack watch mode.
    const { outputPath, outputFileSystem: memoryFs } = serverCompiler
    
    compilerServer.watch({}, (error: Error, serverStats: Stats) => {
      // On the first compilation just require server module
      if (!serverStore.middleware)  {
        const server = requireFromString(serverStats)
    
        // Save `default` export and `hotApi` into a serverStore
        serverStore.middleware = server.default
        serverStore.hotAPI = server.hot
      } else {
        tryHotServerUpdate(serverStore, serverStats)
      }
    });
  6. On the first execution of the watch callback just require server module from the memoryFs as described in the Initial Hot Reloading approach above. Apart from getting the module itself from the default export now we have access to hot module API that we exported from the server entry. Save them into a serverStore object.
  7. On the second execution of the watch callback we already have serverStore.middleware along with serverStore.hotApi. Which means we can use to try hot-reload the server module.
  8. The idea of how to use hot API was grabbed from webpack-hot-client. Specifically from here. Here’s the modified version of checkForHotUpdate function:
    const SHOULD_APPLY_UPDATE = true;
    const HOT_IDLE_STATUS = 'idle'
    const FAIL_STATUSES = ['abort', 'fail']
    
    export async function tryHotServerUpdate(serverStore, serverStats) {
      const hot = runtime.hotAPI;
    
      if (hot.status() !== HOT_IDLE_STATUS) {
        return;
      }
    
      try {
        const updatedModules: string[] = await hot.check(SHOULD_APPLY_UPDATE);
    
        if (!updatedModules || updatedModules.length === ) {
          console.warn('Cannot find update.');
    
          return;
        }
    
        console.log('Updated modules:');
        updatedModules.forEach(moduleId => console.log(` - ${moduleId}`));
        console.log('Update applied.');
      } catch (err) {
        if (FAIL_STATUSES.includes(hot.status())) {
          console.warn(`Cannot apply update. ${err}`);
    
          // Fallback to hard-reload if hot API failed
          const server = await requireFromString(serverStats)
    
            serverStore.middleware = server.default
            serverStore.hotApi = server.hot
        } else {
          console.error('Update failed:', err);
        }
      }
    }
  9. Here we try to hot-reload using hot API. If it fails – fallback to require that we used to load server module on the first watch callback.

That’s it. Now server hot-reloads itself on each edit without any memory leaks. Also, it works faster than re-require approach because we don’t need to process the whole server module. We work only with hot-patches generated by HotModuleReplacementPlugin. Yay, profit!

Thanks for reading that far. If you have an idea on how to improve this approach or know another one which is better in any way, shoot us an email or leave the comment below. Cheers! 🙂

Write a Comment

Comment