As Uber expands to new markets, we want to enable all users to quickly request a ride, regardless of location, network speed, and device. With this in mind, we rebuilt our web client from the ground up as a viable alternative to the native mobile app.

Compatible with all modern browsers, m.uber offers an app-like experience for riders on low-end devices, including those not supported by our native client. The app is also tiny—the core ride request app comes in at just 50kB, enabling the app to load quickly even on 2G networks.

In this article, we describe how we built m.uber (pronounced moo-ber) and explore the challenge of implementing the native app experience in a super-lightweight web app.

m.uber mimics the native Uber app flow, allowing riders to specify their ride request and track driver location after being matched.

 

Smaller, faster: how we built it

m.uber is written in ES2015+, using Babel for ES5 transpilation. The overarching design challenge was minimizing the client footprint while maintaining the rich experience of the native app. So while our traditional architecture utilizes React (with Redux) and Browserify for module bundling, we swapped in Preact for its size benefits and Webpack for its dynamic bundle splitting and tree-shaking capabilities. Below, we discuss how we addressed those and other challenges across the application architecture:

Initial server render

Clients cannot begin to render markup until all core JavaScript bundles have been downloaded, so m.uber responds to the initial browser request by rendering Preact on the server. The resulting state and markup are inlined as strings in the server response so that content loads almost immediately.  

Serve bundles on demand

The goal of m.uber is to let users request a ride as quickly as possible, but much of our JavaScript is for ancillary tasks: updating payment options, checking the progress of a trip, or editing settings. To ensure we are only serving the JavaScript we need, we use Webpack for code splitting.

We use a splitPage function that returns the ancillary bundle wrapped in an asynchronous component. For example, the settings page is called by the function below:

const AsyncSettings = splitPage(
 {load: () => import(‘../../screens/settings’)}
);

Using this function, the settings bundle will be fetched only if and when AsyncSettings is conditionally included by the parent render method. For very slow connections, AsyncSettings will render a “loading” modal pending the completion of the bundle fetch.

Tiny Libraries

m.uber is designed to be fast even on 2G networks, so client size is critical. Our core app (the essential part of the app that allows you to request a ride) comes in at just 50kB gzipped and minified, which means a three second time to interaction on typical 2G (250kB/s, 300ms latency) networks. Below, we highlight the difference in vendor bundle size and numbers of dependencies now and at the beginning of the m.uber project:

 

The m.uber vendor bundle size and number of dependencies are much smaller than when the project began.

Preact over React

In the interest of size, we chose Preact (3kB GZip/minified) over React (45kB). Preact can do almost everything that React does (it does not support PropTypes or synthetic events) and adds a few nice reflection capabilities of its own. Preact is a little overzealous when it comes to recycling components and elements (but they’re working on it), which means you may have to define keys on elements you would not expect to, but otherwise it worked very well for our needs.

Minimal Dependencies

To fight dependency bloat, we were selective about npm packages used in the client, making use of libraries like Just whose modules are only responsible for one function and have no dependencies. We found that it made sense to confine expensive data transformations to the server, so that heavier modules like Moment do not need to be downloaded. To identify sources of dependency bloat, we made heavy use of tools like source-map-explorer.

Conditional Feature Set

The mission of m.uber is to give everyone, everywhere the ability to easily request a ride and provide additional features when the device and network allow for them. We detect the time to first interaction using the window.performanceAPI and hide or load the interactive map experience based on the result. The map can also be toggled on and off in the settings page for users whose network performance we cannot detect.

Minimal render Calls

Preact (like React) uses a VDOM to generate new markup when a change occurs, but that does not mean calling render is free. It takes a lot of JavaScript chatter for render to figure out that nothing needs to happen. We use shouldComponentUpdate extensively to minimize calls to render.

Caching

Service Workers

Service workers intercept URL requests, enabling network and local disk fetches to be replaced by custom fetch logic, which typically leverages the browser’s Cache API. By caching the initial HTML response as well as JavaScript bundles, service workers allow m.uber to continue to serve content in the event of intermittent network loss.

