Cải thiện tốc độ Web App với Tinder

December 25, 2017 (6y ago)

Tinder recently swiped right on the web. Their new responsive Progressive Web App — Tinder Online — is available to 100% of users on desktop and mobile, employing techniques for JavaScript performance optimization, Service Workers for network resilience and Push Notifications for chat engagement. Today we’ll walk through some of their web perf learnings.

Journey to a Progressive Web App

Tinder Online started with the goal of getting adoption in new markets, striving to hit feature parity with V1 of Tinder’s experience on other platforms.

The MVP for the PWA took 3 months to implement using React as their UI library and Redux for state management. The result of their efforts is a PWA that delivers the core Tinder experience in 10% of the data-investment costs for someone in a data-costly or data-scarce market:

Comparing the data-investment for Tinder Online vs the native apps. It’s important to note that this isn’t comparing apples to apples, however. The PWA loads code for new routes on demand, and the cost of additional code is amortized over the lifetime of the application. Subsequent navigations still don’t cost as much data as the download of the app.

Early signs show good swiping, messaging and session length compared to the native app. With the PWA:

Performance

The mobile devices Tinder Online’s users most commonly access their web experience with include:

Using the Chrome User Experience report (CrUX), we’re able to learn that the majority of users accessing the site are on a 4G connection:

Note: Rick Viscomi recently covered CrUX on PerfPlanet and Inian Parameshwaran covered rUXt for better visualizing this data for the top 1M sites.

Testing the new experience on WebPageTest and Lighthouse (using the Galaxy S7 on 4G) we can see that they’re able to load and get interactive in under 5 seconds:

There is of course lots of room to improve this further on median mobile hardware (like the Moto G4), which is more CPU constrained:

Tinder are hard at work on optimizing their experience and we look forward to hearing about their work on web performance in the near future.

Performance Optimization

Tinder were able to improve how quickly their pages could load and become interactive through a number of techniques. They implemented route-based code-splitting, introduced performance budgets and long-term asset caching.

Route-level code-splitting

Tinder initially had large, monolithic JavaScript bundles that delayed how quickly their experience could get interactive. These bundles contained code that wasn’t immediately needed to boot-up the core user experience, so it could be broken up using code-splitting. It’s generally useful to only ship code users need upfront and lazy-load the rest as needed.

To accomplish this, Tinder used React Router and React Loadable. As their application centralized all their route and rendering info a configuration base, they found it straight-forward to implement code splitting at the top level.

In summary:

React Loadable is a small library by James Kyle to make component-centric code splitting easier in React. Loadable is a higher-order component (a function that creates a component) which makes it easy to split up bundles at a component level.

Let’s say we have two components “A” and “B”. Before code-splitting, Tinder statically imported everything (A, B, etc) into their main bundle. This was inefficient as we didn’t need both A and B right away:

After adding code-splitting, components A and B could be loaded as and when needed. Tinder did this by introducing React Loadable, dynamic import() and webpack’s magic comment syntax (for naming dynamic chunks) to their JS:

For “vendor” (library) chunking, Tinder used the webpack CommonsChunkPlugin to move commonly used libraries across routes up to a single bundle file that could be cached for longer periods of time:

Next, Tinder used React Loadable’s preload support to preload potential resources for the next page on control component:

Tinder also used Service Workers to precache all their route level bundles and include routes that users are most likely to visit in the main bundle without code-splitting. They’re of course also using common optimizations like JavaScript minification via UglifyJS:

new webpack.optimize.UglifyJsPlugin({
      parallel: true,
      compress: {
        warnings: false,
        screw_ie8: true
      },
      sourceMap: SHOULD_SOURCEMAP
    }),

Impact

After introducing route-based code-splitting their main bundle sizes went down from 166KB to 101KB and DCL improved from 5.46s to 4.69s:

Long-term asset caching

Ensuring long-term caching of static resources output by webpack benefits from using [chunkhash] to add a cache-buster to each file.

Tinder were using a number of open-source (vendor) libraries as part of their dependency tree. Changes to these libraries would originally cause the [chunkhash] to change and invalidate their cache. To address this, Tinder began defining a whitelist of external dependencies and splitting out their webpack manifest from the main chunk to improve caching. The bundle size is now about 160KB for both chunks.

Preloading late-discovered resources

As a refresher, resource prioritization is a declarative instruction to the browser to load critical, late-discovered resources earlier on. In single-page applications, these resources can sometimes be JavaScript bundles.

Tinder implemented support for to preload their critical JavaScript/webpack bundles that were important for the core experience. This reduced load time by 1s and first paint from 1000ms to about 500ms.

Performance budgets

Tinder adopted performance budgets for helping them hit their performance goals on mobile. As Alex Russell noted in “Can you afford it?: real-world performance budgets”, you have a limited headroom to deliver an experience when considering slow 3G connections being used on median mobile hardware.

