Exploring CHAOS: Building a Backend for Server-Driven UI
-
Jonathan Baird, Software Engineer; Xin Shen, Software Engineer
- Jul 8, 2025
A little while ago, we published a blog post on CHAOS: Yelp’s Unified Framework for Server-Driven UI. We strongly recommend reading that post first to gain a solid understanding of SDUI and the goals of CHAOS. This post builds on those concepts to delve into the inner workings of the CHAOS backend and how it generates server-driven content. To briefly recap, CHAOS is a server-driven UI framework used at Yelp. When a client wants to display CHAOS-powered content, it sends a GraphQL query to the CHAOS API. The API processes the query, requests the CHAOS backend to construct the configuration, formats the response, and returns it to the client for rendering.
CHAOS API
The CHAOS backend accepts client requests through the GraphQL-based CHAOS API. At Yelp, we have adopted Apollo Federation for our GraphQL architecture, utilizing Strawberry for federated Python subgraphs to leverage type-safe schema definitions and Python’s type hints. The CHAOS-specific GraphQL schema resides in its own CHAOS Subgraph, hosted by a Python service.
This federated architecture allows us to manage our CHAOS-specific GraphQL schema independently while seamlessly integrating it into Yelp’s broader Supergraph.
Behind the GraphQL layer, we support multiple CHAOS backends that implement a CHAOS REST API to serve CHAOS content in the form of CHAOS Configurations. This architecture allows different teams to manage their CHAOS content independently on their own services, while the GraphQL layer provides a unified interface for client requests. The CHAOS API authenticates requests and routes them to the relevant backend service, where most of the build logic is handled.
CHAOS Backend
The primary goal of a CHAOS backend is to construct a CHAOS SDUI Configuration. This data model encompasses all the information needed for a client to configure a CHAOS-powered SDUI view. Below is an example of a CHAOS view called “consumer.welcome” and its configuration:
{
"data": {
"chaosView": {
"views": [
{
"identifier": "consumer.welcome",
"layout": {
"__typename": "ChaosSingleColumn",
"rows": [
"welcome-to-yelp-header",
"welcome-to-yelp-illustration",
"find-local-businesses-button"
]
},
"__typename": "ChaosView"
}
],
"components": [
{
"__typename": "ChaosJsonComponent",
"identifier": "welcome-to-yelp-header",
"componentType": "chaos.text.v1",
"parameters": "{\"text\": \"Welcome to Yelp\", \"textStyle\": \"heading1-bold\", \"textAlignment\": \"center\"}}"
},
{
"__typename": "ChaosJsonComponent",
"identifier": "welcome-to-yelp-illustration",
"componentType": "chaos.illustration.v1",
"parameters": "{\"dimensions\": {\"width\": 375, \"height\": 300}, \"url\": \"https://media.yelp.com/welcome-to-yelp.svg\"}}"
},
{
"__typename": "ChaosJsonComponent",
"identifier": "find-local-businesses-button",
"componentType": "chaos.button.v1",
"parameters": "{\"text\": \"Find local businesses\", \"style\": \"primary\"}, \"onClick”: [\"open-search-url\"]}"
}
],
"actions": [
{
"__typename": "ChaosJsonAction",
"identifier": "open-search-url",
"actionType": "chaos.open-url.v1",
"parameters": "{\"url\": \"https://yelp.com/search\"}"
}
],
"initialViewId": "consumer.welcome",
"__typename": "ChaosConfiguration"
}
}
}
The configuration includes a list of views, each with a unique identifier and a layout. If there are multiple views, the initialViewId specifies which view should be displayed first. The layout, such as the single-column layout in this example, organizes components into sections based on their component IDs, helping the client determine the positioning of components within the CHAOS view.
Additionally, the configuration lists components and actions, detailing their settings as referenced by their respective IDs. Each component may have its own action, such as an onClick action for a button. A screen may also have actions triggered at specific stages, such as onView, for purposes like logging.
CHAOS Elements
In CHAOS, components and actions are the fundamental building blocks. Instead of defining individual schemas for each element in the GraphQL layer, we use JSON strings for element content. This approach maintains a stable GraphQL schema and allows for rapid iteration on new elements or versions.
To ensure proper configuration, each element is defined as a Python dataclass, providing a clear interface. Type hinting guides developers on the expected parameters. These components and actions are available through a shared CHAOS Python package. For example, a text component could be structured as follows:
@dataclass
class TextV1(_ComponentData):
value: str
style: TextStyle
color: Optional[Color] = None
textAlignment: Optional[TextAlignment] = None
margin: Optional[Margin] = None
onView: Optional[List[Action]] = None
onClick: Optional[List[Action]] = None
component_type: str = "chaos.component.text.v1"
text = Component(
component_data=TextV1(
value="Welcome to Yelp!",
style=TextStyleV1.HEADING_1_BOLD,
textAlignment=TextAlignment.CENTER,
)
)
These dataclasses internally handle the serialization of Python dataclasses to JSON strings, as shown below:
{
"component_type": "chaos.text.v1",
"parameters": "{\"text\": \"Welcome to Yelp\", \"textStyle\": \"heading1-bold\", \"textAlignment\": \"center\"}}"
}
These basic components and actions, when combined with container-like components such as vertical and horizontal stacks—which organize elements in a vertical or horizontal sequence—enable powerful UI building capabilities.
Building a Configuration
In this section, we will explore how CHAOS constructs a configuration. Although the process can be complex, the shared CHAOS Python Package, which also contains CHAOS elements, provides Python classes that manage most of the build process in the background. This allows backend developers using the CHAOS SDUI framework to focus on configuring their content. Below is a high-level overview of the build process, with subsequent sections examining each step in detail.
Step 1: Request
When a client sends a GraphQL query to the CHAOS API, it provides a view name and context. The view name is used to route the request to the relevant CHAOS backend to build the configuration. The context, a JSON object, is forwarded to the backend and includes information such as client specifications or feature specifications. This allows the backend to customize the build for each client request.
To illustrate this process, here is a simplified request from a mobile device that demonstrates how to retrieve a CHAOS view named “consumer.welcome”:
POST /graphql
Request Body:
{
"query": "
query GetChaosConfiguration($name: String!, $context: ContextInput!) {
chaosConfiguration(name: $viewName, context: $context) {
# The actual fields of ChaosConfiguration would be specified here
...ChaosConfiguration Schema...
}
}
",
"variables": {
"viewName": "consumer.welcome",
"context": "{\"screen_scale\": \"foo\", \"platform\": \"bar\", \"app_version\": \"baz\"}"
}
}
Upon receiving the request, the CHAOS subgraph routes it to a CHAOS backend service for further processing.
Step 2: View Selection
An individual CHAOS backend can support various CHAOS views. The ChaosConfigBuilder
allows backend developers to register their ViewBuilder
classes, which manage individual view builds. Upon receiving a request, the encapsulated logic in ChaosConfigBuilder
selects the relevant ViewBuilder
based on the request’s view name and executes the view build steps, constructing the final configuration. Here is a simplified example of using ChaosConfigBuilder
in practice:
Simplified example to illustrate the use of ChaosConfigBuilder
from chaos.builders import ChaosConfigBuilder
from chaos.utils import get_chaos_context
from .views.welcome_view import ConsumerWelcomeViewBuilder
def handle_chaos_request(request):
# Obtain the context for the CHAOS request
context = get_chaos_context(request)
# Register the view builders supported by this service.
ChaosConfigBuilder.register_view_builders([
ConsumerWelcomeViewBuilder,
# Add other view builders here
])
# Build and return the final configuration
return ChaosConfigBuilder(context).build()
Step 3: Layout Selection
Each view has a ViewBuilder class, which selects the appropriate layout and manages the construction of the view.
CHAOS supports different layouts. For example, a single-column layout, as shown in the previous example, has only one “main” section. Other layouts, such as a basic mobile layout, include additional sections like a toolbar and footer. This flexibility allows content to be presented differently across various clients, such as web and mobile, to accommodate different client characteristics.
Each supported layout type in CHAOS has a corresponding LayoutBuilder. This class accepts a list of FeatureProvider
classes (described in detail later) for each section. The order of FeatureProviders within each section determines their order when rendered on the client.
Continuing with the welcome_consumer example, the ViewBuilder looks like this:
Simplified and illustrative example of a ViewBuilder in CHAOS.
from chaos.builders import ViewBuilderBase, LayoutBuilderBase, SingleColumnLayoutBuilder
from .features import WelcomeFeatureProvider
class ConsumerWelcomeViewBuilder(ViewBuilderBase):
@classmethod
def view_id(cls) -> str:
return "consumer.welcome"
def subsequent_views(self) -> List[Type[ViewBuilderBase]]:
"refer to 'Advanced Features - View Flows' section for details"
return []
def _get_layout_builder(self) -> LayoutBuilderBase:
"""
Logic to select the appropriate layout builder based on the context.
"""
return SingleColumnLayoutBuilder(
main=[
WelcomeFeatureProvider,
],
context=self._context
)
When the ChaosConfigBuilder
executes the ViewBuilder
’s build steps, it internally invokes the _get_layout_builder() method to determine the appropriate LayoutBuilder
and execute its build steps. In this example, the method returns a SingleColumnLayoutBuilder, which is structured with a single section named “main”. This section contains only one feature provider: WelcomeFeatureProvider. The LayoutBuilder will then execute the FeatureProvider’s build process, which constructs the configuration for the feature’s SDUI.
Step 4: Build Features
A feature’s SDUI comprises one or more components and actions that collectively fulfill a product purpose, allowing users to view and interact with it on the Yelp app. Feature developers define each feature by inheriting from the FeatureProvider class, which encapsulates all the logic required to load feature data and configure the user interface appropriately.
Each FeatureProvider builds its feature by going through the following major steps:
class FeatureProviderBase:
def __init__(self, context: Context):
self.context = context
@property
def registers(self) -> List[Register]:
"""Sets platform conditions and presenter handler."""
def is_qualified_to_load(self) -> bool:
"""Checks if data loading is allowed."""
return True
def load_data(self) -> None:
"""Initiates asynchronous data loading."""
def resolve(self) -> None:
"""Processes data for SDUI component configuration."""
def is_qualified_to_present(self) -> bool:
"""Checks if configuration of the feature is allowed."""
return True
def result_presenter(self) -> List[Component]:
"""Defines component configurations."""
A view can contain multiple features, and during the build process, all features are built in parallel to enhance performance. To achieve this, the feature providers are iterated over twice. In the first loop, the build process is initiated, triggering any asynchronous calls to external services. This includes the steps: registers
, is_qualified_to_load
, and load_data
. The second loop waits for responses and completes the build process, encompassing the steps: resolve
, is_qualified_to_present
, and result_presenter
. (It is worth mentioning that the latest CHAOS backend framework introduces the next generation of builders using Python asyncio, which simplifies the interface. This will be explored in a future blog post.)
Check Registers
The Register
class in CHAOS is crucial for ensuring that any SDUI content returned to the client is supported. Each register specifies:
- Platform: The platforms (e.g., iOS, Android, web) for which the registered configuration is intended.
- Elements: The required components and actions in this configuration that the client must support. Internally, we maintain information about which components and actions supported by a given client platform type and app version, which is used for verification.
- Presenter Handler: The associated handler (e.g., result_presenter) responsible for constructing the configuration if all conditions are met.
During setup, developers can define multiple registers, each linked to a different handler. Based on the client information provided to the backend, the presenter handler of the first qualifying register is selected to build the configuration. If no register qualifies, the feature is omitted from the final response.
Check Qualification to Load
The qualification step, is_qualified_to_load
, allows developers to perform additional checks to decide whether the feature building process should continue and if feature data should be loaded. This is typically where feature toggles are applied or experimental checks are conducted. If this step returns false, the feature will be excluded from the final configuration.
Async Data Loading and Resolve
During the load_data
stage, we initiate asynchronous requests to upstream services in parallel. We defer resolving and blocking for results to the resolve
stage. This approach enables efficient dispatch of requests and data sharing in all feature providers, optimizing performance by resolving data at a later stage.
Check Qualification to Present
The qualification step, is_qualified_to_present
, allows developers to perform additional checks to determine whether a feature should be included in the configuration. This is especially useful when data fetched during the loading step is needed to decide if the feature should be displayed. If this returns false, the feature will be dropped from the final configuration.
Configure the Feature
This is the stage where we configure the components and actions that constitute the feature. In the FeatureProvider
code, this is represented by the result_presenter
method. Developers can define multiple presenter handlers. The one selected in the registers will serve as the final handler for the feature.
Back to the example, the WelcomeFeatureProvider
feature is shown to users when it meets the following conditions: the requesting client is on an iOS or Android platform, and the client supports the required CHAOS elements (TextV1
, IllustrationV1
, ButtonV1
). If satisfied, an asynchronous request fetches button text in the load_data
method, which is then processed in the resolve
method. The result_presenter
method configures and displays the welcome text, illustration, and button with the fetched text.
class WelcomeFeatureProvider(ProviderBase):
@property
def registers(self) -> List[Register]:
return [
Register(
condition=Condition(
platform=[Platform.IOS, Platform.ANDROID],
library=[TextV1, IllustrationV1, ButtonV1],
),
presenter_handler=self.result_presenter,
)
]
def is_qualified_to_load(self) -> bool:
return True
def load_data(self) -> None:
self._button_text_future = AsyncButtonTextRequest()
def resolve(self) -> None:
button_text_results = self._button_text_future.result()
self._button_text = button_text_results.text
def result_presenter(self) -> List[Component]:
return [
Component(
component_data=TextV1(
text="Welcome to Yelp!",
style=TextStyleV1.HEADER_1,
text_alignment=TextAlignment.CENTER,
)
),
Component(
component_data=IllustrationV1(
dimensions=Dimensions(width=375, height=300),
url="https://media.yelp.com/welcome-to-yelp.svg",
),
),
Component(
component_data=ButtonV1(
text=self._button_text,
button_type=ButtonType.PRIMARY,
onClick=[
Action(
action_data=OpenUrlV1(
url="https://yelp.com/search"
)
),
],
)
)
]
What About Error Handling?
In an SDUI view with multiple features, error handling is essential. In a data-intensive backend, upstream requests might fail, or unexpected issues could occur. To prevent a complete CHAOS configuration failure due to a single feature’s issue, each FeatureProvider is wrapped in an error-handling wrapper during the CHAOS build process. If an exception occurs, the individual feature is dropped, and the rest of the view remains unaffected. Unless developers choose to mark the feature as “essential,” meaning its failure will affect the entire view.
Simplified pseudo-code example for error handling in a feature provider.
def error_decorator(f: F) -> F:
@wraps(f)
def wrapper(self, *args, **kwargs):
try:
return f(self, *args, **kwargs)
except Exception as e:
if self._is_essential_provider:
raise
log_error(exception=e, context=self._context)
return []
return cast(F, wrapper)
class ErrorHandlingExecutionContext:
def __init__(self, wrapped_element: ProviderBase) -> None:
self._wrapped_element: ProviderBase = wrapped_element
self._context: Context = self._wrapped_element.context
self._is_essential_provider: bool = self._wrapped_element.IS_ESSENTIAL_PROVIDER
# Other methods are omitted for brevity.
@error_decorator
def final_result_presenter(self) -> List:
...
When an error occurs, we record details such as the feature name, ownership info, exception specifics, and additional request context. This logging facilitates the monitoring of issues, the generation of alerts, and the automatic notification of the responsible team when problems reach a specified threshold.
Advanced Features
The example above covers a pretty basic configuration build example. Now, here’s a quick look at some advanced CHAOS features.
View Flows
In the CHAOS configuration schema, the “ChaosView - views” is defined as a list, with the initial view specified by “ChaosView - initialViewId.”
The CHAOS framework is engineered to allow a view to be linked with multiple “subsequent views.” The configurations for these subsequent views are also contained within “ChaosView - views,” with each view having its own unique ViewId.
Subsequent views are accessed through the “CHAOS Action - Open Subsequent View.” This action enables navigation to another view using its associated ViewId. This action can be attached to the onClick event of a component, such as a button, thereby allowing users to navigate seamlessly.
@dataclass
class OpenSubsequentView(_ActionData):
"""`"""
viewId: str
"""The name of subsequent view this action should open."""
action_type: str = field(init=False, default="chaos.open-subsequent-view.v1", metadata=excluded_from_encoding)
The process for constructing subsequent views is identical to that of the primary view builder. To register a view builder as a subsequent view to the primary one, the ViewBuilder class provides the subsequent_views method.
def subsequent_views(self) -> List[Type[ViewBuilderBase]]:
return [
# Add View Builders for Subsequent Views here.
]
Each view builder in this list is constructed alongside the primary view builder and stored in the “ChaosView - views” list within the final configuration. This design allows developers to define sequences of views, known as “flows,” which are interconnected using the “OpenSubsequentView” action. This approach is particularly beneficial in scenarios where users need to navigate quickly through a series of closely related content. By preloading these views, we eliminate the need for additional network requests for each view configuration, thereby enhancing the user experience by reducing latency.
Below is an example of a CHAOS Flow utilized in our Yelp for Business mobile app, specifically designed to support a customer support FAQ menu.
Simplified Flow CHAOS Config
This basic configuration demonstrates a three-view flow. Each view contains a button that, when clicked, triggers an action to open the next view. In this example, the views will navigate sequentially from View 1 to View 2 to View 3, and then loop back to View 1.
{
"data": {
"chaosView": {
"views": [
{
"identifier": "consumer.view_one",
"layout": {
"__typename": "ChaosSingleColumn",
"rows": [
"button-one"
]
},
"__typename": "ChaosView"
},
{
"identifier": "consumer.view_two",
"layout": {
"__typename": "ChaosSingleColumn",
"rows": [
"button-two"
]
},
"__typename": "ChaosView"
},
{
"identifier": "consumer.view_three",
"layout": {
"__typename": "ChaosSingleColumn",
"rows": [
"button-three"
]
},
"__typename": "ChaosView"
}
],
"components": [
{
"__typename": "ChaosJsonComponent",
"identifier": "button-one",
"componentType": "chaos.button.v1",
"parameters": "{\"text\": \"Next\", \"style\": \"primary\"}, \"onClick”: [\"open-subsequent-one\"]}"
},
{
"__typename": "ChaosJsonComponent",
"identifier": "button-two",
"componentType": "chaos.button.v1",
"parameters": "{\"text\": \"Next\", \"style\": \"primary\"}, \"onClick”: [\"open-subsequent-two\"]}"
},
{
"__typename": "ChaosJsonComponent",
"identifier": "button-three",
"componentType": "chaos.button.v1",
"parameters": "{\"text\": \"Back to start\", \"style\": \"primary\"}, \"onClick”: [\"open-subsequent-three\"]}"
}
],
"actions": [
{
"__typename": "ChaosJsonAction",
"identifier": "open-subsequent-one",
"actionType": "chaos.open-subsequent-view.v1",
"parameters": "{\"viewId\": \"consumer.view_two\"}"
},
{
"__typename": "ChaosJsonAction",
"identifier": "open-subsequent-two",
"actionType": "chaos.open-subsequent-view.v1",
"parameters": "{\"viewId\": \"consumer.view_three\"}"
},
{
"__typename": "ChaosJsonAction",
"identifier": "open-subsequent-three",
"actionType": "chaos.open-subsequent-view.v1",
"parameters": "{\"viewId\": \"consumer.view_one\"}"
}
],
"initialViewId": "consumer.view_one",
"__typename": "ChaosConfiguration"
}
}
}
View Placeholders
In CHAOS, we allow a CHAOS view to be nested within another CHAOS view, which the client loads once the parent view is displayed. This is achieved using a special CHAOS component called a view placeholder. When rendering this component, the parent view initially shows a loading spinner by default until the nested view’s CHAOS configuration is successfully loaded asynchronously. Once loaded, the nested view is seamlessly integrated with the surrounding content of the parent view.
This approach enables the main content to be displayed to the user more quickly, while additional content is loaded in the background as the user engages with other items on the screen.
The view placeholder component can also be optionally configured to handle different states during the loading process, including loading, error, and empty states.
@dataclass
class ViewPlaceholderV1(_ComponentData):
"""
Used to provide a placeholder that clients should use to fetch the indicated CHAOS Configuration and then load the retrieved content in the location of this component.
"""
viewName: str
"""The name of the CHAOS view to fetch, e.g. "consumer.inject_me"."""
featureContext: Optional[ChaosJsonContextData]
"""
A feature-specific JSON object to be passed to the backend for the view building process by view placeholder.
"""
loadingComponentId: Optional[ComponentId]
"""An optional component that provides a custom loading state."""
errorComponentId: Optional[ComponentId]
"""An optional component that provides a custom error state."""
emptyComponentId: Optional[ComponentId]
"""An optional component that provides a custom empty state."""
headerComponentId: Optional[ComponentId]
"""An optional component that provides a static header."""
footerComponentId: Optional[ComponentId]
"""
An optional component that provides a static footer.
Use the footer to provide a separator between the component and content below it.
If the view is closed, the separator will be removed along with the view content.
"""
estimatedContentHeight: Optional[int]
"""An optional estimate for the height of the content so that space can be allocated when loading."""
defaultLoadingComponentPadding: Optional[Padding]
"""Specifies whether padding should be added around the shimmer."""
component_type: str = field(init=False, default="chaos.component.view-placeholder.v1")
Here’s an example of the View Placeholder in action on our Yelp for Business home screen. The full home screen is supported by CHAOS. The “Reminders” feature is another standalone CHAOS view supported by a different CHAOS backend service. A ViewPlaceholder is used to asynchronously fetch the Reminders after the home screen has loaded and position it in the appropriate location.
More CHAOS?
This post provided a high-level overview of how the backend build process for CHAOS comes together. We walked through how configurations are built, how features are composed and validated, and how advanced capabilities like view flows and nested views help create dynamic, responsive user experiences.
In upcoming posts, our client engineering teams will take a deeper dive into how CHAOS is implemented across Web, iOS, and Android, and how each platform adapts the server-driven configurations to deliver a seamless experience to users. We’ll also explore more advanced topics, such as strategies for making CHAOS even more dynamic, optimizing performance, and scaling the framework to support increasingly complex product needs.
We’re excited to continue sharing what we’ve learned as we evolve CHAOS to power even richer, faster, and more flexible user experiences across Yelp. Stay tuned!
Join Our Team at Yelp
We're tackling exciting challenges at Yelp. Interested in joining us? Apply now!
View Job