Service workers can also significantly decrease load times. Disk I/O performance varies greatly across operating systems and devices, and in many cases, even fetching data from disk cache is frustratingly slow. Where service workers are supported, all re-fetched content (including HTML) comes directly from the browser cache, enabling pages to reload immediately.

m.uber clients install a new service worker after each build. Since WebPack generates dynamic bundle names, our build process writes new names directly to the service worker module. On install, we cache our core JavaScript libraries then lazily cache HTML and ancillary JavaScript bundles as they are fetched.

Local Storage

Where we need to cache response data that is too volatile for service workers, we save it to the browser’s local storage. m.uber polls for the ride status every few seconds; keeping the latest status data in local storage means when a rider returns to the app, we can quickly re-render their page without waiting for a round trip to the API. Since our status data is small and the stored data size is finite, storage updates are fast and reliable, and we ultimately found that we did not need to use an asynchronous local storage API like indexedDB.

Styling

Styletron

Styles are defined as JavaScript objects within each component. When a component is rendered, Styletron dynamically generates stylesheets from these definitions. Colocation of styles with components allows for easy bundle splitting and asynchronous loading of styles. CSS that is not used is never loaded.

Styletron de-duplicates style declarations by creating an atomic stylesheet for each unique rule, allowing for a minimal CSS runtime and best-in-class rendering performance. We use Styletron for all component-level CSS generation on m.uber.

SVGs

To save on space, we use the SVG format for icon-like images whenever possible, and inline them in the render method. For tuning, we used SVGO together with manual optimizations to further shorten the paths. Sometimes, we were able to replace polylines with basic shapes, and we used view box dimensions with suitable divisors to avoid expensive decimals in paths.

The impact of this strategy on overall app size is significant; for example, we reduced our logo size from 7.4kB (png) to 500 bytes (tuned SVG).

Fonts

With judicious use of size and color we found we were able to entirely eliminate custom fonts, without significantly compromising the visual design.

Error Handling

A lean tech stack is not always conducive to easy error diagnosis, so we added some lightweight tooling to help, for instance:

  • Instead of using a hefty, off-the-peg error monitoring library, we extended window.onerror to post errors to a client-error reporter on the server.
  • We short-circuited recursive lifecycle method errors by wrapping Preact’s render and shouldComponentUpdate.
  • In our design, errors thrown by a CDN-hosted file will not provide useful data to window.onerror unless the appropriate permissions are provided by cross-origin resource sharing (CORS) headers. Even with such headers, however, errors thrown during an asynchronous event cannot be traced back to the parent module and so window.onerror will remain in the dark. We wrapped all event listeners to allow errors to be passed to the parent module via try/catch.

Next Steps

Through our work with m.uber, we have put a lot of effort into creating a native, app-like experience in a performant package, but we are not finished—there are still plenty of opportunities for improvement. In the coming months, we are planning on releasing additional optimizations, including:

  • Formalizing a strategy for minimizing render calls by having components only accept a flat collection of primitives and array props. This will both allow us to use React.pureComponent (which automatically implements shouldComponentUpdate with a shallow prop comparison) and render to focus on markup generation instead of branching logic and other tangential tasks. Transforming API responses to flattened primitives can be delegated to server logic (see normalizr) and/or mapStateToProps as appropriate.
  • Combining actions and reducers, which would make bundle separation more intuitive.
  • Using HTTP/2 for all requests and replace polling APIs with Push notifications.

Additionally, we are abstracting the infrastructure pieces from m.uber into an open source architecture which will serve as the foundation for future lightweight Uber web apps—stay tuned for an upcoming article on this topic.

Angus Croll is an engineer on the Uber Web Platform team, based in San Francisco, and can be reached on Twitter at @angustweets. He is also a literature fanatic and author of If Hemingway Wrote JavaScript.

Photo Header Credit: “High-Performance Cheetah on Branch” by Conor Myhrvold, Okavango Delta, Botswana.

0 Comments