To get and stay interactive quickly, Tinder enforced a budget of ~155KB for their main and vendor chunks, asynchronous (lazily loaded) chunks are ~55KB and other chunks are ~35KB. CSS has a limit of 20KB. This was crucial to ensuring they were able to avoid regressing on performance.

Webpack Bundle Analysis

Webpack Bundle Analyzer allows you to discover what the dependency graph for your JavaScript bundles looks like so you can discover whether there’s low-hanging fruit to optimize.

Tinder used Webpack Bundle Analyzer to discover areas for improvement:

Using bundle analysis led to also also taking advantage of Webpack’s Lodash Module Replacement Plugin. The plugin creates smaller Lodash builds by replacing feature sets of modules with noop, identity or simpler alternatives:

Webpack Bundle Analyzer can be integrated into your Webpack config. Tinder’s setup for it looks like this:

plugins: [
      new BundleAnalyzerPlugin({
        analyzerMode: 'server',
        analyzerPort: 8888,
        reportFilename: 'report.html',
        openAnalyzer: true,
        generateStatsFile: false,
        statsFilename: 'stats.json',
        statsOptions: null
      })

The majority of the JavaScript left is the main chunk which is trickier to split out without architecture changes to Redux Reducer and Saga Register.

CSS Strategy

Tinder are using Atomic CSS to create highly reusable CSS styles. All of these atomic CSS styles are inlined in the initial paint and some of the rest of the CSS is loaded in the stylesheet (including animation or base/reset styles). Critical styles have a maximum size of 20KB gzipped, with recent builds coming in at a lean < 11KB.

Tinder use CSS stats and Google Analytics for each release to keep track of what has changed. Before Atomic CSS was being used, average page load times were ~6.75s. After they were ~5.75s.

Tinder Online also uses the PostCSS Autoprefixer plugin to parse CSS and add vendor prefixes based on rules from Can I Use:

new webpack.LoaderOptionsPlugin({
    options: {
    context: paths.basePath,
    output: { path: './' },
    minimize: true,
    postcss: [
        autoprefixer({
        browsers: [
            'last 2 versions',
            'not ie < 11',
            'Safari >= 8'
        ]
        })
      ]
    }
}),

Runtime performance

Deferring non-critical work with requestIdleCallback()

To improve runtime performance, Tinder opted to use requestIdleCallback() to defer non-critical actions into idle time.

requestIdleCallback(myNonEssentialWork);

This included work like instrumentation beacons. They also simplified some HTML composite layers to reduce paint count while swiping.

Using requestIdleCallback() for their instrumentation beacons while swiping:

before..

and after..

Dependency upgrades

Webpack 3 + Scope Hoisting

In older versions of webpack, when bundling each module in your bundle would be wrapped in individual function closures. These wrapper functions made it slower for your JavaScript to execute in the browser. Webpack 3 introduced “scope hoisting” — the ability to concatenate the scope of all your modules into one closure and allow for your code to have a faster execution time in the browser. It accomplishes this with the Module Concatenation plugin:

new webpack.optimize.ModuleConcatenationPlugin();

Webpack 3’s scope hoisting improved Tinder’s initial JavaScript parsing time for vendor chunk by 8%.

React 16

React 16 introduced improvements that decreased React’s bundle size compared to previous versions. This was in part due to better packaging (using Rollup) as well as removing now unused code.

By updating from React 15 to React 16, Tinder reduced the total gzipped size of their vendor chunk by ~7%.

The size of react + react-dom used to be~50KB gzipped and is now just ~35KB. Thanks to Dan Abramov, Dominic Gannaway and Nate Hunzaker who were instrumental in trimming down React 16’s bundle size.

Workbox for network resilience and offline asset caching

Tinder also use the Workbox Webpack plugin for caching both their Application Shell and their core static assets like their main, vendor, manifest bundles and CSS. This enables network resilience for repeat visits and ensures that the application starts-up more quickly when a user returns for subsequent visits.

Opportunities

Digging into the Tinder bundles using source-map-explorer (another bundle analysis tool), there are additional opportunities for reducing payload size. Before logging in, components like Facebook Photos, notifications, messaging and captchas are fetched. Moving these away from the critical path could save up to 20% off the main bundle:

Another dependency in the critical path is a 200KB Facebook SDK script. Dropping this script (which could be lazily loaded when needed) could shave 1 second off initial loading time.

Conclusions

Tinder are still iterating on their Progressive Web App but have already started to see positive results from the fruits of their labor. Check out Tinder.com and stay tuned for more progress in the near future!

With thanks and congrats to Roderick Hsiao, Jordan Banafsheha, and Erik Hellenbrand for launching Tinder Online and their input to this article. Thanks to Cheney Tsai for his review.

Related reading:

This article was cross-posted from Performance Planet. If you’re new to React, I’ve found React for Beginners a comprehensive starting point.