Best practices for exposing runtime server env variables for JS client

Matti Bar-Zeev - Apr 30 '21 - - Dev Community

The requirements

In some cases we need to have runtime environment variables available on the JS running on the client.
What exactly does that mean?
Say we have some kind of an API service which requires a certain key to it (not a secret). This key is something that differs from one env to another and this env variable is computed only on runtime and not in build time, therefore cannot be taken into consideration during build time (tools like Webpack’s Define Plugin are not an option).

Another assumption that should be made is that the data on these runtime env variables is global system data and not specific for a user, for instance, we don’t want to have the user’s account id as a part of it.

Some constraints

  • The variables should be available before any other app script executes
  • The time it takes for the runtime env variable to be available on the client side should be reduced to minimum
  • The data should be available from anywhere in the current page which requires it
  • We don’t want different code for different pages, meaning we don’t want that when requesting page A the server returns the doc, but when requesting page B the server does additional logic to provide the runtime env variables, when both pages are derived from the same Single Page App.
  • Bonus: We would like to have the runtime env variables available only for pages which require it

Solutions

Option 1 - Set it on the global scope variable on the doc

Pros

  • No additional request. The variables are available when the page arrives to the client
  • Data is available anywhere from within the SPA

Cons

  • It’s a global JS variable which is considered a bad practice
  • Fetching the main doc takes more time for resolving the constants on the server side
  • The risk of exposing a way to add more and more stuff to the global scope without the ability to enforce what’s valid and what’s not. Can turn pretty quick to a “Garbage bin” where all developers put whatever they want in.

Option 2 - fetch it on demand

Pros

  • You do not pollute the global JS scope
  • No extra work on the server side for fetching the needed constants
  • Only pages which are the variables fetch them

Cons

  • Bad performance experience - Additional expensive call from the client to the server which postpones FID and LCP
  • Scripts which need the variables in order to execute cannot start without it, and so they need to wait. This adds 2 cons really - bad performance and maintaining a logic for the “wait”.

Option 3 - push variables ES6 module JS file using http2

In theory - Creating a script file which exports a module with the global runtime variable. This file will then be pushed over HTTP2 along with the main doc. This should make the variables available as soon as possible while encapsulating them within a module.

Pros

  • We don't pollute the global scope
  • Aligns better with the ES6 modules modem applications are built with

Cons

  • Generating the file on run time involves I/O which will potentially cost even more in performance
  • Might have race-condition since there is no assurance that the module will load by the time its content is needed
  • Chrome engineering claims they will abandon h2 push in the very near future, so this kinda puts a lid on this option as well

Option 4 - Encapsulating the variables in a module inline on the doc

This is like runtime module generation, but we’re creating a script tag with “module” type and attempting to export the env variables from it.
Sadly exporting modules from a script tag is still not supported at the time writing this :(

My verdict

Though I don’t feel 100% comfortable with it, it seems that the best practice to go with is Option 1 - Set it on the global scope variable on the doc.
Here is one way to go about it:

Given that you use EJS as your templating engine you need to set a script tag with a placeholder for the variables you wish to inject into it.

<script>
var GLOBAL_RUNTIME_CONSTANTS = <%- globalConstants %>;
</script>
Enter fullscreen mode Exit fullscreen mode

The dash (“-”) there is for Unescaped buffering. We are going to inject a Stringified JSON there and we would like to avoid it being escaped.
(Of course, don't forget to protect your script tags with CSP nonce, but this is not part of this writing...)

Next, on our server controller we would like to prepare the runtime env variables to be injected. Something like this:

const globalConstants = JSON.stringify({
SOME_RUNTIME_ENV_VAR: value of that var,
});
Enter fullscreen mode Exit fullscreen mode

This will later be injected to the EJS template by the rendering file method.

On the client it keep these 2 things in mind:

  • Wrap the access to these global variables with a service. You never know if someday you will need another impl, so it might be wise to keep the interface intact while you are able to change the undergoing implementation.
  • Use globalThis for it is the best option to support Browser, ServiceWorker and NodeJS environments. Read more about it here.

The service might look like this:

export const getGlobalRuntimeConstantValue = (constantName) => {
   const globalRuntimeConstants = globalThis.GLOBAL_RUNTIME_CONSTANTS;
   if (!globalRuntimeConstants) {
       throw new Error('Global runtime constants are not available');
   }

   const result = globalRuntimeConstants[constantName];
   if (!result) {
       throw new Error(`No global constant was defined with then name "${constantName}"`);
   }

   return result;
};
Enter fullscreen mode Exit fullscreen mode

Now you can call this method from anywhere on your app and get that runtime env variable:

import {getGlobalRuntimeConstantValue} from '../../services/GlobalAccessService';
const RUNTIME_ENV_VARIABLE = getGlobalRuntimeConstantValue(RUNTIME_ENV_VARIABLE);
Enter fullscreen mode Exit fullscreen mode

Conclusion

As always in web development there are probably more ways to accomplish this. If you have something in mind, I’m very interested in hearing about it! share it so we can discuss how well it solves the challenges raised here.

Thanks

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .