You're reading the free online version of this book. If you'd like to support me, please considering purchasing the e-book.

Server-Side vs. Client-Side

In a multi-page application, the page is newly rendered after each browser navigation event. As such, there's no meaningful difference between a feature flag used on the server-side vs. one used on the client-side. Any changes made to a feature flag's configuration will propagate naturally to the client-side upon navigation.

With single-page applications (SPAs), the client-side code comes with life-cycle implications that require us to think more carefully about feature flag consumption. In a SPA, the client-side code—often referred to as a "thick client"—is composed of a relatively large HTML, CSS, and JavaScript bundle. This bundle is cached in the browser; and, navigation events are fulfilled via client-side view-manipulation with API calls to fetch live data from the back-end.

One benefit of loading the SPA code upfront is that—from a user's perspective—subsequent navigation events appear very fast. This is especially true if the client-side logic uses optimistic updates and stale while revalidate rendering strategies.

One downside of loading the SPA code upfront is that the version of the code executing in the browser may become outdated quickly. Each application is going to have its own usage patterns; but, for a business-oriented app, it's not uncommon for a SPA to remain open all day. Or, in the case of an email client, to remain open for weeks at a time.

Which means that if you deploy new code every hour, there's a good chance that the code in the browser will be several versions behind the code you just deployed to production. On top of that, if the SPA makes an API request to the server mid-deployment (or, mid-roll-back), the mismatch in versions can go in any direction:

  • Old client code makes API request to old server instance.
  • Old client code makes API request to new server instance.
  • New client code makes API request to old server instance.
  • New client code makes API request to new server instance.

Because of this, we have to code defensively and expect failure. This extends to all parts of SPA-based product development; but, it's particularly relevant for feature flags since feature flags are intentionally temporary constructs within the application.

Include Feature Flags in the View Data

In a SPA application, the easiest way to share state between the server-side and the client-side is to include state in the initial rendering of the client-side application:

<html>
<body>
	<script type="text/javascript">
		// Include some initial state that the
		// SPA client can consume.
		window.config = JSON.parse(
			"#encodeForJavaScript( initialState )#"
		);
	</script>
</body>
</html>

This encodes the server-side state as a JSON payload, which is then parsed in the browser and made available in the client-side context. This allows the client-side SPA to bootstrap without making any additional API calls; but, since the initial state is only rendered once, the client-side state will become stale once the application starts.

Security note: Encoding user-provided data into an HTML page is always dangerous and can lead to persisted cross-site scripting attacks. As such, always be sure to securely encode such data. In the above example, we're using encodeForJavaScript() to escape any potentially malicious content.

This staleness includes the state of any feature flags. And, since the whole point of feature flags is to enable dynamic runtime behaviors, we have to find another way to deliver feature flag state to the client-side. In a SPA architecture, most views are backed by at least one API request. I recommend including all view-relevant feature flags in this API response payload.

Imagine that we have an application view that renders a list of users. And, that we're developing a new user-management feature which allows users to be deleted. During development, we'll have a feature flag that hides this action. The API response for such a view should contain both the list of users and the feature flag state:

var canDeleteUsers = features.getVariant(
	"user-deletion",
	{
		key: request.user.id,
		userEmail: request.user.email
	}
);

// Construct the API response for the list of users.
var apiResponse = {
	users: [
		{ ... },
		{ ... },
		{ ... }
	],
	permissions: {
		canDeleteUsers: canDeleteUsers
	}
};

In the client-side view rendering, the permissions object can then be consumed as a means to conditionally include HTML elements and gate the ingress to the new feature:

<button ng-if="permissions.canDeleteUsers">
	Delete user
</button>

By leaning on the server to provide the feature flag state in the API response, we get several benefits. First, this keeps the permissions model synchronized between the server and the client even if the client-side code is lagging behind the currently-deployed version.

This is helpful when we turn new features on; but, it's even more important during an emergency. If a new feature is causing a problem—such as placing too much load on the server—we need to know that turning off the feature flag will immediately propagate to the client (the next time the user goes to render the relevant view).

DDOS Consideration: If the feature that you're building includes a polling operation—that is, the client-side view makes periodic, interval-based requests back to the server—it's worth including the feature flag state in the polling response as well. A small bug in your polling logic can easily lead to an accidental Denial of Service attack on your own servers. By including the feature flag state in the polling response, you can disable the feature even before the user refreshes the current view.

