In this article, I will show you how we reduced our
time-to-market on our native mobile apps using our server driven UI platform.
Traditionally, client apps fetch data (i.e
what to render) from the server and decide
how to render it.
There are a couple of problems with this client-driven UI (CDUI) approach:
- Release bottlenecks impede time-to-market by a few weeks/days.
- Abysmal adoption of new app releases.
- Logic is duplicated on both Android & iOS. With feature evolution, this bifurcation usually leads to divergent app behavior.
- App rigidity ordinarily requires new releases to fix bugs — which is slowed by (1) & (2).
A solution: Server-driven UI (SDUI)
With SDUI, the server decides
how to render, then sends this instruction to the client. This enables new features to be deployed on all platforms simultaneously via a backend change, without releasing new versions of the native apps.
1. Reinventing the browser
“You are just reinventing the browser” is a common reductive snark on SDUI: it suggests native development is condemned to CDUI and that SDUI competes with the web.
There are projects which allow tools to be picked without accepting status-quo constraints: React native enables “native” UI with web tools; Native SDUI enables flexible & minimally-duplicated UI with native tools.
We only offer a way for native client teams to have dynamic UI while eschewing non-native concepts/tradeoffs. Our client libraries are only a thin layer over our design system and each platform’s native UI kit.
2. Violating Appstore rules
This concern mostly stems from conflating downloading remote code with downloading UI config. The SDUI implementation code is contained within the app, goes through the normal app review process, and doesn’t change after review.
Also, while SDUI feels like a nascent paradigm shift, it isn’t entirely new, it’s only gaining popularity recently. It has been around (in some form) since at least 2014.
Fluid – The platform
Fluid is our SDUI platform that decouples the UI from the app release cycle. Essentially, it reduces our
time-to-value by facilitating rapid product iterations.
It’s made up of three discrete systems working together in tandem:
This post focuses only on the clients & templates editor. Subsequent posts will dive into other systems like:
- Backend library: A standalone use-case agnostic library responsible for transforming a set of high-level nested data models into a flatter UI low-level data model. Teams plugin this library within their infrastructure.
- Config management (portal): A
no-codetool that empowers teams to create, scale & localize the user experience (UX). Teams can also configure and run experiments to optimize the user journey.
The Fluid client library is a multi-platform mobile template engine that enables SDUI. It combines a template with a data model to produce native UI efficiently.
The implication of being a library is that you are still in control. Fluid client libraries are only concerned with the UI – rendering the UI and dispatching events on user interaction. (Why I Hate Frameworks)
- Enable SDUI.
- Simple things are simple, and complex things are possible.
- Straightforward setup.
- Extendable to support custom complex UI.
- Agnostic of data serialization format or server.
- As performant as directly working with the platform’s native UI kit.
A template represents a single cohesive reusable fragment/section on a screen. Templates do not know about what screen they’re on or other templates around them. This property maximizes reusability.
How it works
Encoded templates from the server are decoded on the client into a tree of widget models (UI descriptions/blueprints). These models are then used recursively to create a native UI.
The template editor (i.e. the source of templates) and client libraries share the same code (including codecs). Thus, they are always in sync. The clients use
type-safe builders for decoding into strongly typed widget models. As will be shown later, the template editor uses the same
type-safe builders for safely encoding templates.
Widget model & factories
A widget model is a simple class with immutable variables that specifies the features of a native widget. For example:
The widget models and other shared classes are defined once in the common module of the Kotlin multi-platform project.
Widget factories map a model into an actual native widget for each platform. For example:
An expression is an object that returns a single typed value when evaluated. Currently, we support three:
- Literals – e.g: 23, “Mohammed”.
- Placeholders – A symbol subsequently replaced by a value (i.e, a free variable).
- References – for resources bundled in the app like images, colors, etc.
The server pairs a
template-id with data for the placeholders.
Consider a template as a function:
Placeholdersform the formal parameters of the template. Placeholders appear in the template definition.
Data(arguments) are the actual values that substitute/replace placeholders.
Dataappear in the content and are only used for data-binding.
For performance reasons, we separated the data-binding step from template UI creation. This allows an already created UI (in a list) to be recycled and reused.
There are three distinct points where we can analyze the performance of the libraries. When viewed holistically, it’s evident that our client libraries don’t introduce a significant performance cost. In some cases, they lead to a faster UX.
This has varying performance characteristics based on the data serialization format used. A fast binary format can contend with a platform’s native UI creation/inflation mechanism.
Decoding only needs to be done once (on a background thread) and the templates can be reused across multiple screens using a shared pool – if needed.
2. UI Creation
Once Fluid decodes a template, it can be used to create multiple template’s UI instances — super fast.
For example, on Android, Fluid creates widgets programmatically with zero-reflection (similar to Anko). This article claims this strategy can be ~400% faster than Android’s XML-based UI. Our empirical data resonates with this claim – we noticed a 100%+ speed up in our createViewHolder function when using Fluid.
This is where data is applied to a UI instance. This would always be slower than a handcrafted optimized code that updates the UI. However, the difference is negligible – usually in nanoseconds. Thanks to diffing (in properties & collections), upfront decoding, and minimal abstractions, binding is by default fast — without requiring manual optimizations.
One way to implement SDUI is using high-level components as the bedrock abstraction. This is a very common approach. However, we discovered this is only marginally better than CDUI and doesn’t scale – Adhoc primitives end up being created to accommodate product requests.
We went with a pragmatic hybrid incremental solution:
- Expose a limited set of core low-level widgets/components.
2. Expose our design system primitive tokens and widgets.
3. Allow teams to create high-level components — trading flexibility for power.
Specifying design constraints
We are well aware that constraints liberate & liberties constrain. Due to the asymmetry of evolution (speed), where possible & sensible, we preclude design constraints from the client. We rely on the template editor to impose constraints. E.g: A team can require the use of only high-level components in the editor.
Correctness & Complexity
Fluid discourages directly mutating widgets and encourages unidirectional data flow (UDF) – data flows down and events flow up. This eliminates a whole suite of UI bugs as you don’t have to manually sync individual widgets with the respective value(s).
Consequently, Fluid does (simple) UI diffing. The implication of this is that you get performant UI without doing surgical error-prone UI mutations. Working with Fluid feels similar to SwiftUI or Jetpack Compose.
We noticed that these two things decreased UI code complexity -which leads to fewer bugs.
SDUI isn’t exclusive to online-only experiences. Once a template has been downloaded and cached, it can be used to satisfy any rendering requests without trips to the server.
The video below shows our offline homescreen experience powered by Fluid.
Periodically bundling a set of templates into the app binary is also an effective strategy for offline-first features. We utilize this strategy to make our homescreen always available – even when the user is offline after the first install.
Local data sources
The server can be augmented with local data sources. This is especially useful when implementing optimistic UI updates.
We employ prop-tests for rigorously testing widgets and their adherence to a shared contract across platforms. This is more effective than example-based testing. We also use screenshot testing for all production-level templates.
Fluid client library exposes
type-safe builders which form an embedded (Kotlin) DSL suitable for building UIs in a semi-declarative way. The DSL is the substrate of the editor project.
Making changes to the templates follows the common development process: create a PR → QA → Get reviews -> Publish (to the template store automatically).
Since the project is version-controlled (with git), we can easily keep track of where, when, why, and who made a change. If a problematic change manages to slip through QA, we can easily revert to the last working version.
The editor output (artifacts) is published
atomically to a remote store like Amazon’s S3. The artifacts are encoded template definition files that are served to the clients.
Because we use the Kotlin serialization library, we have out-of-the-box support for many data serialization formats: JSON, Protobuf, CBOR, Hocon, Properties, and more.
Although the backend is not concerned with template storage, it usually serves as a proxy to the templates store for clients — to avoid coupling them to a specific storage system. Templates can also be served from a CDN since they are just static files.
Evolution is natural – it’s imperative to factor this knowledge into the design of any system. We are concerned with two types of changes:
1. Representation changes
Any change in a template’s representation results in a change in something we call a template hash.
Template hashes are analogous to HTTP Etags – they are opaque identifiers for a resource (i.e template) and are only used for cache (in)validation. This mechanism allows caches to be more efficient and saves bandwidth, as the client doesn’t need to request a template from the server if it’s already cached.
2. Capability changes
When a new property or widget is created/updated/deleted, the capabilities of the client system change. Fluid maintains a semantic version number that is sent to the server with every request – allowing it to determine what client capabilities are supported. Fluid is both forward and backward compatible.
Fluid is forward-compatible, as older versions can “gracefully” process data designed for newer versions by ignoring parts that it does not understand. All widget properties are optional and have a default value.
|Type of change||Old client behavior|
|New widget||Ignores it|
|Delete widget||Nothing to do|
|New property||Ignores it|
|Delete property||Use default property value|
Fluid is also backward compatible as newer versions can accept & decode data that worked under the previous versions.
We have a few enforced rules that make handling evolution simple:
- Changes must be additive.
- Deprecate a widget or property instead of deleting it.
- Create a new widget or property and deprecate the old one, instead of renaming it.
- Deprecated widgets or properties can be pruned after a relevant app forced-update.
Full-compatibility guarantees that both old and new clients can co-exist without technical issues – however, it doesn’t eliminate possible UX issues. For example, when a new capability is ignored, does the UI still render “properly”?
There are three possible resolutions:
- Ignore: Some cosmetic features can be safely ignored on old versions.
- Alternative design: Designers might want to fall back to an alternative design. The DSL helps with specifying designs as a function of a client’s version. It also provides version-related warnings.
- Hide the content on older versions.
State of the art
Last year, I compiled a list of resources while researching the SDUI space.
This section talks about other SDUI implementations. Practices are contextual, and there’s usually no such thing as “best practice” – just a bunch of trade-offs. There are lots of similarities among the implementations below and one isn’t necessarily superior to another.
- High-level components: As mentioned above, we abandoned this approach due to its limitation. Because templates are not built like lego pieces, changes need to be anticipated, or else they are difficult to make. For example, changing the padding between an image and a text inside a section might have all the problems of CDUI.
- Screen-level integration: GP seems to be used to describe screens. Although Fluid can also be used to describe screens, it’s usually used within a screen. Fluid interops fully with the native UI toolkits – they can be used inside or alongside Fluid.
- Clients’ framework: We wanted simple libraries where an engineer is in full control. Fluid is unencumbered with various screens idiosyncrasies.
- Backend tools: We also have opinionated & scalable backend tools, but these are optional. For a hackathon or simple use-case, engineers can use S3 or an A/B testing tool to serve templates & data.
Doordash also recently wrote about their SDUI platform. There are also a few notable differences:
- High-level components: As noted in the next steps in their post, they are also facing similar flexibility issues with this approach.
- Type-safety: They use a custom property (without type-safety) to accommodate niche use-cases. In Fluid, every property is type-safe and has a default value.
Thanks to everyone that helped with writing this post:
- Fábio Carballo
- Abiola Malik
- Ahmed Khaled Mohammed
- Azamat Murzagalin
- Kingsley Adio
- Mayank Dixit
- Miloš Marinković
- Satabdi Biswas
Thank you Elvis and team for sharing your learnings with us!