Repository: GoogleChrome/lighthouse
Pull request #9409 (2019-07-18 18:33:34)

core(tbt): Clean up total blocking time code
This PR cleans up Total Blocking Time code in lighthouse. It
- changes the name from CLQD -> TBT as decided by the [bikeshedding doc](https://docs.google.com/document/d/1SaYGOCy4_wEEoa0N_gNb1PyOxnZgSvuu_r52WhRYFKQ/edit#).
- Changes the definition of blocking time to be anything after first 50ms of a task, as opposed to anything before last 50ms. People [found](https://docs.google.com/a/google.com/document/d/1atjVa5eOuh1pGd0d5j3axDvj0jewN3FZZFESonnwu8A/edit?disco=AAAADQT1Gm8) the previous definition confusing, and this detail would change the value of the metric only by a maximum of 50ms, so the simplicity is worth it. 
- Has properly i18n-ed UI strings.  


brendankenny: I'd like to get this into 5.2 so the next http archive run (starting August 1st) will have the up-to-date version of TBT

Patch:
diff --git a/docs/architecture.md b/docs/architecture.md
index cf67e925d..7b519f96a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -16,7 +16,7 @@ _Some incomplete notes_
 ### Audit/Report terminology
 * **Category** - Roll-up collection of audits and audit groups into a user-facing section of the report (eg. `Best Practices`). Applies weighting and overall scoring to the section. Examples: PWA, Accessibility, Best Practices.
 * **Audit title** - Short user-visible title for the successful audit. eg. “All image elements have `[alt]` attributes.”
-* **Audit failureTitle** - Short user-visible title for a failing  audit. eg. “Some image elements do not have `[alt]` attributes.”
+* **Audit failureTitle** - Short user-visible title for a failing audit. eg. “Some image elements do not have `[alt]` attributes.”
 * **Audit description** - Explanation of why the user should care about the audit. Not necessarily how to fix it, unless there is no external link that explains it. ([See description guidelines](CONTRIBUTING.md#description-guidelines)). eg. “Informative elements should aim for short, descriptive alternate text. Decorative elements can be ignored with an empty alt attribute. [Learn more].”
 
 ## Protocol
diff --git a/docs/understanding-results.md b/docs/understanding-results.md
index 587f83e5d..7d795f455 100644
--- a/docs/understanding-results.md
+++ b/docs/understanding-results.md
@@ -12,7 +12,7 @@ For an always up-to-date definition of the LHR, take a look [at our typedefs](ht
 
 | Name | Description |
 | - | - |
-| lighthouseVersion | The version of Lighthouse with which this result were generated. |
+| lighthouseVersion | The version of Lighthouse with which this result was generated. |
 | fetchTime | The ISO-8601 timestamp of when the result was generated. |
 | userAgent | The user agent string of the version of Chrome that was used by Lighthouse. |
 | requestedUrl | The URL that was supplied to Lighthouse and initially navigated to. |
diff --git a/docs/variability.md b/docs/variability.md
index 6be848435..dce77de3b 100644
--- a/docs/variability.md
+++ b/docs/variability.md
@@ -30,7 +30,7 @@ Local networks have inherent variability from packet loss, variable traffic prio
 
 ### Tier-1 Network Variability
 
-Network interconnects are generally very stable and have minimal impact but cross-geo requests, i.e. measuring performance of a Chinese site from the US, can start to experience a high degree of latency introduced from tier-1 network hops. _Applied_ throttling partially mask these effects with network throttling. _Simulated_ throttling mitigates these effects by replaying network activity on its own.
+Network interconnects are generally very stable and have minimal impact but cross-geo requests, i.e. measuring performance of a Chinese site from the US, can start to experience a high degree of latency introduced from tier-1 network hops. _Applied_ throttling partially masks these effects with network throttling. _Simulated_ throttling mitigates these effects by replaying network activity on its own.
 
 ### Web Server Variability
 
diff --git a/lighthouse-core/audits/metrics.js b/lighthouse-core/audits/metrics.js
index 374517c21..98a50d46e 100644
--- a/lighthouse-core/audits/metrics.js
+++ b/lighthouse-core/audits/metrics.js
@@ -14,7 +14,7 @@ const FirstCPUIdle = require('../computed/metrics/first-cpu-idle.js');
 const Interactive = require('../computed/metrics/interactive.js');
 const SpeedIndex = require('../computed/metrics/speed-index.js');
 const EstimatedInputLatency = require('../computed/metrics/estimated-input-latency.js');
-const CumulativeLongQueuingDelay = require('../computed/metrics/cumulative-long-queuing-delay.js');
+const TotalBlockingTime = require('../computed/metrics/total-blocking-time.js');
 
 class Metrics extends Audit {
   /**
@@ -60,7 +60,7 @@ class Metrics extends Audit {
     const interactive = await requestOrUndefined(Interactive, metricComputationData);
     const speedIndex = await requestOrUndefined(SpeedIndex, metricComputationData);
     const estimatedInputLatency = await EstimatedInputLatency.request(metricComputationData, context); // eslint-disable-line max-len
-    const cumulativeLongQueuingDelay = await CumulativeLongQueuingDelay.request(metricComputationData, context); // eslint-disable-line max-len
+    const totalBlockingTime = await TotalBlockingTime.request(metricComputationData, context); // eslint-disable-line max-len
 
     /** @type {UberMetricsItem} */
     const metrics = {
@@ -77,7 +77,7 @@ class Metrics extends Audit {
       speedIndexTs: speedIndex && speedIndex.timestamp,
       estimatedInputLatency: estimatedInputLatency.timing,
       estimatedInputLatencyTs: estimatedInputLatency.timestamp,
-      cumulativeLongQueuingDelay: cumulativeLongQueuingDelay.timing,
+      totalBlockingTime: totalBlockingTime.timing,
 
       // Include all timestamps of interest from trace of tab
       observedNavigationStart: traceOfTab.timings.navigationStart,
@@ -140,7 +140,7 @@ class Metrics extends Audit {
  * @property {number=} speedIndexTs
  * @property {number} estimatedInputLatency
  * @property {number=} estimatedInputLatencyTs
- * @property {number} cumulativeLongQueuingDelay
+ * @property {number} totalBlockingTime
  * @property {number} observedNavigationStart
  * @property {number} observedNavigationStartTs
  * @property {number=} observedFirstPaint
diff --git a/lighthouse-core/audits/metrics/cumulative-long-queuing-delay.js b/lighthouse-core/audits/metrics/total-blocking-time.js
similarity index 52%
rename from lighthouse-core/audits/metrics/cumulative-long-queuing-delay.js
rename to lighthouse-core/audits/metrics/total-blocking-time.js
index dd0297ad8..174e5d5d5 100644
--- a/lighthouse-core/audits/metrics/cumulative-long-queuing-delay.js
+++ b/lighthouse-core/audits/metrics/total-blocking-time.js
@@ -6,24 +6,28 @@
 'use strict';
 
 const Audit = require('../audit.js');
-const CumulativeLQD = require('../../computed/metrics/cumulative-long-queuing-delay.js');
+const ComputedTBT = require('../../computed/metrics/total-blocking-time.js');
+const i18n = require('../../lib/i18n/i18n.js');
 
-// TODO(deepanjanroy): i18n strings once metric is final.
-const UIStringsNotExported = {
-  title: 'Cumulative Long Queuing Delay',
-  description: '[Experimental metric] Total time period between FCP and Time to Interactive ' +
-      'during which queuing time for any input event would be higher than 50ms.',
+const UIStrings = {
+  /** The name of a metric that calculates the total duration of blocking time for a web page. Blocking times are time periods when the page would be blocked (prevented) from responding to user input (clicks, taps, and keypresses will feel slow to respond). Shown to users as the label for the numeric metric value. Ideally fits within a ~40 character limit. */
+  title: 'Total Blocking Time',
+  /** Description of the Total Blocking Time (TBT) metric, which calculates the total duration of blocking time for a web page. Blocking times are time periods when the page would be blocked (prevented) from responding to user input (clicks, taps, and keypresses will feel slow to respond). This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits.*/
+  description: 'Sum of all time periods between FCP and Time to Interactive, ' +
+      'when task length exceeded 50ms, expressed in milliseconds.',
 };
 
-class CumulativeLongQueuingDelay extends Audit {
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
+class TotalBlockingTime extends Audit {
   /**
    * @return {LH.Audit.Meta}
    */
   static get meta() {
     return {
-      id: 'cumulative-long-queuing-delay',
-      title: UIStringsNotExported.title,
-      description: UIStringsNotExported.description,
+      id: 'total-blocking-time',
+      title: str_(UIStrings.title),
+      description: str_(UIStrings.description),
       scoreDisplayMode: Audit.SCORING_MODES.NUMERIC,
       requiredArtifacts: ['traces', 'devtoolsLogs'],
     };
@@ -46,13 +50,12 @@ class CumulativeLongQueuingDelay extends Audit {
   }
 
   /**
-   * Audits the page to calculate Cumulative Long Queuing Delay.
+   * Audits the page to calculate Total Blocking Time.
    *
-   * We define Long Queuing Delay Region as any time interval in the loading timeline where queuing
-   * time for an input event would be longer than 50ms. For example, if there is a 110ms main thread
-   * task, the first 60ms of it is Long Queuing Delay Region, because any input event occuring in
-   * that region has to wait more than 50ms. Cumulative Long Queuing Delay is the sum of all Long
-   * Queuing Delay Regions between First Contentful Paint and Interactive Time (TTI).
+   * We define Blocking Time as any time interval in the loading timeline where task length exceeds
+   * 50ms. For example, if there is a 110ms main thread task, the last 60ms of it is blocking time.
+   * Total Blocking Time is the sum of all Blocking Time between First Contentful Paint and
+   * Interactive Time (TTI).
    *
    * @param {LH.Artifacts} artifacts
    * @param {LH.Audit.Context} context
@@ -62,7 +65,7 @@ class CumulativeLongQueuingDelay extends Audit {
     const trace = artifacts.traces[Audit.DEFAULT_PASS];
     const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
     const metricComputationData = {trace, devtoolsLog, settings: context.settings};
-    const metricResult = await CumulativeLQD.request(metricComputationData, context);
+    const metricResult = await ComputedTBT.request(metricComputationData, context);
 
     return {
       score: Audit.computeLogNormalScore(
@@ -71,9 +74,10 @@ class CumulativeLongQueuingDelay extends Audit {
         context.options.scoreMedian
       ),
       numericValue: metricResult.timing,
-      displayValue: 10 * Math.round(metricResult.timing / 10) + '\xa0ms',
+      displayValue: str_(i18n.UIStrings.ms, {timeInMs: metricResult.timing}),
     };
   }
 }
 
-module.exports = CumulativeLongQueuingDelay;
+module.exports = TotalBlockingTime;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/computed/metrics/lantern-cumulative-long-queuing-delay.js b/lighthouse-core/computed/metrics/lantern-total-blocking-time.js
similarity index 82%
rename from lighthouse-core/computed/metrics/lantern-cumulative-long-queuing-delay.js
rename to lighthouse-core/computed/metrics/lantern-total-blocking-time.js
index 112b3539f..214f5c425 100644
--- a/lighthouse-core/computed/metrics/lantern-cumulative-long-queuing-delay.js
+++ b/lighthouse-core/computed/metrics/lantern-total-blocking-time.js
@@ -13,7 +13,7 @@ const LanternInteractive = require('./lantern-interactive.js');
 
 /** @typedef {BaseNode.Node} Node */
 
-class LanternCumulativeLongQueuingDelay extends LanternMetric {
+class LanternTotalBlockingTime extends LanternMetric {
   /**
    * @return {LH.Gatherer.Simulation.MetricCoefficients}
    */
@@ -48,32 +48,32 @@ class LanternCumulativeLongQueuingDelay extends LanternMetric {
    */
   static getEstimateFromSimulation(simulation, extras) {
     // Intentionally use the opposite FCP estimate. A pessimistic FCP is higher than equal to an
-    // optimistic FCP, which means potentially more tasks are excluded from the
-    // CumulativeLongQueuingDelay computation. So a more pessimistic FCP gives a more optimistic
-    // CumulativeLongQueuingDelay for the same work.
+    // optimistic FCP, which means potentially more tasks are excluded from the Total Blocking Time
+    // computation. So a more pessimistic FCP gives a more optimistic Total Blocking Time for the
+    // same work.
     const fcpTimeInMs = extras.optimistic
       ? extras.fcpResult.pessimisticEstimate.timeInMs
       : extras.fcpResult.optimisticEstimate.timeInMs;
 
     // Similarly, we always have pessimistic TTI >= optimistic TTI. Therefore, picking optimistic
     // TTI means our window of interest is smaller and thus potentially more tasks are excluded from
-    // CumulativeLongQueuingDelay computation, yielding a lower (more optimistic)
-    // CumulativeLongQueuingDelay value for the same work.
+    // Total Blocking Time computation, yielding a lower (more optimistic) Total Blocking Time value
+    // for the same work.
     const interactiveTimeMs = extras.optimistic
       ? extras.interactiveResult.optimisticEstimate.timeInMs
       : extras.interactiveResult.pessimisticEstimate.timeInMs;
 
     // Require here to resolve circular dependency.
-    const CumulativeLongQueuingDelay = require('./cumulative-long-queuing-delay.js');
-    const minDurationMs = CumulativeLongQueuingDelay.LONG_QUEUING_DELAY_THRESHOLD;
+    const TotalBlockingTime = require('./total-blocking-time.js');
+    const minDurationMs = TotalBlockingTime.BLOCKING_TIME_THRESHOLD;
 
-    const events = LanternCumulativeLongQueuingDelay.getTopLevelEvents(
+    const events = LanternTotalBlockingTime.getTopLevelEvents(
       simulation.nodeTimings,
       minDurationMs
     );
 
     return {
-      timeInMs: CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
+      timeInMs: TotalBlockingTime.calculateSumOfBlockingTime(
         events,
         fcpTimeInMs,
         interactiveTimeMs
@@ -118,4 +118,4 @@ class LanternCumulativeLongQueuingDelay extends LanternMetric {
   }
 }
 
-module.exports = makeComputedArtifact(LanternCumulativeLongQueuingDelay);
+module.exports = makeComputedArtifact(LanternTotalBlockingTime);
diff --git a/lighthouse-core/computed/metrics/cumulative-long-queuing-delay.js b/lighthouse-core/computed/metrics/total-blocking-time.js
similarity index 51%
rename from lighthouse-core/computed/metrics/cumulative-long-queuing-delay.js
rename to lighthouse-core/computed/metrics/total-blocking-time.js
index 18aa99d15..d5419203d 100644
--- a/lighthouse-core/computed/metrics/cumulative-long-queuing-delay.js
+++ b/lighthouse-core/computed/metrics/total-blocking-time.js
@@ -9,28 +9,27 @@ const makeComputedArtifact = require('../computed-artifact.js');
 const ComputedMetric = require('./metric.js');
 const LHError = require('../../lib/lh-error.js');
 const TracingProcessor = require('../../lib/tracehouse/trace-processor.js');
-const LanternCumulativeLongQueuingDelay = require('./lantern-cumulative-long-queuing-delay.js');
+const LanternTotalBlockingTime = require('./lantern-total-blocking-time.js');
 const TimetoInteractive = require('./interactive.js');
 
 /**
- * @fileoverview This audit determines Cumulative Long Queuing Delay between FCP and TTI.
+ * @fileoverview This audit determines Total Blocking Time.
 
- * We define Long Queuing Delay Region as any time interval in the loading timeline where queuing
- * time for an input event would be longer than 50ms. For example, if there is a 110ms main thread
- * task, the first 60ms of it is Long Queuing Delay Region, because any input event occuring in
- * that region has to wait more than 50ms. Cumulative Long Queuing Delay is the sum of all Long
- * Queuing Delay Regions between First Contentful Paint and Interactive Time (TTI).
+ * We define Blocking Time as any time interval in the loading timeline where task length exceeds
+ * 50ms. For example, if there is a 110ms main thread task, the last 60ms of it is blocking time.
+ * Total Blocking Time is the sum of all Blocking Time between First Contentful Paint and
+ * Interactive Time (TTI).
  *
  * This is a new metric designed to accompany Time to Interactive. TTI is strict and does not
  * reflect incremental improvements to the site performance unless the improvement concerns the last
- * long task. Cumulative Long Queuing Delay on the other hand is designed to be much more responsive
+ * long task. Total Blocking Time on the other hand is designed to be much more responsive
  * to smaller improvements to main thread responsiveness.
  */
-class CumulativeLongQueuingDelay extends ComputedMetric {
+class TotalBlockingTime extends ComputedMetric {
   /**
    * @return {number}
    */
-  static get LONG_QUEUING_DELAY_THRESHOLD() {
+  static get BLOCKING_TIME_THRESHOLD() {
     return 50;
   }
   /**
@@ -39,50 +38,43 @@ class CumulativeLongQueuingDelay extends ComputedMetric {
    * @param {number} interactiveTimeMs
    * @return {number}
    */
-  static calculateSumOfLongQueuingDelay(topLevelEvents, fcpTimeInMs, interactiveTimeMs) {
+  static calculateSumOfBlockingTime(topLevelEvents, fcpTimeInMs, interactiveTimeMs) {
     if (interactiveTimeMs <= fcpTimeInMs) return 0;
 
-    const threshold = CumulativeLongQueuingDelay.LONG_QUEUING_DELAY_THRESHOLD;
-    const longQueuingDelayRegions = [];
-    // First identifying the long queuing delay regions.
+    const threshold = TotalBlockingTime.BLOCKING_TIME_THRESHOLD;
+    let sumBlockingTime = 0;
     for (const event of topLevelEvents) {
-      // If the task is less than the delay threshold, it contains no Long Queuing Delay Region.
+      // Early exit for small tasks, which should far outnumber long tasks.
       if (event.duration < threshold) continue;
-      // Otherwise, the duration of the task before the delay-threshold-sized interval at the end is
-      // considered Long Queuing Delay Region. Example assuming the threshold is 50ms:
-      //   [              250ms Task                   ]
-      //   |  Long Queuing Delay Region  |   Last 50ms |
-      //               200 ms
-      longQueuingDelayRegions.push({
-        start: event.start,
-        end: event.end - threshold,
-        duration: event.duration - threshold,
-      });
-    }
 
-    let sumLongQueuingDelay = 0;
-    for (const region of longQueuingDelayRegions) {
-      // We only want to add up the Long Queuing Delay regions that fall between FCP and TTI.
-      //
+      // We only want to consider tasks that fall between FCP and TTI.
       // FCP is picked as the lower bound because there is little risk of user input happening
       // before FCP so Long Queuing Qelay regions do not harm user experience. Developers should be
       // optimizing to reach FCP as fast as possible without having to worry about task lengths.
-      //
+      if (event.end < fcpTimeInMs) continue;
+
       // TTI is picked as the upper bound because we want a well defined end point so that the
       // metric does not rely on how long we trace.
-      if (region.end < fcpTimeInMs) continue;
-      if (region.start > interactiveTimeMs) continue;
+      if (event.start > interactiveTimeMs) continue;
 
-      // If a Long Queuing Delay Region spans the edges of our region of interest, we clip it to
-      // only include the part of the region that falls inside.
-      const clippedStart = Math.max(region.start, fcpTimeInMs);
-      const clippedEnd = Math.min(region.end, interactiveTimeMs);
-      const queuingDelayAfterClipping = clippedEnd - clippedStart;
+      // We first perform the clipping, and then calculate Blocking Region. So if we have a 150ms
+      // task [0, 150] and FCP happens midway at 50ms, we first clip the task to [50, 150], and then
+      // calculate the Blocking Region to be [100, 150]. The rational here is that tasks before FCP
+      // are unimportant, so we care whether the main thread is busy more than 50ms at a time only
+      // after FCP.
+      const clippedStart = Math.max(event.start, fcpTimeInMs);
+      const clippedEnd = Math.min(event.end, interactiveTimeMs);
+      const clippedDuration = clippedEnd - clippedStart;
+      if (clippedDuration < threshold) continue;
 
-      sumLongQueuingDelay += queuingDelayAfterClipping;
+      // The duration of the task beyond 50ms at the beginning is considered the Blocking Region.
+      // Example:
+      //   [              250ms Task                   ]
+      //   | First 50ms |   Blocking Region (200ms)    |
+      sumBlockingTime += (clippedDuration - threshold);
     }
 
-    return sumLongQueuingDelay;
+    return sumBlockingTime;
   }
 
   /**
@@ -91,7 +83,7 @@ class CumulativeLongQueuingDelay extends ComputedMetric {
    * @return {Promise<LH.Artifacts.LanternMetric>}
    */
   static computeSimulatedMetric(data, context) {
-    return LanternCumulativeLongQueuingDelay.request(data, context);
+    return LanternTotalBlockingTime.request(data, context);
   }
 
   /**
@@ -107,12 +99,10 @@ class CumulativeLongQueuingDelay extends ComputedMetric {
 
     const interactiveTimeMs = (await TimetoInteractive.request(data, context)).timing;
 
-    // Not using the start time argument of getMainThreadTopLevelEvents, because
-    // we need to clip the part of the task before the last 50ms properly.
     const events = TracingProcessor.getMainThreadTopLevelEvents(data.traceOfTab);
 
     return {
-      timing: CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
+      timing: TotalBlockingTime.calculateSumOfBlockingTime(
         events,
         firstContentfulPaint,
         interactiveTimeMs
@@ -121,4 +111,4 @@ class CumulativeLongQueuingDelay extends ComputedMetric {
   }
 }
 
-module.exports = makeComputedArtifact(CumulativeLongQueuingDelay);
+module.exports = makeComputedArtifact(TotalBlockingTime);
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 93ab96ddf..e77251978 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -21,8 +21,8 @@ const UIStrings = {
   metricGroupTitle: 'Metrics',
   /** Title of the opportunity section of the Performance category. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page. 'Suggestion'/'Optimization'/'Recommendation' are reasonable synonyms for 'opportunity' in this case. */
   loadOpportunitiesGroupTitle: 'Opportunities',
-  /** Description of the opportunity section of the Performance category. 'Optimizations' could also be 'recommendations' or 'suggestions'. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page. */
-  loadOpportunitiesGroupDescription: 'These optimizations can speed up your page load.',
+  /** Description of the opportunity section of the Performance category. 'Suggestions' could also be 'recommendations'. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page. */
+  loadOpportunitiesGroupDescription: 'These suggestions can help your page load faster. They don\'t [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.',
   /** Title of an opportunity sub-section of the Performance category. Within this section are audits with imperative titles that suggest actions the user can take to improve the time of the first initial render of the webpage. */
   firstPaintImprovementsGroupTitle: 'First Paint Improvements',
   /** Description of an opportunity sub-section of the Performance category. Within this section are audits with imperative titles that suggest actions the user can take to improve the time of the first initial render of the webpage. */
@@ -33,8 +33,8 @@ const UIStrings = {
   overallImprovementsGroupDescription: 'Enhance the overall loading experience, so the page is responsive and ready to use as soon as possible. Key metrics: Time to Interactive, Speed Index',
   /** Title of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on the page's page load performance characteristics. Whereas the 'Opportunities' suggest an action along with expected time savings, diagnostics do not. Within this section, the user may read the details and deduce additional actions they could take. */
   diagnosticsGroupTitle: 'Diagnostics',
-  /** Description of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on the page's page load performance characteristics. Whereas the 'Opportunities' suggest an action along with expected time savings, diagnostics do not. Within this section, the user may read the details and deduce additional actions they could take. */
-  diagnosticsGroupDescription: 'More information about the performance of your application.',
+  /** Description of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on a web page's load performance characteristics. Within this section, the user may read the details and deduce additional actions they could take to improve performance. */
+  diagnosticsGroupDescription: 'More information about the performance of your application. These numbers don\'t [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.',
   /** Title of the Accessibility category of audits. This section contains audits focused on making web content accessible to all users. Also used as a label of a score gauge; try to limit to 20 characters. */
   a11yCategoryTitle: 'Accessibility',
   /** Description of the Accessibility category. This is displayed at the top of a list of audits focused on making web content accessible to all users. No character length limits. 'improve the accessibility of your web app' becomes link text to additional documentation. */
@@ -171,7 +171,7 @@ const defaultConfig = {
     'screenshot-thumbnails',
     'final-screenshot',
     'metrics/estimated-input-latency',
-    'metrics/cumulative-long-queuing-delay',
+    'metrics/total-blocking-time',
     'metrics/max-potential-fid',
     'errors-in-console',
     'time-to-first-byte',
@@ -369,7 +369,7 @@ const defaultConfig = {
         {id: 'first-cpu-idle', weight: 2, group: 'metrics'},
         {id: 'max-potential-fid', weight: 0, group: 'metrics'},
         {id: 'estimated-input-latency', weight: 0}, // intentionally left out of metrics so it won't be displayed
-        {id: 'cumulative-long-queuing-delay', weight: 0}, // intentionally left out of metrics so it won't be displayed
+        {id: 'total-blocking-time', weight: 0}, // intentionally left out of metrics so it won't be displayed
         {id: 'render-blocking-resources', weight: 0, group: 'load-opportunities'},
         {id: 'uses-responsive-images', weight: 0, group: 'load-opportunities'},
         {id: 'offscreen-images', weight: 0, group: 'load-opportunities'},
diff --git a/lighthouse-core/lib/asset-saver.js b/lighthouse-core/lib/asset-saver.js
index b5325c877..8ff4c65ba 100644
--- a/lighthouse-core/lib/asset-saver.js
+++ b/lighthouse-core/lib/asset-saver.js
@@ -16,6 +16,7 @@ const rimraf = require('rimraf');
 const mkdirp = require('mkdirp');
 const NetworkAnalysisComputed = require('../computed/network-analysis.js');
 const LoadSimulatorComputed = require('../computed/load-simulator.js');
+const LHError = require('../lib/lh-error.js');
 
 const artifactsFilename = 'artifacts.json';
 const traceSuffix = '.trace.json';
@@ -42,9 +43,10 @@ function loadArtifacts(basePath) {
     throw new Error('No saved artifacts found at ' + basePath);
   }
 
-  // load artifacts.json
+  // load artifacts.json using a reviver to deserialize any LHErrors in artifacts.
+  const artifactsStr = fs.readFileSync(path.join(basePath, artifactsFilename), 'utf8');
   /** @type {LH.Artifacts} */
-  const artifacts = JSON.parse(fs.readFileSync(path.join(basePath, artifactsFilename), 'utf8'));
+  const artifacts = JSON.parse(artifactsStr, LHError.parseReviver);
 
   const filenames = fs.readdirSync(basePath);
 
@@ -73,6 +75,21 @@ function loadArtifacts(basePath) {
   return artifacts;
 }
 
+/**
+ * A replacer function for JSON.stingify of the artifacts. Used to serialize objects that
+ * JSON won't normally handle.
+ * @param {string} key
+ * @param {any} value
+ */
+function stringifyReplacer(key, value) {
+  // Currently only handle LHError and other Error types.
+  if (value instanceof Error) {
+    return LHError.stringifyReplacer(value);
+  }
+
+  return value;
+}
+
 /**
  * Save artifacts object mostly to single file located at basePath/artifacts.log.
  * Also save the traces & devtoolsLogs to their own files
@@ -100,8 +117,8 @@ async function saveArtifacts(artifacts, basePath) {
     fs.writeFileSync(`${basePath}/${passName}${devtoolsLogSuffix}`, log, 'utf8');
   }
 
-  // save everything else
-  const restArtifactsString = JSON.stringify(restArtifacts, null, 2);
+  // save everything else, using a replacer to serialize LHErrors in the artifacts.
+  const restArtifactsString = JSON.stringify(restArtifacts, stringifyReplacer, 2);
   fs.writeFileSync(`${basePath}/${artifactsFilename}`, restArtifactsString, 'utf8');
   log.log('Artifacts saved to disk in folder:', basePath);
   log.timeEnd(status);
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index b0dd8ba42..896c9019e 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -1039,6 +1039,14 @@
     "message": "Speed Index",
     "description": "The name of the metric that summarizes how quickly the page looked visually complete. The name of this metric is largely abstract and can be loosely translated. Shown to users as the label for the numeric metric value. Ideally fits within a ~40 character limit."
   },
+  "lighthouse-core/audits/metrics/total-blocking-time.js | description": {
+    "message": "Sum of all time periods between FCP and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds.",
+    "description": "Description of the Total Blocking Time (TBT) metric, which calculates the total duration of blocking time for a web page. Blocking times are time periods when the page would be blocked (prevented) from responding to user input (clicks, taps, and keypresses will feel slow to respond). This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits."
+  },
+  "lighthouse-core/audits/metrics/total-blocking-time.js | title": {
+    "message": "Total Blocking Time",
+    "description": "The name of a metric that calculates the total duration of blocking time for a web page. Blocking times are time periods when the page would be blocked (prevented) from responding to user input (clicks, taps, and keypresses will feel slow to respond). Shown to users as the label for the numeric metric value. Ideally fits within a ~40 character limit."
+  },
   "lighthouse-core/audits/network-rtt.js | description": {
     "message": "Network round trip times (RTT) have a large impact on performance. If the RTT to an origin is high, it's an indication that servers closer to the user could improve performance. [Learn more](https://hpbn.co/primer-on-latency-and-bandwidth/).",
     "description": "Description of a Lighthouse audit that tells the user that a high network round trip time (RTT) can effect their website's performance because the server is physically far away from them thus making the RTT high. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
@@ -1476,8 +1484,8 @@
     "description": "Title of the Budgets section of the Performance Category. 'Budgets' refers to a budget (like a financial budget), but applied to the amount of resources on a page, rather than money."
   },
   "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": {
-    "message": "More information about the performance of your application.",
-    "description": "Description of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on the page's page load performance characteristics. Whereas the 'Opportunities' suggest an action along with expected time savings, diagnostics do not. Within this section, the user may read the details and deduce additional actions they could take."
+    "message": "More information about the performance of your application. These numbers don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
+    "description": "Description of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on a web page's load performance characteristics. Within this section, the user may read the details and deduce additional actions they could take to improve performance."
   },
   "lighthouse-core/config/default-config.js | diagnosticsGroupTitle": {
     "message": "Diagnostics",
@@ -1492,8 +1500,8 @@
     "description": "Title of an opportunity sub-section of the Performance category. Within this section are audits with imperative titles that suggest actions the user can take to improve the time of the first initial render of the webpage."
   },
   "lighthouse-core/config/default-config.js | loadOpportunitiesGroupDescription": {
-    "message": "These optimizations can speed up your page load.",
-    "description": "Description of the opportunity section of the Performance category. 'Optimizations' could also be 'recommendations' or 'suggestions'. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page."
+    "message": "These suggestions can help your page load faster. They don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
+    "description": "Description of the opportunity section of the Performance category. 'Suggestions' could also be 'recommendations'. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page."
   },
   "lighthouse-core/config/default-config.js | loadOpportunitiesGroupTitle": {
     "message": "Opportunities",
@@ -1788,7 +1796,7 @@
     "description": "Label shown preceding any important warnings that may have invalidated the entire report. For example, if the user has Chrome extensions installed, they may add enough performance overhead that Lighthouse's performance metrics are unreliable. If shown, this will be displayed at the top of the report UI."
   },
   "lighthouse-core/report/html/renderer/util.js | varianceDisclaimer": {
-    "message": "Values are estimated and may vary.",
+    "message": "Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).",
     "description": "Disclaimer shown to users below the metric values (First Contentful Paint, Time to Interactive, etc) to warn them that the numbers they see will likely change slightly the next time they run Lighthouse."
   },
   "lighthouse-core/report/html/renderer/util.js | warningAuditsGroupTitle": {
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 7759bc4ca..685a1f833 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -779,6 +779,12 @@
   "lighthouse-core/audits/metrics/speed-index.js | title": {
     "message": "Ŝṕêéd̂ Ín̂d́êx́"
   },
+  "lighthouse-core/audits/metrics/total-blocking-time.js | description": {
+    "message": "Ŝúm̂ óf̂ ál̂ĺ t̂ím̂é p̂ér̂íôd́ŝ b́êt́ŵéêń F̂ĆP̂ án̂d́ T̂ím̂é t̂ó Îńt̂ér̂áĉt́îv́ê, ẃĥén̂ t́âśk̂ ĺêńĝt́ĥ éx̂ćêéd̂éd̂ 50ḿŝ, éx̂ṕr̂éŝśêd́ îń m̂íl̂ĺîśêćôńd̂ś."
+  },
+  "lighthouse-core/audits/metrics/total-blocking-time.js | title": {
+    "message": "T̂ót̂ál̂ B́l̂óĉḱîńĝ T́îḿê"
+  },
   "lighthouse-core/audits/network-rtt.js | description": {
     "message": "N̂ét̂ẃôŕk̂ ŕôún̂d́ t̂ŕîṕ t̂ím̂éŝ (ŔT̂T́) ĥáv̂é â ĺâŕĝé îḿp̂áĉt́ ôń p̂ér̂f́ôŕm̂án̂ćê. Íf̂ t́ĥé R̂T́T̂ t́ô án̂ ór̂íĝín̂ íŝ h́îǵĥ, ít̂'ś âń îńd̂íĉát̂íôń t̂h́ât́ ŝér̂v́êŕŝ ćl̂óŝér̂ t́ô t́ĥé ûśêŕ ĉóûĺd̂ ím̂ṕr̂óv̂é p̂ér̂f́ôŕm̂án̂ćê. [Ĺêár̂ń m̂ór̂é](ĥt́t̂ṕŝ://h́p̂b́n̂.ćô/ṕr̂ím̂ér̂-ón̂-ĺât́êńĉý-âńd̂-b́âńd̂ẃîd́t̂h́/)."
   },
@@ -1107,7 +1113,7 @@
     "message": "B̂úd̂ǵêt́ŝ"
   },
   "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": {
-    "message": "M̂ór̂é îńf̂ór̂ḿât́îón̂ áb̂óût́ t̂h́ê ṕêŕf̂ór̂ḿâńĉé ôf́ ŷóûŕ âṕp̂ĺîćât́îón̂."
+    "message": "M̂ór̂é îńf̂ór̂ḿât́îón̂ áb̂óût́ t̂h́ê ṕêŕf̂ór̂ḿâńĉé ôf́ ŷóûŕ âṕp̂ĺîćât́îón̂. T́ĥéŝé n̂úm̂b́êŕŝ d́ôń't̂ [d́îŕêćt̂ĺŷ áf̂f́êćt̂](h́t̂t́p̂ś://ĝít̂h́ûb́.ĉóm̂/ǴôóĝĺêĆĥŕôḿê/ĺîǵĥt́ĥóûśê/b́l̂ób̂/d́2êć9f̂f́b̂b́21d̂é9âd́1â0f́86êd́24575êd́â32ć796f̂0/d́ôćŝ/śĉór̂ín̂ǵ.m̂d́#ĥóŵ-ár̂é-t̂h́ê-śĉór̂éŝ-ẃêíĝh́t̂éd̂) t́ĥé P̂ér̂f́ôŕm̂án̂ćê śĉór̂é."
   },
   "lighthouse-core/config/default-config.js | diagnosticsGroupTitle": {
     "message": "D̂íâǵn̂óŝt́îćŝ"
@@ -1119,7 +1125,7 @@
     "message": "F̂ír̂śt̂ Ṕâín̂t́ Îḿp̂ŕôv́êḿêńt̂ś"
   },
   "lighthouse-core/config/default-config.js | loadOpportunitiesGroupDescription": {
-    "message": "T̂h́êśê óp̂t́îḿîźât́îón̂ś ĉán̂ śp̂éêd́ ûṕ ŷóûŕ p̂áĝé l̂óâd́."
+    "message": "T̂h́êśê śûǵĝéŝt́îón̂ś ĉán̂ h́êĺp̂ ýôúr̂ ṕâǵê ĺôád̂ f́âśt̂ér̂. T́ĥéŷ d́ôń't̂ [d́îŕêćt̂ĺŷ áf̂f́êćt̂](h́t̂t́p̂ś://ĝít̂h́ûb́.ĉóm̂/ǴôóĝĺêĆĥŕôḿê/ĺîǵĥt́ĥóûśê/b́l̂ób̂/d́2êć9f̂f́b̂b́21d̂é9âd́1â0f́86êd́24575êd́â32ć796f̂0/d́ôćŝ/śĉór̂ín̂ǵ.m̂d́#ĥóŵ-ár̂é-t̂h́ê-śĉór̂éŝ-ẃêíĝh́t̂éd̂) t́ĥé P̂ér̂f́ôŕm̂án̂ćê śĉór̂é."
   },
   "lighthouse-core/config/default-config.js | loadOpportunitiesGroupTitle": {
     "message": "Ôṕp̂ór̂t́ûńît́îéŝ"
@@ -1341,7 +1347,7 @@
     "message": "T̂h́êŕê ẃêŕê íŝśûéŝ áf̂f́êćt̂ín̂ǵ t̂h́îś r̂ún̂ óf̂ Ĺîǵĥt́ĥóûśê:"
   },
   "lighthouse-core/report/html/renderer/util.js | varianceDisclaimer": {
-    "message": "V̂ál̂úêś âŕê éŝt́îḿât́êd́ âńd̂ ḿâý v̂ár̂ý."
+    "message": "V̂ál̂úêś âŕê éŝt́îḿât́êd́ âńd̂ ḿâý v̂ár̂ý. T̂h́ê ṕêŕf̂ór̂ḿâńĉé ŝćôŕê íŝ [b́âśêd́ ôńl̂ý ôń t̂h́êśê ḿêt́r̂íĉś](ĥt́t̂ṕŝ://ǵît́ĥúb̂.ćôḿ/Ĝóôǵl̂éĈh́r̂óm̂é/l̂íĝh́t̂h́ôúŝé/b̂ĺôb́/d̂2éĉ9f́f̂b́b̂21d́ê9ád̂1á0f̂86éd̂24575éd̂á32ĉ796f́0/d̂óĉś/ŝćôŕîńĝ.ḿd̂#h́ôẃ-âŕê-t́ĥé-ŝćôŕêś-ŵéîǵĥt́êd́)."
   },
   "lighthouse-core/report/html/renderer/util.js | warningAuditsGroupTitle": {
     "message": "P̂áŝśêd́ âúd̂ít̂ś b̂út̂ ẃît́ĥ ẃâŕn̂ín̂ǵŝ"
diff --git a/lighthouse-core/lib/lh-error.js b/lighthouse-core/lib/lh-error.js
index 63804c1ac..7c71b1778 100644
--- a/lighthouse-core/lib/lh-error.js
+++ b/lighthouse-core/lib/lh-error.js
@@ -56,10 +56,17 @@ const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
  * @property {boolean} [lhrRuntimeError] True if it should appear in the top-level LHR.runtimeError property.
  */
 
+const LHERROR_SENTINEL = '__LighthouseErrorSentinel';
+const ERROR_SENTINEL = '__ErrorSentinel';
+/**
+ * @typedef {{sentinel: '__LighthouseErrorSentinel', code: string, stack?: string, [p: string]: string|undefined}} SerializedLighthouseError
+ * @typedef {{sentinel: '__ErrorSentinel', message: string, code?: string, stack?: string}} SerializedBaseError
+ */
+
 class LighthouseError extends Error {
   /**
    * @param {LighthouseErrorDefinition} errorDefinition
-   * @param {Record<string, string|boolean|undefined>=} properties
+   * @param {Record<string, string|undefined>=} properties
    */
   constructor(errorDefinition, properties) {
     super(errorDefinition.code);
@@ -96,6 +103,77 @@ class LighthouseError extends Error {
     const error = new Error(`Protocol error ${errMsg}`);
     return Object.assign(error, {protocolMethod: method, protocolError: protocolError.message});
   }
+
+  /**
+   * A JSON.stringify replacer to serialize LHErrors and (as a fallback) Errors.
+   * Returns a simplified version of the error object that can be reconstituted
+   * as a copy of the original error at parse time.
+   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter
+   * @param {Error|LighthouseError} err
+   * @return {SerializedBaseError|SerializedLighthouseError}
+   */
+  static stringifyReplacer(err) {
+    if (err instanceof LighthouseError) {
+      // Remove class props so that remaining values were what was passed in as `properties`.
+      // eslint-disable-next-line no-unused-vars
+      const {name, code, message, friendlyMessage, lhrRuntimeError, stack, ...properties} = err;
+
+      return {
+        sentinel: LHERROR_SENTINEL,
+        code,
+        stack,
+        ...properties,
+      };
+    }
+
+    // Unexpected errors won't be LHErrors, but we want them serialized as well.
+    if (err instanceof Error) {
+      const {message, stack} = err;
+      // @ts-ignore - code can be helpful for e.g. node errors, so preserve it if it's present.
+      const code = err.code;
+      return {
+        sentinel: ERROR_SENTINEL,
+        message,
+        code,
+        stack,
+      };
+    }
+
+    throw new Error('Invalid value for LHError stringification');
+  }
+
+  /**
+   * A JSON.parse reviver. If any value passed in is a serialized Error or
+   * LHError, the error is recreated as the original object. Otherwise, the
+   * value is passed through unchanged.
+   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#Using_the_reviver_parameter
+   * @param {string} key
+   * @param {any} possibleError
+   * @return {any}
+   */
+  static parseReviver(key, possibleError) {
+    if (typeof possibleError === 'object' && possibleError !== null) {
+      if (possibleError.sentinel === LHERROR_SENTINEL) {
+        // Include sentinel in destructuring so it doesn't end up in `properties`.
+        // eslint-disable-next-line no-unused-vars
+        const {sentinel, code, stack, ...properties} = /** @type {SerializedLighthouseError} */ (possibleError);
+        const errorDefinition = LighthouseError.errors[/** @type {keyof typeof ERRORS} */ (code)];
+        const lhError = new LighthouseError(errorDefinition, properties);
+        lhError.stack = stack;
+
+        return lhError;
+      }
+
+      if (possibleError.sentinel === ERROR_SENTINEL) {
+        const {message, code, stack} = /** @type {SerializedBaseError} */ (possibleError);
+        const error = new Error(message);
+        Object.assign(error, {code, stack});
+        return error;
+      }
+    }
+
+    return possibleError;
+  }
 }
 
 const ERRORS = {
@@ -227,7 +305,7 @@ const ERRORS = {
   },
 
   /* Protocol timeout failures
-   * Requires an additional `icuProtocolMethod` field for translation.
+   * Requires an additional `protocolMethod` field for translation.
    */
   PROTOCOL_TIMEOUT: {
     code: 'PROTOCOL_TIMEOUT',
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 1193bb3a8..52c0975f0 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -148,7 +148,8 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
     // 'Values are estimated and may vary' is used as the category description for PSI
     if (environment !== 'PSI') {
       const estValuesEl = this.dom.createChildOf(metricAuditsEl, 'div', 'lh-metrics__disclaimer');
-      estValuesEl.textContent = Util.UIStrings.varianceDisclaimer;
+      const disclaimerEl = this.dom.convertMarkdownLinkSnippets(Util.UIStrings.varianceDisclaimer);
+      estValuesEl.appendChild(disclaimerEl);
     }
 
     metricAuditsEl.classList.add('lh-audit-group--metrics');
diff --git a/lighthouse-core/report/html/renderer/util.js b/lighthouse-core/report/html/renderer/util.js
index f29b62933..b47602a57 100644
--- a/lighthouse-core/report/html/renderer/util.js
+++ b/lighthouse-core/report/html/renderer/util.js
@@ -557,7 +557,7 @@ Util.numberDateLocale = 'en';
  */
 Util.UIStrings = {
   /** Disclaimer shown to users below the metric values (First Contentful Paint, Time to Interactive, etc) to warn them that the numbers they see will likely change slightly the next time they run Lighthouse. */
-  varianceDisclaimer: 'Values are estimated and may vary.',
+  varianceDisclaimer: 'Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).',
   /** Column heading label for the listing of opportunity audits. Each audit title represents an opportunity. There are only 2 columns, so no strict character limit.  */
   opportunityResourceColumnLabel: 'Opportunity',
   /** Column heading label for the estimated page load savings of opportunity audits. Estimated Savings is the total amount of time (in seconds) that Lighthouse computed could be reduced from the total page load time, if the suggested action is taken. There are only 2 columns, so no strict character limit. */
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index be06994c3..5e3d387ed 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -45,6 +45,7 @@
   --color-gray-50: #FAFAFA;
   --color-gray-500: #9E9E9E;
   --color-gray-600: #757575;
+  --color-gray-700: #616161;
   --color-gray-800: #424242;
   --color-gray-900: #212121;
   --color-gray: #000000;
@@ -97,6 +98,7 @@
   --header-line-height: 24px;
   --highlighter-background-color: var(--report-text-color);
   --icon-square-size: calc(var(--score-icon-size) * 0.88);
+  --image-preview-size: 48px;
   --metric-toggle-lines-fill: #7F7F7F;
   --metrics-toggle-background-color: var(--color-gray-200);
   --plugin-badge-background-color: var(--color-white);
@@ -208,6 +210,7 @@
     --gauge-percentage-font-size: 26px;
     --gauge-wrapper-width: 112px;
     --header-padding: 16px 0 16px 0;
+    --image-preview-size: 24px;
     --plugin-icon-size: 75%;
     --pwa-icon-margin: 0 7px 0 -3px;
     --report-font-size: 14px;
@@ -591,6 +594,9 @@
   color: var(--color-gray-600);
   margin: var(--section-padding-vertical) 0;
 }
+.lh-metrics__disclaimer a {
+  color: var(--color-gray-700);
+}
 
 .lh-metric__description {
   display: none;
@@ -1300,7 +1306,6 @@
 }
 
 .lh-table {
-  --image-preview-size: 24px;
   border-collapse: collapse;
   /* Can't assign padding to table, so shorten the width instead. */
   width: calc(100% - var(--audit-description-padding-left));
@@ -1347,8 +1352,10 @@
 }
 
 
-.lh-table-column--thumbnail {
-  width: calc(var(--image-preview-size) * 2);
+
+.lh-table .lh-table-column--thumbnail {
+  width: var(--image-preview-size);
+  padding: 0;
 }
 
 .lh-table-column--url {
@@ -1377,9 +1384,10 @@
 }
 
 .lh-thumbnail {
-  height: var(--image-preview-size);
+  object-fit: cover;
   width: var(--image-preview-size);
-  object-fit: contain;
+  height: var(--image-preview-size);
+  display: block;
 }
 
 .lh-text__url > a {
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 26687d46d..a418bf9f1 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -45,7 +45,7 @@ limitations under the License.
 <!-- Lighthouse category header -->
 <template id="tmpl-lh-category-header">
   <div class="lh-category-header">
-    <div class="lh-score__gauge"></div>
+    <div class="lh-score__gauge" role="heading" aria-level="2"></div>
     <div class="lh-category-header__description"></div>
   </div>
 </template>
diff --git a/lighthouse-core/scripts/build-report-for-autodeployment.js b/lighthouse-core/scripts/build-report-for-autodeployment.js
index 1fd9c2904..ea1260a86 100644
--- a/lighthouse-core/scripts/build-report-for-autodeployment.js
+++ b/lighthouse-core/scripts/build-report-for-autodeployment.js
@@ -19,7 +19,7 @@ const {defaultSettings} = require('../config/constants.js');
 const lighthouse = require('../index.js');
 const lhr = /** @type {LH.Result} */ (require('../../lighthouse-core/test/results/sample_v2.json'));
 
-const DIST = path.join(__dirname, `../../dist`);
+const DIST = path.join(__dirname, `../../dist/now`);
 
 (async function() {
   addPluginCategory(lhr);
diff --git a/now.json b/now.json
index 02b220f9d..9e04006d2 100644
--- a/now.json
+++ b/now.json
@@ -3,7 +3,8 @@
   "builds": [
     {
       "src": "package.json",
-      "use": "@now/static-build"
+      "use": "@now/static-build",
+      "config": {"distDir": "dist/now"}
     }
   ],
   "github": {
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 8e2f7b1af..63b760193 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -377,15 +377,6 @@
             "scoreDisplayMode": "informative",
             "title": "Minimize Critical Requests Depth"
         },
-        "cumulative-long-queuing-delay": {
-            "description": "[Experimental metric] Total time period between FCP and Time to Interactive during which queuing time for any input event would be higher than 50ms.",
-            "displayValue": "120\u00a0ms",
-            "id": "cumulative-long-queuing-delay",
-            "numericValue": 116.79800000000023,
-            "score": 1.0,
-            "scoreDisplayMode": "numeric",
-            "title": "Cumulative Long Queuing Delay"
-        },
         "custom-controls-labels": {
             "description": "Custom interactive controls have associated labels, provided by aria-label or aria-labelledby. [Learn more](https://developers.google.com/web/fundamentals/accessibility/how-to-review#try_it_with_a_screen_reader).",
             "id": "custom-controls-labels",
@@ -1494,7 +1485,6 @@
             "details": {
                 "items": [
                     {
-                        "cumulativeLongQueuingDelay": 117.0,
                         "estimatedInputLatency": 16.0,
                         "firstCPUIdle": 4927.0,
                         "firstCPUIdleTs": 185608247190.0,
@@ -1525,7 +1515,8 @@
                         "observedTraceEnd": 10281.0,
                         "observedTraceEndTs": 185613601189.0,
                         "speedIndex": 4417.0,
-                        "speedIndexTs": 185607736912.0
+                        "speedIndexTs": 185607736912.0,
+                        "totalBlockingTime": 117.0
                     }
                 ],
                 "type": "debugdata"
@@ -2629,6 +2620,15 @@
             "scoreDisplayMode": "binary",
             "title": "Server response times are low (TTFB)"
         },
+        "total-blocking-time": {
+            "description": "Sum of all time periods between FCP and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds.",
+            "displayValue": "120\u00a0ms",
+            "id": "total-blocking-time",
+            "numericValue": 116.79800000000023,
+            "score": 1.0,
+            "scoreDisplayMode": "numeric",
+            "title": "Total Blocking Time"
+        },
         "total-byte-weight": {
             "description": "Large network payloads cost users real money and are highly correlated with long load times. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/network-payloads).",
             "details": {
@@ -3546,7 +3546,7 @@
                     "weight": 0.0
                 },
                 {
-                    "id": "cumulative-long-queuing-delay",
+                    "id": "total-blocking-time",
                     "weight": 0.0
                 },
                 {
@@ -3914,11 +3914,11 @@
             "title": "Budgets"
         },
         "diagnostics": {
-            "description": "More information about the performance of your application.",
+            "description": "More information about the performance of your application. These numbers don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
             "title": "Diagnostics"
         },
         "load-opportunities": {
-            "description": "These optimizations can speed up your page load.",
+            "description": "These suggestions can help your page load faster. They don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
             "title": "Opportunities"
         },
         "metrics": {
@@ -3976,7 +3976,7 @@
             "snippetExpandButtonLabel": "Expand snippet",
             "thirdPartyResourcesLabel": "Show 3rd-party resources",
             "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:",
-            "varianceDisclaimer": "Values are estimated and may vary.",
+            "varianceDisclaimer": "Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).",
             "warningAuditsGroupTitle": "Passed audits but with warnings",
             "warningHeader": "Warnings: "
         }
@@ -4180,13 +4180,13 @@
             {
                 "duration": 100.0,
                 "entryType": "measure",
-                "name": "lh:audit:cumulative-long-queuing-delay",
+                "name": "lh:audit:total-blocking-time",
                 "startTime": 0.0
             },
             {
                 "duration": 100.0,
                 "entryType": "measure",
-                "name": "lh:computed:CumulativeLongQueuingDelay",
+                "name": "lh:computed:TotalBlockingTime",
                 "startTime": 0.0
             },
             {


Test patch:
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index 06c58615e..dbb9cbc02 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -43,7 +43,7 @@ Object {
       "path": "metrics/estimated-input-latency",
     },
     Object {
-      "path": "metrics/cumulative-long-queuing-delay",
+      "path": "metrics/total-blocking-time",
     },
     Object {
       "path": "metrics/max-potential-fid",
@@ -717,7 +717,7 @@ Object {
           "weight": 0,
         },
         Object {
-          "id": "cumulative-long-queuing-delay",
+          "id": "total-blocking-time",
           "weight": 0,
         },
         Object {
@@ -1079,11 +1079,11 @@ Object {
       "title": "Budgets",
     },
     "diagnostics": Object {
-      "description": "More information about the performance of your application.",
+      "description": "More information about the performance of your application. These numbers don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
       "title": "Diagnostics",
     },
     "load-opportunities": Object {
-      "description": "These optimizations can speed up your page load.",
+      "description": "These suggestions can help your page load faster. They don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
       "title": "Opportunities",
     },
     "metrics": Object {
@@ -1326,11 +1326,11 @@ Object {
       "title": "Budgets",
     },
     "diagnostics": Object {
-      "description": "More information about the performance of your application.",
+      "description": "More information about the performance of your application. These numbers don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
       "title": "Diagnostics",
     },
     "load-opportunities": Object {
-      "description": "These optimizations can speed up your page load.",
+      "description": "These suggestions can help your page load faster. They don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score.",
       "title": "Opportunities",
     },
     "metrics": Object {
diff --git a/lighthouse-core/test/audits/__snapshots__/metrics-test.js.snap b/lighthouse-core/test/audits/__snapshots__/metrics-test.js.snap
index 685d98388..f79601e53 100644
--- a/lighthouse-core/test/audits/__snapshots__/metrics-test.js.snap
+++ b/lighthouse-core/test/audits/__snapshots__/metrics-test.js.snap
@@ -2,7 +2,6 @@
 
 exports[`Performance: metrics evaluates valid input correctly 1`] = `
 Object {
-  "cumulativeLongQueuingDelay": 748,
   "estimatedInputLatency": 78,
   "estimatedInputLatencyTs": undefined,
   "firstCPUIdle": 3351,
@@ -35,5 +34,6 @@ Object {
   "observedTraceEndTs": 225426711887,
   "speedIndex": 1657,
   "speedIndexTs": undefined,
+  "totalBlockingTime": 726,
 }
 `;
diff --git a/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js b/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js
index ef65ff78c..33f1c60fe 100644
--- a/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js
+++ b/lighthouse-core/test/audits/dobetterweb/no-vulnerable-libraries-test.js
@@ -8,6 +8,7 @@
 const NoVulnerableLibrariesAudit =
   require('../../../audits/dobetterweb/no-vulnerable-libraries.js');
 const assert = require('assert');
+const semver = require('semver');
 
 /* eslint-env jest */
 describe('Avoids front-end JavaScript libraries with known vulnerabilities', () => {
@@ -99,3 +100,55 @@ describe('Avoids front-end JavaScript libraries with known vulnerabilities', ()
     assert.equal(auditResult.score, 1);
   });
 });
+
+describe('Snyk database', () => {
+  // https://github.com/npm/node-semver/issues/166#issuecomment-245990039
+  function hasUpperBound(rangeString) {
+    const range = new semver.Range(rangeString);
+    if (!range) return false;
+
+    // For every subset ...
+    for (const subset of range.set) {
+    // Upperbound exists if...
+
+    // < or <= is in one of the subset's clauses (= gets normalized to >= and <).
+      if (subset.some(comparator => comparator.operator && comparator.operator.match(/^</))) {
+        continue;
+      }
+
+      // Subset has a prerelease tag (operator will be empty string).
+      if (subset.length === 1 && subset[0].operator === '') {
+        continue;
+      }
+
+      // No upperbound for this subset.
+      return false;
+    }
+
+    return true;
+  }
+
+  it('hasUpperBound works as intended', () => {
+    assert.equal(hasUpperBound('<1.12.2'), true);
+    assert.equal(hasUpperBound('=1.12.2'), true);
+    assert.equal(hasUpperBound('>=1.12.3 <2.2.2'), true);
+    assert.equal(hasUpperBound('>=2.2.3 <3.0.0'), true);
+    assert.equal(hasUpperBound('>=3.0.0 <3.10.1 || =3.10.2'), true);
+
+    assert.equal(hasUpperBound('>1.12.2'), false);
+    assert.equal(hasUpperBound('>=1.12.2'), false);
+    assert.equal(hasUpperBound('*'), false);
+  });
+
+  it('every snyk vulnerability has an upper bound', () => {
+    for (const vulns of Object.values(NoVulnerableLibrariesAudit.snykDB.npm)) {
+      for (const vuln of vulns) {
+        for (const semver of vuln.semver.vulnerable) {
+          if (!hasUpperBound(semver)) {
+            assert.fail(`invalid semver: ${semver}. Must contain an upper bound`);
+          }
+        }
+      }
+    }
+  });
+});
diff --git a/lighthouse-core/test/audits/metrics/cumulative-long-queuing-delay-test.js b/lighthouse-core/test/audits/metrics/total-blocking-time-test.js
similarity index 71%
rename from lighthouse-core/test/audits/metrics/cumulative-long-queuing-delay-test.js
rename to lighthouse-core/test/audits/metrics/total-blocking-time-test.js
index 160322069..1720753ee 100644
--- a/lighthouse-core/test/audits/metrics/cumulative-long-queuing-delay-test.js
+++ b/lighthouse-core/test/audits/metrics/total-blocking-time-test.js
@@ -5,25 +5,25 @@
  */
 'use strict';
 
-const cLQDAudit = require('../../../audits/metrics/cumulative-long-queuing-delay.js');
-const options = cLQDAudit.defaultOptions;
+const TBTAudit = require('../../../audits/metrics/total-blocking-time.js');
+const options = TBTAudit.defaultOptions;
 
 const pwaTrace = require('../../fixtures/traces/progressive-app-m60.json');
 
 function generateArtifactsWithTrace(trace) {
   return {
-    traces: {[cLQDAudit.DEFAULT_PASS]: trace},
-    devtoolsLogs: {[cLQDAudit.DEFAULT_PASS]: []},
+    traces: {[TBTAudit.DEFAULT_PASS]: trace},
+    devtoolsLogs: {[TBTAudit.DEFAULT_PASS]: []},
   };
 }
 /* eslint-env jest */
 
-describe('Performance: cumulative-long-queuing-delay audit', () => {
-  it('evaluates cumulative long queuing delay metric properly', async () => {
+describe('Performance: total-blocking-time audit', () => {
+  it('evaluates Total Blocking Time metric properly', async () => {
     const artifacts = generateArtifactsWithTrace(pwaTrace);
     const settings = {throttlingMethod: 'provided'};
     const context = {options, settings, computedCache: new Map()};
-    const output = await cLQDAudit.audit(artifacts, context);
+    const output = await TBTAudit.audit(artifacts, context);
 
     expect(output.numericValue).toBeCloseTo(48.3, 1);
     expect(output.score).toBe(1);
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index 83138f049..ceb715c2d 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -238,10 +238,10 @@ describe('SEO: Font size audit', () => {
     expect(auditResult.notApplicable).toBe(true);
   });
 
-  describe('attributes source location', () => {
+  describe('attributes source of style', () => {
     async function runFontSizeAuditWithSingleFailingStyle(style, nodeProperties) {
       const artifacts = {
-        URL: {finalUrl: 'http://www.example.com'},
+        URL,
         MetaElements: makeMetaElements(validViewport),
         FontSize: {
           analyzedFailingNodesData: [
@@ -264,10 +264,13 @@ describe('SEO: Font size audit', () => {
         attributes: ['class', 'my-p'],
       });
 
-      expect(auditResult.details.items[0].selector).toMatchObject({
-        type: 'node',
-        selector: '#my-parent',
-        snippet: '<p class="my-p">',
+      expect(auditResult.details.items[0]).toMatchObject({
+        source: URL.finalUrl,
+        selector: {
+          type: 'node',
+          selector: '#my-parent',
+          snippet: '<p class="my-p">',
+        },
       });
     });
 
@@ -280,10 +283,13 @@ describe('SEO: Font size audit', () => {
         attributes: ['size', '10px'],
       });
 
-      expect(auditResult.details.items[0].selector).toMatchObject({
-        type: 'node',
-        selector: '#my-parent',
-        snippet: '<font size="10px">',
+      expect(auditResult.details.items[0]).toMatchObject({
+        source: URL.finalUrl,
+        selector: {
+          type: 'node',
+          selector: '#my-parent',
+          snippet: '<font size="10px">',
+        },
       });
     });
   });
diff --git a/lighthouse-core/test/computed/metrics/cumulative-long-queuing-delay-test.js b/lighthouse-core/test/computed/metrics/cumulative-long-queuing-delay-test.js
deleted file mode 100644
index 8b02ada00..000000000
--- a/lighthouse-core/test/computed/metrics/cumulative-long-queuing-delay-test.js
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * @license Copyright 2019 Google Inc. All Rights Reserved.
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
- */
-'use strict';
-
-const CumulativeLongQueuingDelay =
-    require('../../../computed/metrics/cumulative-long-queuing-delay.js');
-const trace = require('../../fixtures/traces/progressive-app-m60.json');
-const devtoolsLog = require('../../fixtures/traces/progressive-app-m60.devtools.log.json');
-
-/* eslint-env jest */
-
-describe('Metrics: CumulativeLongQueuingDelay', () => {
-  it('should compute a simulated value', async () => {
-    const settings = {throttlingMethod: 'simulate'};
-    const context = {settings, computedCache: new Map()};
-    const result = await CumulativeLongQueuingDelay.request(
-      {trace, devtoolsLog, settings},
-      context
-    );
-
-    expect({
-      timing: Math.round(result.timing),
-      optimistic: Math.round(result.optimisticEstimate.timeInMs),
-      pessimistic: Math.round(result.pessimisticEstimate.timeInMs),
-    }).toMatchInlineSnapshot(`
-Object {
-  "optimistic": 719,
-  "pessimistic": 777,
-  "timing": 748,
-}
-`);
-  });
-
-  it('should compute an observed value', async () => {
-    const settings = {throttlingMethod: 'provided'};
-    const context = {settings, computedCache: new Map()};
-    const result = await CumulativeLongQueuingDelay.request(
-      {trace, devtoolsLog, settings},
-      context
-    );
-    expect(result.timing).toBeCloseTo(48.3, 1);
-  });
-
-  describe('#calculateSumOfLongQueuingDelay', () => {
-    it('reports 0 when no task is longer than 50ms', () => {
-      const events = [
-        {start: 1000, end: 1050, duration: 50},
-        {start: 2000, end: 2010, duration: 10},
-      ];
-
-      const fcpTimeMs = 500;
-      const interactiveTimeMs = 4000;
-
-      expect(CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
-        events,
-        fcpTimeMs,
-        interactiveTimeMs
-      )).toBe(0);
-    });
-
-    it('only looks at tasks within FMP and TTI', () => {
-      const events = [
-        {start: 1000, end: 1060, duration: 60},
-        {start: 2000, end: 2100, duration: 100},
-        {start: 2300, end: 2450, duration: 150},
-        {start: 2600, end: 2800, duration: 200},
-      ];
-
-      const fcpTimeMs = 1500;
-      const interactiveTimeMs = 2500;
-
-      expect(CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
-        events,
-        fcpTimeMs,
-        interactiveTimeMs
-      )).toBe(150);
-    });
-
-    it('clips queuing delay regions properly', () => {
-      const fcpTimeMs = 1050;
-      const interactiveTimeMs = 2050;
-
-      const events = [
-        {start: 1000, end: 1110, duration: 110}, // Contributes 10ms.
-        {start: 2000, end: 2200, duration: 200}, // Contributes 50ms.
-      ];
-
-      expect(CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
-        events,
-        fcpTimeMs,
-        interactiveTimeMs
-      )).toBe(60);
-    });
-
-    // This can happen in the lantern metric case, where we use the optimistic
-    // TTI and pessimistic FCP.
-    it('returns 0 if interactiveTime is earlier than FCP', () => {
-      const fcpTimeMs = 2050;
-      const interactiveTimeMs = 1050;
-
-      const events = [
-        {start: 500, end: 3000, duration: 2500},
-      ];
-
-      expect(CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
-        events,
-        fcpTimeMs,
-        interactiveTimeMs
-      )).toBe(0);
-    });
-  });
-});
diff --git a/lighthouse-core/test/computed/metrics/total-blocking-time-test.js b/lighthouse-core/test/computed/metrics/total-blocking-time-test.js
new file mode 100644
index 000000000..15e9fcff5
--- /dev/null
+++ b/lighthouse-core/test/computed/metrics/total-blocking-time-test.js
@@ -0,0 +1,148 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const TotalBlockingTime = require('../../../computed/metrics/total-blocking-time.js');
+const trace = require('../../fixtures/traces/progressive-app-m60.json');
+const devtoolsLog = require('../../fixtures/traces/progressive-app-m60.devtools.log.json');
+
+/* eslint-env jest */
+
+describe('Metrics: TotalBlockingTime', () => {
+  it('should compute a simulated value', async () => {
+    const settings = {throttlingMethod: 'simulate'};
+    const context = {settings, computedCache: new Map()};
+    const result = await TotalBlockingTime.request({trace, devtoolsLog, settings}, context);
+
+    expect({
+      timing: Math.round(result.timing),
+      optimistic: Math.round(result.optimisticEstimate.timeInMs),
+      pessimistic: Math.round(result.pessimisticEstimate.timeInMs),
+    }).toMatchInlineSnapshot(`
+Object {
+  "optimistic": 676,
+  "pessimistic": 777,
+  "timing": 726,
+}
+`);
+  });
+
+  it('should compute an observed value', async () => {
+    const settings = {throttlingMethod: 'provided'};
+    const context = {settings, computedCache: new Map()};
+    const result = await TotalBlockingTime.request({trace, devtoolsLog, settings}, context);
+    expect(result.timing).toBeCloseTo(48.3, 1);
+  });
+
+  describe('#calculateSumOfBlockingTime', () => {
+    it('reports 0 when no task is longer than 50ms', () => {
+      const events = [
+        {start: 1000, end: 1050, duration: 50},
+        {start: 2000, end: 2010, duration: 10},
+      ];
+
+      const fcpTimeMs = 500;
+      const interactiveTimeMs = 4000;
+
+      expect(
+        TotalBlockingTime.calculateSumOfBlockingTime(events, fcpTimeMs, interactiveTimeMs)
+      ).toBe(0);
+    });
+
+    it('only looks at tasks within FCP and TTI', () => {
+      const events = [
+        {start: 1000, end: 1060, duration: 60},
+        {start: 2000, end: 2100, duration: 100},
+        {start: 2300, end: 2450, duration: 150},
+        {start: 2600, end: 2800, duration: 200},
+      ];
+
+      const fcpTimeMs = 1500;
+      const interactiveTimeMs = 2500;
+
+      expect(
+        TotalBlockingTime.calculateSumOfBlockingTime(events, fcpTimeMs, interactiveTimeMs)
+      ).toBe(150);
+    });
+
+    it('clips before finding blocking regions', () => {
+      const fcpTimeMs = 150;
+      const interactiveTimeMs = 300;
+
+      const events = [
+        // The clipping is done first, so the task becomes [150, 200] after clipping and contributes
+        // 0ms of blocking time. This is in contrast to first calculating the blocking region ([100,
+        // 200]) and then clipping at FCP (150ms), which yields 50ms blocking time.
+        {start: 50, end: 200, duration: 150},
+        // Similarly, the task is first clipped above to be [240, 300], and then contributes 10ms
+        // blocking time.
+        {start: 240, end: 460, duration: 120},
+      ];
+
+      expect(
+        TotalBlockingTime.calculateSumOfBlockingTime(events, fcpTimeMs, interactiveTimeMs)
+      ).toBe(10); // 0ms + 10ms.
+    });
+
+    // TTI can happen in the middle of a task, for example, if TTI is at FMP which occurs as part
+    // of a larger task, or in the lantern case where we use estimate TTI using a different graph
+    // from the one used to estimate TBT.
+    it('clips properly if TTI falls in the middle of a task', () => {
+      const fcpTimeMs = 1000;
+      const interactiveTimeMs = 2000;
+
+      expect(TotalBlockingTime.calculateSumOfBlockingTime(
+        [{start: 1951, end: 2100, duration: 149}],
+        fcpTimeMs,
+        interactiveTimeMs
+      )).toBe(0); // Duration after clipping is 49, which is < 50.
+      expect(TotalBlockingTime.calculateSumOfBlockingTime(
+        [{start: 1950, end: 2100, duration: 150}],
+        fcpTimeMs,
+        interactiveTimeMs
+      )).toBe(0); // Duration after clipping is 50, so time after 50ms is 0ms.
+      expect(TotalBlockingTime.calculateSumOfBlockingTime(
+        [{start: 1949, end: 2100, duration: 151}],
+        fcpTimeMs,
+        interactiveTimeMs
+      )).toBe(1); // Duration after clipping is 51, so time after 50ms is 1ms.
+    });
+
+    it('clips properly if FCP falls in the middle of a task', () => {
+      const fcpTimeMs = 1000;
+      const interactiveTimeMs = 2000;
+
+      expect(TotalBlockingTime.calculateSumOfBlockingTime(
+        [{start: 900, end: 1049, duration: 149}],
+        fcpTimeMs,
+        interactiveTimeMs
+      )).toBe(0); // Duration after clipping is 49, which is < 50.
+      expect(TotalBlockingTime.calculateSumOfBlockingTime(
+        [{start: 900, end: 1050, duration: 150}],
+        fcpTimeMs,
+        interactiveTimeMs
+      )).toBe(0); // Duration after clipping is 50, so time after 50ms is 0ms.
+      expect(TotalBlockingTime.calculateSumOfBlockingTime(
+        [{start: 900, end: 1051, duration: 151}],
+        fcpTimeMs,
+        interactiveTimeMs
+      )).toBe(1); // Duration after clipping is 51, so time after 50ms is 1ms.
+    });
+
+    // This can happen in the lantern metric case, where we use the optimistic
+    // TTI and pessimistic FCP.
+    it('returns 0 if interactiveTime is earlier than FCP', () => {
+      const fcpTimeMs = 2050;
+      const interactiveTimeMs = 1050;
+
+      const events = [{start: 500, end: 3000, duration: 2500}];
+
+      expect(
+        TotalBlockingTime.calculateSumOfBlockingTime(events, fcpTimeMs, interactiveTimeMs)
+      ).toBe(0);
+    });
+  });
+});
diff --git a/lighthouse-core/test/lib/asset-saver-test.js b/lighthouse-core/test/lib/asset-saver-test.js
index c37a8e360..bce063e58 100644
--- a/lighthouse-core/test/lib/asset-saver-test.js
+++ b/lighthouse-core/test/lib/asset-saver-test.js
@@ -9,6 +9,8 @@ const assetSaver = require('../../lib/asset-saver.js');
 const Metrics = require('../../lib/traces/pwmetrics-events.js');
 const assert = require('assert');
 const fs = require('fs');
+const rimraf = require('rimraf');
+const LHError = require('../../lib/lh-error.js');
 
 const traceEvents = require('../fixtures/traces/progressive-app.json');
 const dbwTrace = require('../results/artifacts/defaultPass.trace.json');
@@ -167,6 +169,68 @@ describe('asset-saver helper', () => {
     });
   });
 
+  describe('JSON serialization', () => {
+    const outputPath = __dirname + '/json-serialization-test-data/';
+
+    afterEach(() => {
+      rimraf.sync(outputPath);
+    });
+
+    it('round trips saved artifacts', async () => {
+      const artifactsPath = __dirname + '/../results/artifacts/';
+      const originalArtifacts = await assetSaver.loadArtifacts(artifactsPath);
+
+      await assetSaver.saveArtifacts(originalArtifacts, outputPath);
+      const roundTripArtifacts = await assetSaver.loadArtifacts(outputPath);
+      expect(roundTripArtifacts).toStrictEqual(originalArtifacts);
+    });
+
+    it('round trips artifacts with an Error member', async () => {
+      const error = new Error('Connection refused by server');
+      // test code to make sure e.g. Node errors get serialized well.
+      error.code = 'ECONNREFUSED';
+
+      const artifacts = {
+        traces: {},
+        devtoolsLogs: {},
+        ViewportDimensions: error,
+      };
+
+      await assetSaver.saveArtifacts(artifacts, outputPath);
+      const roundTripArtifacts = await assetSaver.loadArtifacts(outputPath);
+      expect(roundTripArtifacts).toStrictEqual(artifacts);
+
+      expect(roundTripArtifacts.ViewportDimensions).toBeInstanceOf(Error);
+      expect(roundTripArtifacts.ViewportDimensions.code).toEqual('ECONNREFUSED');
+      expect(roundTripArtifacts.ViewportDimensions.stack).toMatch(
+        /^Error: Connection refused by server.*test[\\/]lib[\\/]asset-saver-test\.js/s);
+    });
+
+    it('round trips artifacts with an LHError member', async () => {
+      // Use an LHError that has an ICU replacement.
+      const protocolMethod = 'Page.getFastness';
+      const lhError = new LHError(LHError.errors.PROTOCOL_TIMEOUT, {protocolMethod});
+
+      const artifacts = {
+        traces: {},
+        devtoolsLogs: {},
+        ScriptElements: lhError,
+      };
+
+      await assetSaver.saveArtifacts(artifacts, outputPath);
+      const roundTripArtifacts = await assetSaver.loadArtifacts(outputPath);
+      expect(roundTripArtifacts).toStrictEqual(artifacts);
+
+      expect(roundTripArtifacts.ScriptElements).toBeInstanceOf(LHError);
+      expect(roundTripArtifacts.ScriptElements.code).toEqual('PROTOCOL_TIMEOUT');
+      expect(roundTripArtifacts.ScriptElements.protocolMethod).toEqual(protocolMethod);
+      expect(roundTripArtifacts.ScriptElements.stack).toMatch(
+          /^LHError: PROTOCOL_TIMEOUT.*test[\\/]lib[\\/]asset-saver-test\.js/s);
+      expect(roundTripArtifacts.ScriptElements.friendlyMessage)
+        .toBeDisplayString(/\(Method: Page\.getFastness\)/);
+    });
+  });
+
   describe('saveLanternNetworkData', () => {
     const outputFilename = 'test-lantern-network-data.json';
 
diff --git a/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js b/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
index eec7b0744..7078d1e6c 100644
--- a/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
@@ -81,6 +81,18 @@ describe('PerfCategoryRenderer', () => {
     assert.equal(timelineElements.length + nontimelineElements.length, metricAudits.length);
   });
 
+  it('renders the metrics variance disclaimer as markdown', () => {
+    const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
+    const disclaimerEl =
+        categoryDOM.querySelector('.lh-audit-group--metrics > .lh-metrics__disclaimer');
+
+    assert.ok(disclaimerEl.textContent.includes('Values are estimated'));
+    const disclamerLink = disclaimerEl.querySelector('a');
+    assert.ok(disclamerLink, 'disclaimer contains coverted markdown link');
+    const disclamerUrl = new URL(disclamerLink.href);
+    assert.strictEqual(disclamerUrl.hostname, 'github.com');
+  });
+
   it('renders the failing performance opportunities', () => {
     const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
 
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 1ec3c3047..a68818bf5 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -191,10 +191,10 @@
       "numericValue": 16,
       "displayValue": "20 ms"
     },
-    "cumulative-long-queuing-delay": {
-      "id": "cumulative-long-queuing-delay",
-      "title": "Cumulative Long Queuing Delay",
-      "description": "[Experimental metric] Total time period between FCP and Time to Interactive during which queuing time for any input event would be higher than 50ms.",
+    "total-blocking-time": {
+      "id": "total-blocking-time",
+      "title": "Total Blocking Time",
+      "description": "Sum of all time periods between FCP and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds.",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 116.79800000000023,
@@ -1258,7 +1258,7 @@
             "speedIndex": 4417,
             "speedIndexTs": 185607736912,
             "estimatedInputLatency": 16,
-            "cumulativeLongQueuingDelay": 117,
+            "totalBlockingTime": 117,
             "observedNavigationStart": 0,
             "observedNavigationStartTs": 185603319912,
             "observedFirstPaint": 3969,
@@ -3417,7 +3417,7 @@
           "weight": 0
         },
         {
-          "id": "cumulative-long-queuing-delay",
+          "id": "total-blocking-time",
           "weight": 0
         },
         {
@@ -4047,7 +4047,7 @@
     },
     "load-opportunities": {
       "title": "Opportunities",
-      "description": "These optimizations can speed up your page load."
+      "description": "These suggestions can help your page load faster. They don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score."
     },
     "budgets": {
       "title": "Budgets",
@@ -4055,7 +4055,7 @@
     },
     "diagnostics": {
       "title": "Diagnostics",
-      "description": "More information about the performance of your application."
+      "description": "More information about the performance of your application. These numbers don't [directly affect](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted) the Performance score."
     },
     "pwa-fast-reliable": {
       "title": "Fast and reliable"
@@ -4283,13 +4283,13 @@
       },
       {
         "startTime": 0,
-        "name": "lh:audit:cumulative-long-queuing-delay",
+        "name": "lh:audit:total-blocking-time",
         "duration": 100,
         "entryType": "measure"
       },
       {
         "startTime": 0,
-        "name": "lh:computed:CumulativeLongQueuingDelay",
+        "name": "lh:computed:TotalBlockingTime",
         "duration": 100,
         "entryType": "measure"
       },
@@ -5112,7 +5112,7 @@
       "snippetExpandButtonLabel": "Expand snippet",
       "thirdPartyResourcesLabel": "Show 3rd-party resources",
       "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:",
-      "varianceDisclaimer": "Values are estimated and may vary.",
+      "varianceDisclaimer": "Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).",
       "warningAuditsGroupTitle": "Passed audits but with warnings",
       "warningHeader": "Warnings: "
     },
@@ -5215,6 +5215,12 @@
           },
           "path": "audits[estimated-input-latency].displayValue"
         },
+        {
+          "values": {
+            "timeInMs": 116.79800000000023
+          },
+          "path": "audits[total-blocking-time].displayValue"
+        },
         {
           "values": {
             "timeInMs": 122.537
@@ -5234,6 +5240,12 @@
           "path": "audits[network-server-latency].displayValue"
         }
       ],
+      "lighthouse-core/audits/metrics/total-blocking-time.js | title": [
+        "audits[total-blocking-time].title"
+      ],
+      "lighthouse-core/audits/metrics/total-blocking-time.js | description": [
+        "audits[total-blocking-time].description"
+      ],
       "lighthouse-core/audits/metrics/max-potential-fid.js | title": [
         "audits[max-potential-fid].title"
       ],
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index ccf4bf339..99c41c861 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -330,32 +330,40 @@ describe('Runner', () => {
       });
     });
 
-    // TODO: need to support save/load of artifact errors.
-    // See https://github.com/GoogleChrome/lighthouse/issues/4984
-    it.skip('outputs an error audit result when required artifact was an Error', () => {
-      const errorMessage = 'blurst of times';
-      const artifactError = new Error(errorMessage);
+    it('outputs an error audit result when required artifact was an Error', async () => {
+      // Start with empty-artifacts.
+      const baseArtifacts = assetSaver.loadArtifacts(__dirname +
+          '/fixtures/artifacts/empty-artifacts/');
 
-      const url = 'https://example.com';
+      // Add error and save artifacts using assetSaver to serialize Error object.
+      const errorMessage = 'blurst of times';
+      const artifacts = {
+        ...baseArtifacts,
+        ViewportDimensions: new Error(errorMessage),
+        TestedAsMobileDevice: true,
+      };
+      const artifactsPath = '.tmp/test_artifacts';
+      const resolvedPath = path.resolve(process.cwd(), artifactsPath);
+      await assetSaver.saveArtifacts(artifacts, resolvedPath);
+
+      // Load artifacts via auditMode.
       const config = new Config({
+        settings: {
+          auditMode: resolvedPath,
+        },
         audits: [
+          // requires ViewportDimensions and TestedAsMobileDevice artifacts
           'content-width',
         ],
-
-        artifacts: {
-          // Error objects don't make it through the Config constructor due to
-          // JSON.stringify/parse step, so populate with test error below.
-          ViewportDimensions: null,
-        },
       });
-      config.artifacts.ViewportDimensions = artifactError;
 
-      return Runner.run({}, {url, config}).then(results => {
-        const auditResult = results.lhr.audits['content-width'];
-        assert.strictEqual(auditResult.score, null);
-        assert.strictEqual(auditResult.scoreDisplayMode, 'error');
-        assert.ok(auditResult.errorMessage.includes(errorMessage));
-      });
+      const results = await Runner.run({}, {config});
+      const auditResult = results.lhr.audits['content-width'];
+      assert.strictEqual(auditResult.score, null);
+      assert.strictEqual(auditResult.scoreDisplayMode, 'error');
+      assert.ok(auditResult.errorMessage.includes(errorMessage));
+
+      rimraf.sync(resolvedPath);
     });
 
     it('only passes the required artifacts to the audit', async () => {