Refactoring RudderStack's JavaScript SDK for High-performance
Since its initial release, we’ve refactored our JavaScript SDK multiple times, and we’ve written about how previous improvements reduced execution time from 200ms to 20ms. Since then, the JavaScript software development kit has grown in size as we’ve added support for new device-mode integrations. It became bulky enough to impact user experience with SDK download and execution (CPU) times, so we recently introduced a new, optimized version of the SDK.
Here, I’ll detail the improvements made with this refactoring, walk through our team’s decision-making process, outline the tradeoffs we considered, and showcase the results of our work.
Key SDK performance improvements
To optimize the size of the SDK and improve performance metrics, we focused on three key items:
- Freeing the SDK of all integrations code upon build
- Clearing technical debt
- Replacing third-party package dependencies
Freeing the SDK of integrations code upon build
Instead of statically importing device-mode integration modules into the core module, the integration modules are now built into independent SDKs (scripts) that can be readily loaded on the client side at runtime. Once the `load` API of the SDK is called, the necessary destination integrations are identified from the source configuration (pulled from the control plane), and their SDKs are loaded async one after another from the hosted location*. After a timeout, the successfully loaded integration modules are initialized to proceed with forwarding events.
The build workflow is slightly altered to generate integration SDKs and the core SDK.
* The hosted location defaults to RudderStack’s CDN. In the case of a custom-hosted location, this can be overridden via the `destSDKBaseURL` option in the `load` call inputs. Additionally, in the case of CDN installation, the SDK determines this URL based on the script tag that adds the SDK on the website (provided the file name is still “rudder-analytics.min.js”).
Clearing technical debt
We removed as much bloat from the SDK as possible. This included dead, redundant, deprecated code, and deprecated auto-track functionality.
Replacing third-party package dependencies
Wherever possible, we replaced third-party package dependencies with lighter ones. A few use cases required custom implementations in order to achieve the results we were looking for.
Why did we decide on this approach?
By design, all the device-mode integrations are independent of each other, so it didn’t make sense to bind everything together as a single piece. Moreover, because each customer will only connect a subset of device-mode integrations to their JS/web source, loading only the necessary integrations on their site is the ideal scenario. These improvements also involved minimal changes to our SDK and processes compared to other alternatives.
An alternative that we considered was to dynamically build the SDK with the necessary integrations when the request is made to https://cdn.rudderlabs.com/v1.1/rudder-analytics.js/<write key>. Using this approach, the device-mode integrations are packaged with the core SDK and delivered based on the write key provided in the URL.
We saw a few disadvantages to this approach:
- CDN costs would increase because we would have to cache a different version of the SDK for every write key
- We wouldn’t be able to take advantage of browser caching across various websites the user visits
- Migrating existing users would be challenging
What tradeoffs did we have to make?
Fortunately, this refactoring didn’t involve any major tradeoffs, but there are two worth noting:
- CDN Costs: Hosting all of the individual device-mode integration SDKs means increased CDN costs. Luckily, the additional cost is not significant.
- Migration costs: To make migrating to v1.1 worthwhile for our customers, we knew we needed to (1) introduce significant performance improvements over v1 and (2) make migrating as easy as possible. We were able to introduce significant improvements, which I'll highlight below. And we worked to make migration as painless as possible. In most cases, migration is completed in a few simple steps, which we documented in a migration guide on our docs site to help customers with all their deployment scenarios.
Problems we had to solve
In v1, all the integrations were exported from their module as default type. We had to convert all of them to named exports for them to be dynamically loaded. See below example:
Default type
JAVASCRIPT
import Amplitude from "./browser";export default Amplitude;
Named export
JAVASCRIPT
import Amplitude from "./browser";export { Amplitude };
Additionally, we had to write a script to build all the individual integrations in one go. This allows us to deploy the integrations along with the core SDK.
Results of the refactoring
Our new SDK is lighter and faster than the previous version. Here is the performance data:
- 70% size reduction (114 KB to 34 KB)
- 80% faster SDK download times (9.44 ms to 1.96ms)
- 28% faster script evaluation times (86 ms to 63 ms)
The optimized SDK reduces the latency to successfully deliver the first tracking event to the data plane. This results in a much faster front-end experience – even with a limited network bandwidth on the client side.
Check out the PR for the refactoring on GitHub.