Performance
Performance is an essential part and one of the main areas of concern for any modern application.
Monitoring
We use sitespeed.io to continuously measure frontend performance. There are two complementary levels of monitoring: page-level and journey-level.
Page-level monitoring
The page summary Grafana dashboard automatically aggregates metric data every 4 hours across a set of individual pages.
These pages are defined in text files inside the sitespeed-measurement-setup repository under gitlab.
Any frontend engineer can contribute by adding or removing URLs from those text files. Changes go live on the next scheduled run after merging into master.
There are 3 recommended high impact metrics (core web vitals) to review on each page:
For these metrics, lower numbers are better as it means that the website is more performant.
The dashboard also surfaces Total Blocking Time (TBT), which measures how long the main thread is blocked during page load. TBT is not a Core Web Vital (it is a lab metric, not measurable in the field), but it is a useful diagnostic for identifying interactivity problems and is available on the Sitespeed dashboard.
Both the page-level and journey-level dashboards also capture User Timing API marks and measures emitted by the GitLab frontend code. This means any custom performance.mark() or performance.measure() calls you add to the codebase using the performanceMarkAndMeasure utility are automatically collected and visible in Grafana. Use this to instrument and monitor rendering milestones that matter specifically to your feature.
Journey-level monitoring (user journeys)
In addition to page-level metrics, we measure the performance of complete user workflows end-to-end. These are called user journeys and represent the workflows that matter most to users, such as editing and committing a file, creating a merge request, or running a pipeline.
Journey metrics are visualized in the User Journey Grafana dashboard. Each journey reports cumulative stopwatch timings per step, flowing into Graphite under sitespeed_io.desktop.gitlab-workflows.<journeyName>.<stopwatchName>. Use Grafana’s diffSeries() to compute per-step deltas.
Journey scripts live in gitlab/desktop/workflows/ in the sitespeed-measurement-setup repository. The Create_SourceCode_WritingCode journey (editing and committing a file via the web editor) serves as the reference implementation.
To add a user journey for your own team, follow the user journey guide in the sitespeed-measurement-setup repository. It covers:
- Defining journey steps and selectors
- Creating fixture data in the test group
- Implementing the journey script with
workflow_helperstopwatches - Testing locally with Docker
- Reading and interpreting the Grafana metrics
User Timing API
User Timing API is a web API available in all modern browsers. It allows measuring custom times and durations in your applications by placing special marks in your code. You can use the User Timing API in GitLab to measure any timing, regardless of the framework, including Rails, Vue, or vanilla JavaScript environments. For consistency and convenience of adoption, GitLab offers several ways to enable custom user timing metrics in your code.
User Timing API introduces two important paradigms: mark and measure.
Mark is the timestamp on the performance timeline. For example,
performance.mark('my-component-start'); makes a browser note the time this code
is met. Then, you can obtain information about this mark by querying the global
performance object again. For example, in your DevTools console:
performance.getEntriesByName('my-component-start')Measure is the duration between either:
- Two marks
- The start of navigation and a mark
- The start of navigation and the moment the measurement is taken
It takes several arguments of which the measurement’s name is the only one required. Examples:
Duration between the start and end marks:
performance.measure('My component', 'my-component-start', 'my-component-end')Duration between a mark and the moment the measurement is taken. The end mark is omitted in this case.
performance.measure('My component', 'my-component-start')Duration between the navigation start and the moment the actual measurement is taken.
performance.measure('My component')Duration between the navigation start and a mark. You cannot omit the start mark in this case but you can set it to
undefined.performance.measure('My component', undefined, 'my-component-end')
To query a particular measure, You can use the same API, as for mark:
performance.getEntriesByName('My component')You can also query for all captured marks and measurements:
performance.getEntriesByType('mark');
performance.getEntriesByType('measure');Using getEntriesByName() or getEntriesByType() returns an Array of
the PerformanceMeasure objects
which contain information about the measurement’s start time and duration.
User Timing API utility
You can use the performanceMarkAndMeasure utility anywhere in GitLab, as it’s not tied to any
particular environment.
performanceMarkAndMeasure takes an object as an argument, where:
| Attribute | Type | Required | Description |
|---|---|---|---|
mark | String | no | The name for the mark to set. Used for retrieving the mark later. If not specified, the mark is not set. |
measures | Array | no | The list of the measurements to take at this point. |
In return, the entries in the measures array are objects with the following API:
| Attribute | Type | Required | Description |
|---|---|---|---|
name | String | yes | The name for the measurement. Used for retrieving the mark later. Must be specified for every measure object, otherwise JavaScript fails. |
start | String | no | The name of a mark from which the measurement should be taken. |
end | String | no | The name of a mark to which the measurement should be taken. |
Example:
import { performanceMarkAndMeasure } from '~/performance/utils';
...
performanceMarkAndMeasure({
mark: MR_DIFFS_MARK_DIFF_FILES_END,
measures: [
{
name: MR_DIFFS_MEASURE_DIFF_FILES_DONE,
start: MR_DIFFS_MARK_DIFF_FILES_START,
end: MR_DIFFS_MARK_DIFF_FILES_END,
},
],
});Vue performance plugin
The plugin captures and measures the performance of the specified Vue components automatically leveraging the Vue lifecycle and the User Timing API.
To use the Vue performance plugin:
Import the plugin:
import PerformancePlugin from '~/performance/vue_performance_plugin';Use it before initializing your Vue application:
Vue.use(PerformancePlugin, { components: [ 'MyComponent', 'MyOtherComponent', ] });
The plugin accepts the list of components, performance of which should be measured. The components
should be specified by their name option.
You might need to explicitly set this option on the needed components, as most components in the codebase don’t have this option set:
export default {
name: 'MyComponent',
components: {
...
...
}The plugin captures and stores the following:
- The start mark for when the component has been initialized (in
beforeCreate()hook) - The end mark of the component when it has been rendered (next animation frame after
nextTickinmounted()hook). In most cases, this event does not wait for all sub-components to be bootstrapped. To measure the sub-components, you should include those into the plugin options. - Measure duration between the two marks above.
Access stored measurements
To access stored measurements, you can use either:
Performance bar. If you have it enabled (
P+Bkey-combo), you can see the metrics output in your DevTools console.“Performance” tab of the DevTools. You can get the measurements (not the marks, though) in this tab when profiling performance.
DevTools console. As mentioned above, you can query for the entries:
performance.getEntriesByType('mark'); performance.getEntriesByType('measure');
Naming convention
All the marks and measures should be instantiated with the constants from
app/assets/javascripts/performance/constants.js. When you’re ready to add a new mark’s or
measurement’s label, you can follow the pattern.
This pattern is a recommendation and not a hard rule.
app-*-start // for a start 'mark'
app-*-end // for an end 'mark'
app-* // for 'measure'
For example, 'webide-init-editor-start, mr-diffs-mark-file-tree-end, and so on. We do it to
help identify marks and measures coming from the different apps on the same page.
Best Practices
Real-time Components
When writing code for real-time features we have to keep a couple of things in mind:
- Do not overload the server with requests.
- It should feel real-time.
Thus, we must strike a balance between sending requests and the feeling of real-time. Use the following rules when creating real-time solutions.
- The server tells you how much to poll by sending
Poll-Intervalin the header. Use that as your polling interval. This enables system administrators to change the polling rate. APoll-Interval: -1means you should disable polling, and this must be implemented. - A response with HTTP status different from 2XX should disable polling as well.
- Use a common library for polling.
- Poll on active tabs only. Use Visibility.
- Use regular polling intervals, do not use backoff polling or jitter, as the interval is controlled by the server.
- The backend code is likely to be using ETags. You do not and should not check for status
304 Not Modified. The browser transforms it for you.
Lazy Loading Images
To improve the time to first render we are using lazy loading for images. This works by setting
the actual image source on the data-src attribute. After the HTML is rendered and JavaScript is loaded,
the value of data-src is moved to src automatically if the image is in the current viewport.
- Prepare images in HTML for lazy loading by renaming the
srcattribute todata-srcand adding the classlazy. - If you are using the Rails
image_taghelper, all images are lazy-loaded by default unlesslazy: falseis provided.
When asynchronously adding content which contains lazy images, call the function
gl.lazyLoader.searchLazyImages() which searches for lazy images and loads them if needed.
In general, it should be handled automatically through a MutationObserver in the lazy loading function.
Animations
Only animate opacity & transform properties. Other properties (such as top, left, margin, and padding) all cause
Layout to be recalculated, which is much more expensive. For details on this, see
High Performance Animations.
If you do need to change layout (for example, a sidebar that pushes main content over), prefer FLIP. FLIP allows you to change expensive properties once, and handle the actual animation with transforms.
Prefetching assets
In addition to prefetching data from the API we allow prefetching the named JavaScript “chunks” as defined in the Webpack configuration. We support two types of prefetching for the chunks:
- The
prefetchlink type is used to prefetch a chunk for the future navigation - The
preloadlink type is used to prefetch a chunk that is crucial for the current navigation but is not discovered until later in the rendering process
Both prefetch and preload links bring the loading performance benefit to the pages. Both are
fetched asynchronously, but contrary to deferring the loading
of the assets which is used for other JavaScript resources in the product by default, prefetch and
preload neither parse nor execute the fetched script unless explicitly imported in any JavaScript
module. This allows to cache the fetched resources without blocking the execution of the
remaining page resources.
To prefetch a JavaScript chunk in a HAML view, :prefetch_asset_tags with the combination of
the webpack_preload_asset_tag helper is provided:
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco')This snippet will add a new <link rel="preload"> element into the resulting HTML page:
<link rel="preload" href="/assets/webpack/monaco.chunk.js" as="script" type="text/javascript">By default, webpack_preload_asset_tag will preload the chunk. You don’t need to worry about
as and type attributes for preloading the JavaScript chunks. However, when a chunk is not
critical, for the current navigation, one has to explicitly request prefetch:
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco', prefetch: true)This snippet will add a new <link rel="prefetch"> element into the resulting HTML page:
<link rel="prefetch" href="/assets/webpack/monaco.chunk.js">Reducing Asset Footprint
Universal code
Code that is contained in main.js and commons/index.js is loaded and
run on all pages. Do not add anything to these files unless it is truly
needed everywhere. These bundles include ubiquitous libraries like vue,
axios, and jQuery, as well as code for the main navigation and sidebar.
Where possible we should aim to remove modules from these bundles to reduce our
code footprint.
Page-specific JavaScript
Webpack has been configured to automatically generate entry point bundles based
on the file structure in app/assets/javascripts/pages/*. The directories
in the pages directory correspond to Rails controllers and actions. These
auto-generated bundles are automatically included on the corresponding
pages.
For example, if you were to visit https://gitlab.com/gitlab-org/gitlab/-/issues,
you would be accessing the app/controllers/projects/issues_controller.rb
controller with the index action. If a corresponding file exists at
pages/projects/issues/index/index.js, it is compiled into a webpack
bundle and included on the page.
Previously, GitLab encouraged the use of
content_for :page_specific_javascripts in HAML files, along with
manually generated webpack bundles. However under this new system you should
not ever need to manually add an entry point to the webpack.config.js file.
When unsure what controller and action corresponds to a page,
inspect document.body.dataset.page in your
browser’s developer console from any page in GitLab.
TROUBLESHOOTING:
If using Vite, keep in mind that support for it is new and you may encounter unexpected effects from time to
time. If the entrypoint is correctly configured but the JavaScript is not loading,
try clearing the Vite cache and restarting the service:
rm -rf tmp/cache/vite && gdk restart vite
Alternatively, you can opt to use Webpack instead. Follow these instructions for disabling Vite and using Webpack.
Important Considerations
- Keep Entry Points Lite: Page-specific JavaScript entry points should be as lite as possible. These files are exempt from unit tests, and should be used primarily for instantiation and dependency injection of classes and methods that live in modules outside of the entry point script. Just import, read the DOM, instantiate, and nothing else.
DOMContentLoadedshould not be used: All GitLab JavaScript files are added with thedeferattribute. According to the Mozilla documentation, this implies that “the script is meant to be executed after the document has been parsed, but before firingDOMContentLoaded”. Because the document is already parsed,DOMContentLoadedis not needed to bootstrap applications because all the DOM nodes are already at our disposal.- Supporting Module Placement:
- If a class or a module is specific to a particular route, try to locate
it close to the entry point in which it is used. For instance, if
my_widget.jsis only imported inpages/widget/show/index.js, you should place the module atpages/widget/show/my_widget.jsand import it with a relative path (for example,import initMyWidget from './my_widget';). - If a class or module is used by multiple routes, place it in a
shared directory at the closest common parent directory for the entry
points that import it. For example, if
my_widget.jsis imported in bothpages/widget/show/index.jsandpages/widget/run/index.js, then place the module atpages/widget/shared/my_widget.jsand import it with a relative path if possible (for example,../shared/my_widget).
- If a class or a module is specific to a particular route, try to locate
it close to the entry point in which it is used. For instance, if
- Enterprise Edition Caveats:
For GitLab Enterprise Edition, page-specific entry points override their
Community Edition counterparts with the same name, so if
ee/app/assets/javascripts/pages/foo/bar/index.jsexists, it takes precedence overapp/assets/javascripts/pages/foo/bar/index.js. If you want to minimize duplicate code, you can import one entry point from the other. This is not done automatically to allow for flexibility in overriding functionality.
Code Splitting
Code that does not need to be run immediately upon page load (for example, modals, dropdowns, and other behaviors that can be lazy-loaded) should be split into asynchronous chunks with dynamic import statements. These imports return a Promise which is resolved after the script has loaded:
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(/* do something */)
.catch(/* report error */)Use webpackChunkName when generating dynamic imports as
it provides a deterministic filename for the chunk which can then be cached
in the browser across GitLab versions.
More information is available in the webpack code splitting documentation and the Vue dynamic component documentation.
Minimizing page size
A smaller page size means the page loads faster, especially on mobile and poor connections. The page is parsed more quickly by the browser, and less data is used for users with capped data plans.
General tips:
- Don’t add new fonts.
- Prefer font formats with better compression, for example, WOFF2 is better than WOFF, which is better than TTF.
- Compress and minify assets wherever possible (For CSS/JS, Sprockets and webpack do this for us).
- If some functionality can reasonably be achieved without adding extra libraries, avoid them.
- Use page-specific JavaScript as described above to load libraries that are only needed on certain pages.
- Use code-splitting dynamic imports wherever possible to lazy-load code that is not needed initially.
- High Performance Animations
Additional Resources
- WebPage Test for testing site loading time and size.
- Google PageSpeed Insights grades web pages and provides feedback to improve the page.
- Profiling with Chrome DevTools
- Browser Diet was a community-built guide that cataloged practical tips for improving web page performance.