Another benefit of evaluating feature flag state on the server is that we can use more information in our user targeting. This includes information that we might not want exposed in the browser (such as the user's IP address or email address).

Lastly, by using the API response to deliver feature flag state, it's one less thing that we have to build and maintain. Meaning, no additional API end-points have to be created specifically for feature flag state; and, no additional technologies—such as WebSockets—have to be used for state synchronization. All we have to do is build upon the techniques that our application is already using.

Pro-tip: Never construct an API response as an array. All API responses should be constructed as objects. And, if an array of data is expected, it should be provided as a top-level key in said object. This allows the API response payload to be augmented as needed without breaking the consumer contract. Imagine if our user list API response had been designed as an array—where would we have put the permissions payload? Switching from an array to an object would have meant making changes to both the server-side and the client-side code.

Expect Feature Flags to Disappear

By their nature, feature flags are a temporary construct within the application. They allow us to build new features safely and incrementally. But, once those new features get rolled-out to customers, the feature flags need to be cleaned-up and removed.

Which means our client-side code has to expect the relevant values to be available in one request and then gone in a subsequent request. This means cutting against the grain of how JavaScript works naturally.

Consider the permissions.canDeleteUsers feature flag consumer from above:

ng-if="permissions.canDeleteUsers"

If the feature flag is currently enabled, this JavaScript expression evaluates to true. However, if the feature flag has been removed from the server-side code, this client-side expression will evaluate as undefined.

In JavaScript, undefined is a Falsy. Which means, in our dynamic view rendering:

<button ng-if="permissions.canDeleteUsers">
	Delete user
</button>

... our ng-if attribute expression goes from true in one request to undefined in the next. Which means that when the feature flag is removed from the server, the feature itself is removed from the client.

But, our intention wasn't to remove the feature, it was to commit to the feature. As such, in order to handle this transient value gracefully, we need to differentiate between false and undefined. false means that the feature is off and undefined means that the feature is on; or rather, that we no longer care about the permissions check.

Our dynamic view logic must, therefore, be changed from an implicit Truthy check:

ng-if="permissions.canDeleteUsers"

... to an explicit value check:

ng-if="( permissions.canDeleteUsers !== false )"

This way, our client-side user experience treats permissions.canDeleteUsers the same whether it evaluates to true or undefined. Only an explicit false will deactivate the feature.

Unfortunately, this logic cuts both ways. Meaning, when the client-side code receives an undefined value where it once expected feature flag state, it has no way of knowing whether we were committing to the feature; or, if we were removing the feature as the result of a failed experiment.

In the case of a failed experiment, we don't want the client-side code to accidentally enable the feature's UI. As such, we need to make sure that the server-side code returns an explicit false even after we remove the feature. And, we need to keep doing this as long as there is outdated client-side code running "in the wild".

But, we don't want this to become a blocker for our clean-up process. Removing dead code from an application is of paramount importance. This is especially true in the case of feature flags, which obfuscate the runtime expectations of control flow.

To allow for this clean-up to occur without breaking the client-side experience, we can hard-code the false value right into the API response. I like to accompany this with a dated TODO comment:

var apiResponse = {
	users: [
		{ ... },
		{ ... },
		{ ... }
	],
	permissions: {
		// TODO: This feature was removed as part of a
		// failed experiment. However, we need to leave
		// this permission in place so that we don't
		// break any existing client-side code. Let's
		// give the browsers a month to refresh before
		// we remove this (today: Oct 28, 2023).
		canDeleteUsers: false
	}
};

Any browser that starts running the most up-to-date code won't have any references to the canDeleteUsers permission; and so, this value in the API response is simply ignored. However, any browser that continues to run the old code—because the user hasn't refreshed the page—will see the false value and hide the old feature's user interface elements.

Keep It Simple

The SPA architectural pattern is already fairly complex. When introducing feature flags, this complexity is compounded. As such, we must strive to keep our client-side code simple:

  • As much as possible, defer feature flag evaluation to the server-side. This decouples the notion of permissions from the implementation; and, reduces the amount of business logic on the front-end.

  • As much as possible, use feature flags to gate the ingress to new features. This means, conditionally rendering a link or a button; and then, isolating the rest of the changes inside a new component or view (which doesn't have to know about the feature flag).

  • As much as possible, separate style changes from functional changes. Style changes are harder to gate behind a feature flag since CSS selectors and HTML structure are tightly coupled. If styles changes do have to be gated, try to reduce the gating to a conditional CSS class name without making too many HTML changes.

  • As much as possible, avoid passing feature flag state around. This includes component attributes and input bindings. If a component needs to be powered by a feature flag, either read the feature flag state from inside the component (if it does its own data fetching); or, create a new component with the new behavior (using copy-paste-modify) and then conditionally render this new component.

And, of course, always, always, always clean up after yourself. With an industry shift towards SPA-based architectures, development is becoming increasingly complex. Between rich interactions, egregious animations, and long-lived state, front-end developers are already struggling to build bug-free, high-performance user experiences. When you leave feature flags and dead code in the client-side bundle, you make all of these problems worse.

Have questions? Let's discuss this chapter: https://bennadel.com/go/4549