Runtime Configuration for SPAs

My software team recently needed runtime configuration in our web app’s frontend. Here, I’ll describe what that means and how we achieved it.

Runtime Config and Why You Need It in Your Frontend

Twelve-Factor defines config as “everything that is likely to vary between deploys (staging, production, developer environments, etc).”

For example, when your backend server retrieves its database connection string from an environment variable, that’s runtime config. Contrast this with build-time config. That’s what you would have if you baked that connection string into your server at compile time.

Initially, backend configuration was enough: our frontend didn’t vary much between deploys. We’ve accumulated a few variances now, though:

  • Link destinations. (e.g., the prod instance of Application A contains links to the prod instance of Application B)
  • Configuration for the identity provider (e.g. Auth0 or Cognito or Firebase). Different deployed environments integrate with different instances of the provider, containing different sets of users. (And, unlike some other integrations, this isn’t solely a server concern. The client browser communicates directly with the IDP.)

Our frontend happens to be a React app, so we could easily put config like this into build-time variables (e.g. with the REACT_APP_ prefix). But, then we’d have to build a unique frontend for each deployed environment.

Goals

We set out to add runtime config with a few goals in mind:

  • The frontend’s config vars should be editable in server environment variables in AWS, just like our backend’s.
  • Changes should take effect immediately. Edit the value in the running server, refresh the browser, and see the new value.
  • In the application code, runtime config values should look like always-available globals.
  • In particular, config should become available as early as possible during application startup, preferably “underneath” React.
  • The frontend and backend should have shared knowledge of what configuration variables are expected to exist. This is possible because we’re already sharing code between the frontend and backend (in a single TypeScript git repo).

Solution

Join me for a tour of what we came up with. Starting from the top:

1. Frontend config values are kept in server-side environment variables.

  • This is where we already have runtime config for the server like the database connection information.
  • We differentiate variables meant for the frontend with a special prefix: REACT_RUNTIME_.

For this example, let’s say we’re adding a variable REACT_RUNTIME_NAV_COLOR=#114477 .

2. Frontend configuration is exposed through a backend endpoint.

The Express endpoint looks like this:


import {
  buildRuntimeConfigObject,
  runtimeConfigKeyName,
} from "core/runtime-config";

apiController.get("/runtime-config.js", (_req, res) => {
  const obj = buildRuntimeConfigObject(process.env);

  res
    // No caching! We want to be able to edit config vars on the server
    // and have them immediately reflected in the client.
    .set("Cache-Control", "no-store, max-age=0")
    .type("application/javascript")
    .send(`window.${runtimeConfigKeyName}=${JSON.stringify(obj)};`);
});

Note that this sends down an exectuable Javascript statement. I’ll share the implementation of buildRuntimeConfigObject() below.

3. The client retrieves the configuration information.

We kept it really simple with a script element:

<script src="/api/runtime-config.js"></script>

Note that this form of <script> is executed immediately. This is a trade-off:

  • ✅ It’s easy to understand.
  • ✅ It was fast to build.
  • ✅ You can count on it taking effect before anything else runs.
  • ❌ It adds an extra network round-trip, harming performance metrics.

4. Values are accessed from window.


import { getRuntimeConfig } from "core/runtime-config";

const color = getRuntimeConfig(window).navColor;

Helper Implementation

Here’s a gist containing implementations of buildRuntimeConfigObject() and getRuntimeConfig() from the samples above. Highlights:

  • The server logs when the environment’s set of REACT_RUNTIME_ variables differs from those expected by the application. They can be either missing or extraneous.
  • The client also logs when it experiences missing or extraneous variables.
  • TypeScript’s template literal types turn the server’s REACT_RUNTIME_FOO_BAR_BAZ into fooBarBaz in the client.

Potential Improvement

I’d like to improve upon the <script src= approach, avoiding the extra network request. The first thing that comes to mind is to instead patch in an inline script (e.g. <script>window.runtimeConfig={/*..*/};</script>). Either:

  • Serving index.html dynamically, adding the <script>every time it’s served.
  • Patching index.html at server startup. (e.g. in the Docker entrypoint)

Do you have any other ideas?

Frontend Runtime Config

This was pretty quick to throw together and enables us to use one build of the frontend for multiple deployment environments. There’s nothing React-specific about this approach, so it ought to work for other JS Frameworks, too.

Have you had a need for frontend runtime config before? How have you handled it?