Repository: GoogleChrome/lighthouse
Pull request #9744 (2019-09-26 20:16:16)

core(network-recorder): remove quic-request-finished workaround
#5256 added a workaround for #5254, where many QUIC requests never got a final `LoadingFinished` or `LoadingFailed` event, so we were treating them as forever open, we never reached network quiet, and so we always hit the LH load timeout. The workaround was to check if the request was over QUIC and if there was end timing information, calling that "finished".

We broke this workaround *I think* when we finally dropped our direct dependence on devtools-frontend and [wrote our own `NetworkRequest`](https://github.com/GoogleChrome/lighthouse/pull/5451). 

[`NetworkRequest._recomputeTimesWithResourceTiming`](https://github.com/GoogleChrome/lighthouse/blob/6ff02e42f7083b25c1a4b9869478419864c12c5b/lighthouse-core/lib/network-request.js#L324-L334) takes in some preliminary timing information that may or may not be updated later, but any QUIC requests always appear finished after this happens, whether or not they end up updated later.

This causes `networkidle` and `network-2-idle` to sometimes fire too early, which means we may be cutting off some runs before TTI would actually be possible. I'm not sure how bad the impact is in practice, however, as
- the requests are often finished soon after anyways
- the `Network` protocol events LH receives are often delayed well after they actually occurred
- it's rare that we're in e.g. `network-3-idle` where one of the requests is QUIC and appears finished and we're not almost `network-2-idle` anyways

Originally I was going to try to update the QUIC heuristic, but after doing a bunch of tests, I don't think the original behavior is an issue anymore. All but a tiny handful of QUIC requests I've observed have a `Network.loadingFinished` event, and every time I've encountered a case of one without a finish event (developers.google.com sometimes hits this), extending `networkQuietThresholdMs` was enough to make the problem go away, so I think they're just legitimately slow loads.

So this PR just removes the workaround :)

brendankenny: perf-wise, since this is in the n^2 path, we get like a 20% improvement in `NetworkRecorder.recordsFromLogs` execution time, 69ms to 54ms for a big (~800 request) theverge devtoolsLog.

That leaves about 20% of that total runtime spent on idle detection...we could make it O(1), but I'm not sure those last 10ms are worth it for how invasive the extra request counting would be.
brendankenny: And here's the worst example I could find (in a relatively short search) of network-idle periods from the network events on master, from this PR, and vs the "true" network idle periods reconstructed after we have the full devtoolsLog and real network timestamps (`NetworkRecorder.findNetworkQuietPeriods`).

I used a hacked-together trace to visualize, forgive me :)

<img width="1097" alt="network idle timeline" src="https://user-images.githubusercontent.com/316891/65723847-3b2f4c00-e07d-11e9-9a36-2dfedce0787f.png">

This is loading `https://google.com/search?q=ghost`. In this example, master shows a long quiet period almost at the beginning of the load before eventually mostly aligning with the results from this PR and ground truth.

([raw trace if you want to do some zooming](https://gist.github.com/brendankenny/f09e8377c3591abb62366269f564c7b0)...will have to load manually, timeline-viewer CORS stuff isn't working right now)
patrickhulce: > We broke this workaround I think when we finally dropped our direct dependence on devtools-frontend and wrote our own NetworkRequest.
NetworkRequest._recomputeTimesWithResourceTiming takes in some preliminary timing information that may or may not be updated later, but any QUIC requests always appear finished after this happens, whether or not they end up updated later.

I'm not sure I follow how it's more broken now than it ever was :) It seems like it's still working exactly as intended, just using the receipt of any response/headers as the "finished" state for QUIC requests because at the time we had no other event that could possibly represent finished. `_recomputeTimesWithResourceTiming` in our impl is still only called `_onResponse`.

Great news that the workaround isn't necessary anymore though and we can move our idle slightly closer reality!
brendankenny: > I'm not sure I follow how it's more broken now than it ever was :)

ha, well I guess I didn't anticipate that possibility :) Good that we can remove it, then.

Patch:
diff --git a/lighthouse-core/audits/bootup-time.js b/lighthouse-core/audits/bootup-time.js
index 7041469a6..e488d70d5 100644
--- a/lighthouse-core/audits/bootup-time.js
+++ b/lighthouse-core/audits/bootup-time.js
@@ -20,7 +20,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that tells the user that they should reduce the amount of time spent executing javascript and one method of doing so. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Consider reducing the time spent parsing, compiling, and executing JS. ' +
     'You may find delivering smaller JS payloads helps with this. [Learn ' +
-    'more](https://developers.google.com/web/tools/lighthouse/audits/bootup).',
+    'more](https://web.dev/bootup-time).',
   /** Label for the total time column in a data table; entries will be the number of milliseconds spent executing per resource loaded by the page. */
   columnTotal: 'Total CPU Time',
   /** Label for a time column in a data table; entries will be the number of milliseconds spent evaluating script for every script loaded by the page. */
diff --git a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
index 26cc33c16..333fdd05e 100644
--- a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
+++ b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
@@ -93,8 +93,10 @@ class UnusedBytes extends Audit {
       // This was an asset that was inlined in a different resource type (e.g. HTML document).
       // Use the compression ratio of the resource to estimate the total transferred bytes.
       const transferSize = networkRecord.transferSize || 0;
-      const resourceSize = networkRecord.resourceSize;
-      const compressionRatio = resourceSize !== undefined ? (transferSize / resourceSize) : 1;
+      const resourceSize = networkRecord.resourceSize || 0;
+      // Get the compression ratio, if it's an invalid number, assume no compression.
+      const compressionRatio = Number.isFinite(resourceSize) && resourceSize > 0 ?
+        (transferSize / resourceSize) : 1;
       return Math.round(totalBytes * compressionRatio);
     }
   }
diff --git a/lighthouse-core/audits/byte-efficiency/efficient-animated-content.js b/lighthouse-core/audits/byte-efficiency/efficient-animated-content.js
index 1ec939348..6237b9dc2 100644
--- a/lighthouse-core/audits/byte-efficiency/efficient-animated-content.js
+++ b/lighthouse-core/audits/byte-efficiency/efficient-animated-content.js
@@ -18,7 +18,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that tells the user *why* they should use video instead of GIF format for delivering animated content. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Large GIFs are inefficient for delivering animated content. Consider using ' +
     'MPEG4/WebM videos for animations and PNG/WebP for static images instead of GIF to save ' +
-    'network bytes. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/replace-animated-gifs-with-video/)',
+    'network bytes. [Learn more](https://web.dev/efficient-animated-content)',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/byte-efficiency/offscreen-images.js b/lighthouse-core/audits/byte-efficiency/offscreen-images.js
index 8979ba029..f267388c8 100644
--- a/lighthouse-core/audits/byte-efficiency/offscreen-images.js
+++ b/lighthouse-core/audits/byte-efficiency/offscreen-images.js
@@ -23,7 +23,7 @@ const UIStrings = {
   description:
     'Consider lazy-loading offscreen and hidden images after all critical resources have ' +
     'finished loading to lower time to interactive. ' +
-    '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/offscreen-images).',
+    '[Learn more](https://web.dev/offscreen-images).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
index 30aba1de3..75efa984b 100644
--- a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
+++ b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
@@ -35,7 +35,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that tells the user *why* they should reduce or remove network resources that block the initial render of the page. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Resources are blocking the first paint of your page. Consider ' +
     'delivering critical JS/CSS inline and deferring all non-critical ' +
-    'JS/styles. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/blocking-resources).',
+    'JS/styles. [Learn more](https://web.dev/render-blocking-resources).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
index a7af641da..aa06e6ec0 100644
--- a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
+++ b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
@@ -18,7 +18,7 @@ const UIStrings = {
   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).',
+  'more](https://web.dev/total-byte-weight).',
   /** Used to summarize the total byte size of the page and all its network requests. The `{totalBytes}` placeholder will be replaced with the total byte sizes, shown in kilobytes (e.g. 142 KB) */
   displayValue: 'Total size was {totalBytes, number, bytes}\xa0KB',
 };
diff --git a/lighthouse-core/audits/byte-efficiency/unminified-css.js b/lighthouse-core/audits/byte-efficiency/unminified-css.js
index 4ebde58c6..8fb2f155d 100644
--- a/lighthouse-core/audits/byte-efficiency/unminified-css.js
+++ b/lighthouse-core/audits/byte-efficiency/unminified-css.js
@@ -15,7 +15,7 @@ const UIStrings = {
   title: 'Minify CSS',
   /** Description of a Lighthouse audit that tells the user *why* they should minify (remove whitespace) the page's CSS code. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Minifying CSS files can reduce network payload sizes. ' +
-    '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/minify-css).',
+    '[Learn more](https://web.dev/unminified-css).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/byte-efficiency/unminified-javascript.js b/lighthouse-core/audits/byte-efficiency/unminified-javascript.js
index 0d2f405e3..81242c480 100644
--- a/lighthouse-core/audits/byte-efficiency/unminified-javascript.js
+++ b/lighthouse-core/audits/byte-efficiency/unminified-javascript.js
@@ -14,7 +14,7 @@ const UIStrings = {
   title: 'Minify JavaScript',
   /** Description of a Lighthouse audit that tells the user *why* they should minify the page’s JS code to reduce file size. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Minifying JavaScript files can reduce payload sizes and script parse time. ' +
-    '[Learn more](https://developers.google.com/speed/docs/insights/MinifyResources).',
+    '[Learn more](https://web.dev/unminified-javascript).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
index d2b7344d7..01c189dd7 100644
--- a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
+++ b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
@@ -14,7 +14,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that tells the user *why* they should defer loading any content in CSS that isn’t needed at page load. This is displayed after a user expands the section to see more. No word length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Remove dead rules from stylesheets and defer the loading of CSS not used for ' +
     'above-the-fold content to reduce unnecessary bytes consumed by network activity. ' +
-    '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).',
+    '[Learn more](https://web.dev/unused-css-rules).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
@@ -47,6 +47,10 @@ class UnusedCSSRules extends ByteEfficiencyAudit {
    */
   static indexStylesheetsById(styles, networkRecords) {
     const indexedNetworkRecords = networkRecords
+        // Some phantom network records appear with a 0 resourceSize that aren't real.
+        // A network record that has no size data is just as good as no network record at all for our
+        // purposes, so we'll just filter them out. https://github.com/GoogleChrome/lighthouse/issues/9684#issuecomment-532381611
+        .filter(record => record.resourceSize > 0)
         .reduce((indexed, record) => {
           indexed[record.url] = record;
           return indexed;
diff --git a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
index eb918d1ee..ba3f77c06 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
@@ -22,7 +22,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that tells the user *why* they need to adopt a long cache lifetime policy. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description:
     'A long cache lifetime can speed up repeat visits to your page. ' +
-    '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/cache-policy).',
+    '[Learn more](https://web.dev/uses-long-cache-ttl).',
   /** [ICU Syntax] Label for the audit identifying network resources with inefficient cache values. Clicking this will expand the audit to show the resources. */
   displayValue: `{itemCount, plural,
     =1 {1 resource found}
diff --git a/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js b/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js
index 8ddf42ab8..5932f54a3 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js
@@ -18,7 +18,7 @@ const UIStrings = {
   title: 'Efficiently encode images',
   /** Description of a Lighthouse audit that tells the user *why* they need to efficiently encode images. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Optimized images load faster and consume less cellular data. ' +
-  '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/optimize-images).',
+  '[Learn more](https://web.dev/uses-optimized-images).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js b/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
index 4e96e0397..724c56763 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js
@@ -25,7 +25,7 @@ const UIStrings = {
   description:
   'Serve images that are appropriately-sized to save cellular data ' +
   'and improve load time. ' +
-  '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/oversized-images).',
+  '[Learn more](https://web.dev/uses-responsive-images).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/byte-efficiency/uses-text-compression.js b/lighthouse-core/audits/byte-efficiency/uses-text-compression.js
index 69539a0aa..1517740c0 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-text-compression.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-text-compression.js
@@ -19,7 +19,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that tells the user *why* their text-based resources should be served with compression (like gzip). This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Text-based resources should be served with compression (gzip, deflate or' +
     ' brotli) to minimize total network bytes.' +
-    ' [Learn more](https://developers.google.com/web/tools/lighthouse/audits/text-compression).',
+    ' [Learn more](https://web.dev/uses-text-compression).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/byte-efficiency/uses-webp-images.js b/lighthouse-core/audits/byte-efficiency/uses-webp-images.js
index 35eacb291..97c8f8bfe 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-webp-images.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-webp-images.js
@@ -18,7 +18,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that tells the user *why* they should use newer and more efficient image formats. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Image formats like JPEG 2000, JPEG XR, and WebP often provide better ' +
     'compression than PNG or JPEG, which means faster downloads and less data consumption. ' +
-    '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/webp).',
+    '[Learn more](https://web.dev/uses-webp-images).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/critical-request-chains.js b/lighthouse-core/audits/critical-request-chains.js
index 19a17bd4d..ebb7ba2e2 100644
--- a/lighthouse-core/audits/critical-request-chains.js
+++ b/lighthouse-core/audits/critical-request-chains.js
@@ -17,7 +17,7 @@ const UIStrings = {
       'loaded with a high priority. Consider reducing ' +
       'the length of chains, reducing the download size of resources, or ' +
       'deferring the download of unnecessary resources to improve page load. ' +
-      '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/critical-request-chains).',
+      '[Learn more](https://web.dev/critical-request-chains).',
   /** [ICU Syntax] Label for an audit identifying the number of sequences of dependent network requests used to load the page. */
   displayValue: `{itemCount, plural,
     =1 {1 chain found}
diff --git a/lighthouse-core/audits/font-display.js b/lighthouse-core/audits/font-display.js
index 29f3b3c12..4753fd455 100644
--- a/lighthouse-core/audits/font-display.js
+++ b/lighthouse-core/audits/font-display.js
@@ -23,7 +23,7 @@ const UIStrings = {
   description:
     'Leverage the font-display CSS feature to ensure text is user-visible while ' +
     'webfonts are loading. ' +
-    '[Learn more](https://developers.google.com/web/updates/2016/02/font-display).',
+    '[Learn more](https://web.dev/font-display).',
   /**
    * @description A warning message that is shown when Lighthouse couldn't automatically check some of the page's fonts and that the user will need to manually check it.
    * @example {https://font.cdn.com/} fontURL
diff --git a/lighthouse-core/audits/mainthread-work-breakdown.js b/lighthouse-core/audits/mainthread-work-breakdown.js
index c16b88585..8bbaac8c3 100644
--- a/lighthouse-core/audits/mainthread-work-breakdown.js
+++ b/lighthouse-core/audits/mainthread-work-breakdown.js
@@ -22,7 +22,8 @@ const UIStrings = {
   failureTitle: 'Minimize main-thread work',
   /** Description of a Lighthouse audit that tells the user *why* they should reduce JS execution times. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Consider reducing the time spent parsing, compiling and executing JS. ' +
-    'You may find delivering smaller JS payloads helps with this.',
+    'You may find delivering smaller JS payloads helps with this. ' +
+    '[Learn more](https://web.dev/mainthread-work-breakdown)',
   /** Label for the Main Thread Category column in data tables, rows will have a main thread Category and main thread Task Name. */
   columnCategory: 'Category',
 };
diff --git a/lighthouse-core/audits/metrics/estimated-input-latency.js b/lighthouse-core/audits/metrics/estimated-input-latency.js
index 5de2cf700..4f0241f7d 100644
--- a/lighthouse-core/audits/metrics/estimated-input-latency.js
+++ b/lighthouse-core/audits/metrics/estimated-input-latency.js
@@ -16,7 +16,7 @@ const UIStrings = {
   description: 'Estimated Input Latency is an estimate of how long your app takes to respond to ' +
       'user input, in milliseconds, during the busiest 5s window of page load. If your ' +
       'latency is higher than 50 ms, users may perceive your app as laggy. ' +
-      '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).',
+      '[Learn more](https://web.dev/estimated-input-latency).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/metrics/first-contentful-paint.js b/lighthouse-core/audits/metrics/first-contentful-paint.js
index 9038a6ff9..1f9964bb9 100644
--- a/lighthouse-core/audits/metrics/first-contentful-paint.js
+++ b/lighthouse-core/audits/metrics/first-contentful-paint.js
@@ -14,7 +14,7 @@ const UIStrings = {
   title: 'First Contentful Paint',
   /** Description of the First Contentful Paint (FCP) metric, which marks the time at which the first text or image is painted by the browser. This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'First Contentful Paint marks the time at which the first text or image is ' +
-      `painted. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-contentful-paint).`,
+      `painted. [Learn more](https://web.dev/first-contentful-paint).`,
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/metrics/first-cpu-idle.js b/lighthouse-core/audits/metrics/first-cpu-idle.js
index 62295b761..e5118a543 100644
--- a/lighthouse-core/audits/metrics/first-cpu-idle.js
+++ b/lighthouse-core/audits/metrics/first-cpu-idle.js
@@ -14,8 +14,7 @@ const UIStrings = {
   title: 'First CPU Idle',
   /** Description of the First CPU Idle metric, which marks the time at which the page has displayed content and the CPU is not busy executing the page's scripts. This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'First CPU Idle marks the first time at which the page\'s main thread is ' +
-    'quiet enough to handle input. ' +
-    '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-interactive).',
+    'quiet enough to handle input.  [Learn more](https://web.dev/first-cpu-idle).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/metrics/first-meaningful-paint.js b/lighthouse-core/audits/metrics/first-meaningful-paint.js
index adc268f0d..0ec5e35cf 100644
--- a/lighthouse-core/audits/metrics/first-meaningful-paint.js
+++ b/lighthouse-core/audits/metrics/first-meaningful-paint.js
@@ -14,7 +14,7 @@ const UIStrings = {
   title: 'First Meaningful Paint',
   /** Description of the First Meaningful Paint (FMP) metric, which marks the time at which a majority of the content has been painted by the browser. This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'First Meaningful Paint measures when the primary content of a page is ' +
-      'visible. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint).',
+      'visible. [Learn more](https://web.dev/first-meaningful-paint).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/metrics/interactive.js b/lighthouse-core/audits/metrics/interactive.js
index 35d85e0e8..1623a2380 100644
--- a/lighthouse-core/audits/metrics/interactive.js
+++ b/lighthouse-core/audits/metrics/interactive.js
@@ -14,7 +14,7 @@ const UIStrings = {
   title: 'Time to Interactive',
   /** Description of the Time to Interactive (TTI) metric, which evaluates when a page has completed its primary network activity and main thread work. This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Time to interactive is the amount of time it takes for the page to become fully ' +
-    'interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).',
+    'interactive. [Learn more](https://web.dev/interactive).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/metrics/speed-index.js b/lighthouse-core/audits/metrics/speed-index.js
index a78337429..b133dca83 100644
--- a/lighthouse-core/audits/metrics/speed-index.js
+++ b/lighthouse-core/audits/metrics/speed-index.js
@@ -14,7 +14,7 @@ const UIStrings = {
   title: 'Speed Index',
   /** Description of the Speed Index metric, which summarizes how quickly the page looked visually complete. This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Speed Index shows how quickly the contents of a page are visibly populated. ' +
-      '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/speed-index).',
+      '[Learn more](https://web.dev/speed-index).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/redirects.js b/lighthouse-core/audits/redirects.js
index d8abfcb67..0a9ada34f 100644
--- a/lighthouse-core/audits/redirects.js
+++ b/lighthouse-core/audits/redirects.js
@@ -17,7 +17,7 @@ const UIStrings = {
   /** Imperative title of a Lighthouse audit that tells the user to eliminate the redirects taken through multiple URLs to load the page. This is shown in a list of audits that Lighthouse generates. */
   title: 'Avoid multiple page redirects',
   /** Description of a Lighthouse audit that tells users why they should reduce the number of server-side redirects on their page. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
-  description: 'Redirects introduce additional delays before the page can be loaded. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/redirects).',
+  description: 'Redirects introduce additional delays before the page can be loaded. [Learn more](https://web.dev/redirects).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/time-to-first-byte.js b/lighthouse-core/audits/time-to-first-byte.js
index f8ec156a6..93feee2b7 100644
--- a/lighthouse-core/audits/time-to-first-byte.js
+++ b/lighthouse-core/audits/time-to-first-byte.js
@@ -16,7 +16,7 @@ const UIStrings = {
   failureTitle: 'Reduce server response times (TTFB)',
   /** Description of a Lighthouse audit that tells the user *why* they should reduce the amount of time it takes their server to start responding to requests. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Time To First Byte identifies the time at which your server sends a response.' +
-    ' [Learn more](https://developers.google.com/web/tools/lighthouse/audits/ttfb).',
+    ' [Learn more](https://web.dev/time-to-first-byte).',
   /** Used to summarize the total Time to First Byte duration for the primary HTML response. The `{timeInMs}` placeholder will be replaced with the time duration, shown in milliseconds (e.g. 210 ms) */
   displayValue: `Root document took {timeInMs, number, milliseconds}\xa0ms`,
 };
diff --git a/lighthouse-core/audits/user-timings.js b/lighthouse-core/audits/user-timings.js
index 9967e99e5..2a2b4d4f8 100644
--- a/lighthouse-core/audits/user-timings.js
+++ b/lighthouse-core/audits/user-timings.js
@@ -15,7 +15,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that tells the user they may want to use the User Timing API to help measure the performance of aspects of their page load and interaction. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Consider instrumenting your app with the User Timing API to measure your ' +
       'app\'s real-world performance during key user experiences. ' +
-      '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/user-timing).',
+      '[Learn more](https://web.dev/user-timings).',
   /** [ICU Syntax] Label for an audit identifying the number of User Timing timestamps present in the page. */
   displayValue: `{itemCount, plural,
     =1 {1 user timing}
diff --git a/lighthouse-core/audits/uses-rel-preconnect.js b/lighthouse-core/audits/uses-rel-preconnect.js
index 25fa47a5f..8b652ebcf 100644
--- a/lighthouse-core/audits/uses-rel-preconnect.js
+++ b/lighthouse-core/audits/uses-rel-preconnect.js
@@ -28,7 +28,7 @@ const UIStrings = {
   /** Description of a Lighthouse audit that tells the user how to connect early to third-party domains that will be used to load page resources. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description:
     'Consider adding `preconnect` or `dns-prefetch` resource hints to establish early ' +
-    `connections to important third-party origins. [Learn more](https://developers.google.com/web/fundamentals/performance/resource-prioritization#preconnect).`,
+    `connections to important third-party origins. [Learn more](https://web.dev/uses-rel-preconnect).`,
   /**
    * @description A warning message that is shown when the user tried to follow the advice of the audit, but it's not working as expected. Forgetting to set the `crossorigin` HTML attribute, or setting it to an incorrect value, on the link is a common mistake when adding preconnect links.
    * @example {https://example.com} securityOrigin
diff --git a/lighthouse-core/audits/uses-rel-preload.js b/lighthouse-core/audits/uses-rel-preload.js
index 767232e9b..e70bcccfa 100644
--- a/lighthouse-core/audits/uses-rel-preload.js
+++ b/lighthouse-core/audits/uses-rel-preload.js
@@ -19,7 +19,7 @@ const UIStrings = {
   title: 'Preload key requests',
   /** Description of a Lighthouse audit that tells the user *why* they should preload important network requests. The associated network requests are started halfway through pageload (or later) but should be started at the beginning. This is displayed after a user expands the section to see more. No character length limits. '<link rel=preload>' is the html code the user would include in their page and shouldn't be translated. 'Learn More' becomes link text to additional documentation. */
   description: 'Consider using `<link rel=preload>` to prioritize fetching resources that are ' +
-    'currently requested later in page load. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/preload).',
+    'currently requested later in page load. [Learn more](https://web.dev/uses-rel-preload).',
   /**
    * @description A warning message that is shown when the user tried to follow the advice of the audit, but it's not working as expected. Forgetting to set the `crossorigin` HTML attribute, or setting it to an incorrect value, on the link is a common mistake when adding preload links.
    * @example {https://example.com} preloadURL
diff --git a/lighthouse-core/gather/gatherers/script-elements.js b/lighthouse-core/gather/gatherers/script-elements.js
index d68023a5d..cb01c66c4 100644
--- a/lighthouse-core/gather/gatherers/script-elements.js
+++ b/lighthouse-core/gather/gatherers/script-elements.js
@@ -26,6 +26,7 @@ function collectAllScriptElements() {
     return {
       type: script.type || null,
       src: script.src || null,
+      id: script.id || null,
       async: script.async,
       defer: script.defer,
       source: /** @type {'head'|'body'} */ (script.closest('head') ? 'head' : 'body'),
@@ -81,6 +82,7 @@ class ScriptElements extends Gatherer {
             devtoolsNodePath: '',
             type: null,
             src: record.url,
+            id: null,
             async: false,
             defer: false,
             source: 'network',
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index 072110054..0d21d4a2e 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -342,7 +342,7 @@
     "message": "Total CPU Time"
   },
   "lighthouse-core/audits/bootup-time.js | description": {
-    "message": "Consider reducing the time spent parsing, compiling, and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/bootup)."
+    "message": "Consider reducing the time spent parsing, compiling, and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://web.dev/bootup-time)."
   },
   "lighthouse-core/audits/bootup-time.js | failureTitle": {
     "message": "Reduce JavaScript execution time"
@@ -351,25 +351,25 @@
     "message": "JavaScript execution time"
   },
   "lighthouse-core/audits/byte-efficiency/efficient-animated-content.js | description": {
-    "message": "Large GIFs are inefficient for delivering animated content. Consider using MPEG4/WebM videos for animations and PNG/WebP for static images instead of GIF to save network bytes. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/replace-animated-gifs-with-video/)"
+    "message": "Large GIFs are inefficient for delivering animated content. Consider using MPEG4/WebM videos for animations and PNG/WebP for static images instead of GIF to save network bytes. [Learn more](https://web.dev/efficient-animated-content)"
   },
   "lighthouse-core/audits/byte-efficiency/efficient-animated-content.js | title": {
     "message": "Use video formats for animated content"
   },
   "lighthouse-core/audits/byte-efficiency/offscreen-images.js | description": {
-    "message": "Consider lazy-loading offscreen and hidden images after all critical resources have finished loading to lower time to interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/offscreen-images)."
+    "message": "Consider lazy-loading offscreen and hidden images after all critical resources have finished loading to lower time to interactive. [Learn more](https://web.dev/offscreen-images)."
   },
   "lighthouse-core/audits/byte-efficiency/offscreen-images.js | title": {
     "message": "Defer offscreen images"
   },
   "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | description": {
-    "message": "Resources are blocking the first paint of your page. Consider delivering critical JS/CSS inline and deferring all non-critical JS/styles. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/blocking-resources)."
+    "message": "Resources are blocking the first paint of your page. Consider delivering critical JS/CSS inline and deferring all non-critical JS/styles. [Learn more](https://web.dev/render-blocking-resources)."
   },
   "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | title": {
     "message": "Eliminate render-blocking resources"
   },
   "lighthouse-core/audits/byte-efficiency/total-byte-weight.js | description": {
-    "message": "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)."
+    "message": "Large network payloads cost users real money and are highly correlated with long load times. [Learn more](https://web.dev/total-byte-weight)."
   },
   "lighthouse-core/audits/byte-efficiency/total-byte-weight.js | displayValue": {
     "message": "Total size was {totalBytes, number, bytes} KB"
@@ -381,19 +381,19 @@
     "message": "Avoids enormous network payloads"
   },
   "lighthouse-core/audits/byte-efficiency/unminified-css.js | description": {
-    "message": "Minifying CSS files can reduce network payload sizes. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/minify-css)."
+    "message": "Minifying CSS files can reduce network payload sizes. [Learn more](https://web.dev/unminified-css)."
   },
   "lighthouse-core/audits/byte-efficiency/unminified-css.js | title": {
     "message": "Minify CSS"
   },
   "lighthouse-core/audits/byte-efficiency/unminified-javascript.js | description": {
-    "message": "Minifying JavaScript files can reduce payload sizes and script parse time. [Learn more](https://developers.google.com/speed/docs/insights/MinifyResources)."
+    "message": "Minifying JavaScript files can reduce payload sizes and script parse time. [Learn more](https://web.dev/unminified-javascript)."
   },
   "lighthouse-core/audits/byte-efficiency/unminified-javascript.js | title": {
     "message": "Minify JavaScript"
   },
   "lighthouse-core/audits/byte-efficiency/unused-css-rules.js | description": {
-    "message": "Remove dead rules from stylesheets and defer the loading of CSS not used for above-the-fold content to reduce unnecessary bytes consumed by network activity. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css)."
+    "message": "Remove dead rules from stylesheets and defer the loading of CSS not used for above-the-fold content to reduce unnecessary bytes consumed by network activity. [Learn more](https://web.dev/unused-css-rules)."
   },
   "lighthouse-core/audits/byte-efficiency/unused-css-rules.js | title": {
     "message": "Remove unused CSS"
@@ -405,7 +405,7 @@
     "message": "Remove unused JavaScript"
   },
   "lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js | description": {
-    "message": "A long cache lifetime can speed up repeat visits to your page. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/cache-policy)."
+    "message": "A long cache lifetime can speed up repeat visits to your page. [Learn more](https://web.dev/uses-long-cache-ttl)."
   },
   "lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js | displayValue": {
     "message": "{itemCount, plural,\n    =1 {1 resource found}\n    other {# resources found}\n    }"
@@ -417,25 +417,25 @@
     "message": "Uses efficient cache policy on static assets"
   },
   "lighthouse-core/audits/byte-efficiency/uses-optimized-images.js | description": {
-    "message": "Optimized images load faster and consume less cellular data. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/optimize-images)."
+    "message": "Optimized images load faster and consume less cellular data. [Learn more](https://web.dev/uses-optimized-images)."
   },
   "lighthouse-core/audits/byte-efficiency/uses-optimized-images.js | title": {
     "message": "Efficiently encode images"
   },
   "lighthouse-core/audits/byte-efficiency/uses-responsive-images.js | description": {
-    "message": "Serve images that are appropriately-sized to save cellular data and improve load time. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/oversized-images)."
+    "message": "Serve images that are appropriately-sized to save cellular data and improve load time. [Learn more](https://web.dev/uses-responsive-images)."
   },
   "lighthouse-core/audits/byte-efficiency/uses-responsive-images.js | title": {
     "message": "Properly size images"
   },
   "lighthouse-core/audits/byte-efficiency/uses-text-compression.js | description": {
-    "message": "Text-based resources should be served with compression (gzip, deflate or brotli) to minimize total network bytes. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/text-compression)."
+    "message": "Text-based resources should be served with compression (gzip, deflate or brotli) to minimize total network bytes. [Learn more](https://web.dev/uses-text-compression)."
   },
   "lighthouse-core/audits/byte-efficiency/uses-text-compression.js | title": {
     "message": "Enable text compression"
   },
   "lighthouse-core/audits/byte-efficiency/uses-webp-images.js | description": {
-    "message": "Image formats like JPEG 2000, JPEG XR, and WebP often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/webp)."
+    "message": "Image formats like JPEG 2000, JPEG XR, and WebP often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://web.dev/uses-webp-images)."
   },
   "lighthouse-core/audits/byte-efficiency/uses-webp-images.js | title": {
     "message": "Serve images in next-gen formats"
@@ -453,7 +453,7 @@
     "message": "Content is sized correctly for the viewport"
   },
   "lighthouse-core/audits/critical-request-chains.js | description": {
-    "message": "The Critical Request Chains below show you what resources are loaded with a high priority. Consider reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/critical-request-chains)."
+    "message": "The Critical Request Chains below show you what resources are loaded with a high priority. Consider reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load. [Learn more](https://web.dev/critical-request-chains)."
   },
   "lighthouse-core/audits/critical-request-chains.js | displayValue": {
     "message": "{itemCount, plural,\n    =1 {1 chain found}\n    other {# chains found}\n    }"
@@ -675,7 +675,7 @@
     "message": "No browser errors logged to the console"
   },
   "lighthouse-core/audits/font-display.js | description": {
-    "message": "Leverage the font-display CSS feature to ensure text is user-visible while webfonts are loading. [Learn more](https://developers.google.com/web/updates/2016/02/font-display)."
+    "message": "Leverage the font-display CSS feature to ensure text is user-visible while webfonts are loading. [Learn more](https://web.dev/font-display)."
   },
   "lighthouse-core/audits/font-display.js | failureTitle": {
     "message": "Ensure text remains visible during webfont load"
@@ -750,7 +750,7 @@
     "message": "Category"
   },
   "lighthouse-core/audits/mainthread-work-breakdown.js | description": {
-    "message": "Consider reducing the time spent parsing, compiling and executing JS. You may find delivering smaller JS payloads helps with this."
+    "message": "Consider reducing the time spent parsing, compiling and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://web.dev/mainthread-work-breakdown)"
   },
   "lighthouse-core/audits/mainthread-work-breakdown.js | failureTitle": {
     "message": "Minimize main-thread work"
@@ -777,31 +777,31 @@
     "message": "Page transitions don't feel like they block on the network"
   },
   "lighthouse-core/audits/metrics/estimated-input-latency.js | description": {
-    "message": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency)."
+    "message": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://web.dev/estimated-input-latency)."
   },
   "lighthouse-core/audits/metrics/estimated-input-latency.js | title": {
     "message": "Estimated Input Latency"
   },
   "lighthouse-core/audits/metrics/first-contentful-paint.js | description": {
-    "message": "First Contentful Paint marks the time at which the first text or image is painted. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-contentful-paint)."
+    "message": "First Contentful Paint marks the time at which the first text or image is painted. [Learn more](https://web.dev/first-contentful-paint)."
   },
   "lighthouse-core/audits/metrics/first-contentful-paint.js | title": {
     "message": "First Contentful Paint"
   },
   "lighthouse-core/audits/metrics/first-cpu-idle.js | description": {
-    "message": "First CPU Idle marks the first time at which the page's main thread is quiet enough to handle input. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-interactive)."
+    "message": "First CPU Idle marks the first time at which the page's main thread is quiet enough to handle input.  [Learn more](https://web.dev/first-cpu-idle)."
   },
   "lighthouse-core/audits/metrics/first-cpu-idle.js | title": {
     "message": "First CPU Idle"
   },
   "lighthouse-core/audits/metrics/first-meaningful-paint.js | description": {
-    "message": "First Meaningful Paint measures when the primary content of a page is visible. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint)."
+    "message": "First Meaningful Paint measures when the primary content of a page is visible. [Learn more](https://web.dev/first-meaningful-paint)."
   },
   "lighthouse-core/audits/metrics/first-meaningful-paint.js | title": {
     "message": "First Meaningful Paint"
   },
   "lighthouse-core/audits/metrics/interactive.js | description": {
-    "message": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive)."
+    "message": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://web.dev/interactive)."
   },
   "lighthouse-core/audits/metrics/interactive.js | title": {
     "message": "Time to Interactive"
@@ -813,7 +813,7 @@
     "message": "Max Potential First Input Delay"
   },
   "lighthouse-core/audits/metrics/speed-index.js | description": {
-    "message": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/speed-index)."
+    "message": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more](https://web.dev/speed-index)."
   },
   "lighthouse-core/audits/metrics/speed-index.js | title": {
     "message": "Speed Index"
@@ -870,7 +870,7 @@
     "message": "Redirects HTTP traffic to HTTPS"
   },
   "lighthouse-core/audits/redirects.js | description": {
-    "message": "Redirects introduce additional delays before the page can be loaded. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/redirects)."
+    "message": "Redirects introduce additional delays before the page can be loaded. [Learn more](https://web.dev/redirects)."
   },
   "lighthouse-core/audits/redirects.js | title": {
     "message": "Avoid multiple page redirects"
@@ -1092,7 +1092,7 @@
     "message": "Third-Party usage"
   },
   "lighthouse-core/audits/time-to-first-byte.js | description": {
-    "message": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/ttfb)."
+    "message": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://web.dev/time-to-first-byte)."
   },
   "lighthouse-core/audits/time-to-first-byte.js | displayValue": {
     "message": "Root document took {timeInMs, number, milliseconds} ms"
@@ -1113,7 +1113,7 @@
     "message": "Type"
   },
   "lighthouse-core/audits/user-timings.js | description": {
-    "message": "Consider instrumenting your app with the User Timing API to measure your app's real-world performance during key user experiences. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/user-timing)."
+    "message": "Consider instrumenting your app with the User Timing API to measure your app's real-world performance during key user experiences. [Learn more](https://web.dev/user-timings)."
   },
   "lighthouse-core/audits/user-timings.js | displayValue": {
     "message": "{itemCount, plural,\n    =1 {1 user timing}\n    other {# user timings}\n    }"
@@ -1125,7 +1125,7 @@
     "message": "A preconnect <link> was found for \"{securityOrigin}\" but was not used by the browser. Check that you are using the `crossorigin` attribute properly."
   },
   "lighthouse-core/audits/uses-rel-preconnect.js | description": {
-    "message": "Consider adding `preconnect` or `dns-prefetch` resource hints to establish early connections to important third-party origins. [Learn more](https://developers.google.com/web/fundamentals/performance/resource-prioritization#preconnect)."
+    "message": "Consider adding `preconnect` or `dns-prefetch` resource hints to establish early connections to important third-party origins. [Learn more](https://web.dev/uses-rel-preconnect)."
   },
   "lighthouse-core/audits/uses-rel-preconnect.js | title": {
     "message": "Preconnect to required origins"
@@ -1134,7 +1134,7 @@
     "message": "A preload <link> was found for \"{preloadURL}\" but was not used by the browser. Check that you are using the `crossorigin` attribute properly."
   },
   "lighthouse-core/audits/uses-rel-preload.js | description": {
-    "message": "Consider using `<link rel=preload>` to prioritize fetching resources that are currently requested later in page load. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/preload)."
+    "message": "Consider using `<link rel=preload>` to prioritize fetching resources that are currently requested later in page load. [Learn more](https://web.dev/uses-rel-preload)."
   },
   "lighthouse-core/audits/uses-rel-preload.js | title": {
     "message": "Preload key requests"
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 618f5118c..0ac673eda 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -342,7 +342,7 @@
     "message": "T̂ót̂ál̂ ĆP̂Ú T̂ím̂é"
   },
   "lighthouse-core/audits/bootup-time.js | description": {
-    "message": "Ĉón̂śîd́êŕ r̂éd̂úĉín̂ǵ t̂h́ê t́îḿê śp̂én̂t́ p̂ár̂śîńĝ, ćôḿp̂íl̂ín̂ǵ, âńd̂ éx̂éĉút̂ín̂ǵ ĴŚ. Ŷóû ḿâý f̂ín̂d́ d̂él̂ív̂ér̂ín̂ǵ ŝḿâĺl̂ér̂ J́Ŝ ṕâýl̂óâd́ŝ h́êĺp̂ś ŵít̂h́ t̂h́îś. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/bootup)."
+    "message": "Ĉón̂śîd́êŕ r̂éd̂úĉín̂ǵ t̂h́ê t́îḿê śp̂én̂t́ p̂ár̂śîńĝ, ćôḿp̂íl̂ín̂ǵ, âńd̂ éx̂éĉút̂ín̂ǵ ĴŚ. Ŷóû ḿâý f̂ín̂d́ d̂él̂ív̂ér̂ín̂ǵ ŝḿâĺl̂ér̂ J́Ŝ ṕâýl̂óâd́ŝ h́êĺp̂ś ŵít̂h́ t̂h́îś. [L̂éâŕn̂ ḿôŕê](https://web.dev/bootup-time)."
   },
   "lighthouse-core/audits/bootup-time.js | failureTitle": {
     "message": "R̂éd̂úĉé Ĵáv̂áŜćr̂íp̂t́ êx́êćût́îón̂ t́îḿê"
@@ -351,25 +351,25 @@
     "message": "Ĵáv̂áŜćr̂íp̂t́ êx́êćût́îón̂ t́îḿê"
   },
   "lighthouse-core/audits/byte-efficiency/efficient-animated-content.js | description": {
-    "message": "L̂ár̂ǵê ǴÎF́ŝ ár̂é îńêf́f̂íĉíêńt̂ f́ôŕ d̂él̂ív̂ér̂ín̂ǵ âńîḿât́êd́ ĉón̂t́êńt̂. Ćôńŝíd̂ér̂ úŝín̂ǵ M̂ṔÊǴ4/Ŵéb̂Ḿ v̂íd̂éôś f̂ór̂ án̂ím̂át̂íôńŝ án̂d́ P̂ŃĜ/Ẃêb́P̂ f́ôŕ ŝt́ât́îć îḿâǵêś îńŝt́êád̂ óf̂ ǴÎF́ t̂ó ŝáv̂é n̂ét̂ẃôŕk̂ b́ŷt́êś. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/replace-animated-gifs-with-video/)"
+    "message": "L̂ár̂ǵê ǴÎF́ŝ ár̂é îńêf́f̂íĉíêńt̂ f́ôŕ d̂él̂ív̂ér̂ín̂ǵ âńîḿât́êd́ ĉón̂t́êńt̂. Ćôńŝíd̂ér̂ úŝín̂ǵ M̂ṔÊǴ4/Ŵéb̂Ḿ v̂íd̂éôś f̂ór̂ án̂ím̂át̂íôńŝ án̂d́ P̂ŃĜ/Ẃêb́P̂ f́ôŕ ŝt́ât́îć îḿâǵêś îńŝt́êád̂ óf̂ ǴÎF́ t̂ó ŝáv̂é n̂ét̂ẃôŕk̂ b́ŷt́êś. [L̂éâŕn̂ ḿôŕê](https://web.dev/efficient-animated-content)"
   },
   "lighthouse-core/audits/byte-efficiency/efficient-animated-content.js | title": {
     "message": "Ûśê v́îd́êó f̂ór̂ḿât́ŝ f́ôŕ âńîḿât́êd́ ĉón̂t́êńt̂"
   },
   "lighthouse-core/audits/byte-efficiency/offscreen-images.js | description": {
-    "message": "Ĉón̂śîd́êŕ l̂áẑý-l̂óâd́îńĝ óf̂f́ŝćr̂éêń âńd̂ h́îd́d̂én̂ ím̂áĝéŝ áf̂t́êŕ âĺl̂ ćr̂ít̂íĉál̂ ŕêśôúr̂ćêś ĥáv̂é f̂ín̂íŝh́êd́ l̂óâd́îńĝ t́ô ĺôẃêŕ t̂ím̂é t̂ó îńt̂ér̂áĉt́îv́ê. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/offscreen-images)."
+    "message": "Ĉón̂śîd́êŕ l̂áẑý-l̂óâd́îńĝ óf̂f́ŝćr̂éêń âńd̂ h́îd́d̂én̂ ím̂áĝéŝ áf̂t́êŕ âĺl̂ ćr̂ít̂íĉál̂ ŕêśôúr̂ćêś ĥáv̂é f̂ín̂íŝh́êd́ l̂óâd́îńĝ t́ô ĺôẃêŕ t̂ím̂é t̂ó îńt̂ér̂áĉt́îv́ê. [Ĺêár̂ń m̂ór̂é](https://web.dev/offscreen-images)."
   },
   "lighthouse-core/audits/byte-efficiency/offscreen-images.js | title": {
     "message": "D̂éf̂ér̂ óf̂f́ŝćr̂éêń îḿâǵêś"
   },
   "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | description": {
-    "message": "R̂éŝóûŕĉéŝ ár̂é b̂ĺôćk̂ín̂ǵ t̂h́ê f́îŕŝt́ p̂áîńt̂ óf̂ ýôúr̂ ṕâǵê. Ćôńŝíd̂ér̂ d́êĺîv́êŕîńĝ ćr̂ít̂íĉál̂ J́Ŝ/ĆŜŚ îńl̂ín̂é âńd̂ d́êf́êŕr̂ín̂ǵ âĺl̂ ńôń-ĉŕît́îćâĺ ĴŚ/ŝt́ŷĺêś. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/blocking-resources)."
+    "message": "R̂éŝóûŕĉéŝ ár̂é b̂ĺôćk̂ín̂ǵ t̂h́ê f́îŕŝt́ p̂áîńt̂ óf̂ ýôúr̂ ṕâǵê. Ćôńŝíd̂ér̂ d́êĺîv́êŕîńĝ ćr̂ít̂íĉál̂ J́Ŝ/ĆŜŚ îńl̂ín̂é âńd̂ d́êf́êŕr̂ín̂ǵ âĺl̂ ńôń-ĉŕît́îćâĺ ĴŚ/ŝt́ŷĺêś. [L̂éâŕn̂ ḿôŕê](https://web.dev/render-blocking-resources)."
   },
   "lighthouse-core/audits/byte-efficiency/render-blocking-resources.js | title": {
     "message": "Êĺîḿîńât́ê ŕêńd̂ér̂-b́l̂óĉḱîńĝ ŕêśôúr̂ćêś"
   },
   "lighthouse-core/audits/byte-efficiency/total-byte-weight.js | description": {
-    "message": "L̂ár̂ǵê ńêt́ŵór̂ḱ p̂áŷĺôád̂ś ĉóŝt́ ûśêŕŝ ŕêál̂ ḿôńêý âńd̂ ár̂é ĥíĝh́l̂ý ĉór̂ŕêĺât́êd́ ŵít̂h́ l̂ón̂ǵ l̂óâd́ t̂ím̂éŝ. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/network-payloads)."
+    "message": "L̂ár̂ǵê ńêt́ŵór̂ḱ p̂áŷĺôád̂ś ĉóŝt́ ûśêŕŝ ŕêál̂ ḿôńêý âńd̂ ár̂é ĥíĝh́l̂ý ĉór̂ŕêĺât́êd́ ŵít̂h́ l̂ón̂ǵ l̂óâd́ t̂ím̂éŝ. [Ĺêár̂ń m̂ór̂é](https://web.dev/total-byte-weight)."
   },
   "lighthouse-core/audits/byte-efficiency/total-byte-weight.js | displayValue": {
     "message": "T̂ót̂ál̂ śîźê ẃâś {totalBytes, number, bytes} K̂B́"
@@ -381,19 +381,19 @@
     "message": "Âv́ôíd̂ś êńôŕm̂óûś n̂ét̂ẃôŕk̂ ṕâýl̂óâd́ŝ"
   },
   "lighthouse-core/audits/byte-efficiency/unminified-css.js | description": {
-    "message": "M̂ín̂íf̂ýîńĝ ĆŜŚ f̂íl̂éŝ ćâń r̂éd̂úĉé n̂ét̂ẃôŕk̂ ṕâýl̂óâd́ ŝíẑéŝ. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/minify-css)."
+    "message": "M̂ín̂íf̂ýîńĝ ĆŜŚ f̂íl̂éŝ ćâń r̂éd̂úĉé n̂ét̂ẃôŕk̂ ṕâýl̂óâd́ ŝíẑéŝ. [Ĺêár̂ń m̂ór̂é](https://web.dev/unminified-css)."
   },
   "lighthouse-core/audits/byte-efficiency/unminified-css.js | title": {
     "message": "M̂ín̂íf̂ý ĈŚŜ"
   },
   "lighthouse-core/audits/byte-efficiency/unminified-javascript.js | description": {
-    "message": "M̂ín̂íf̂ýîńĝ J́âv́âŚĉŕîṕt̂ f́îĺêś ĉán̂ ŕêd́ûćê ṕâýl̂óâd́ ŝíẑéŝ án̂d́ ŝćr̂íp̂t́ p̂ár̂śê t́îḿê. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/speed/docs/insights/MinifyResources)."
+    "message": "M̂ín̂íf̂ýîńĝ J́âv́âŚĉŕîṕt̂ f́îĺêś ĉán̂ ŕêd́ûćê ṕâýl̂óâd́ ŝíẑéŝ án̂d́ ŝćr̂íp̂t́ p̂ár̂śê t́îḿê. [Ĺêár̂ń m̂ór̂é](https://web.dev/unminified-javascript)."
   },
   "lighthouse-core/audits/byte-efficiency/unminified-javascript.js | title": {
     "message": "M̂ín̂íf̂ý Ĵáv̂áŜćr̂íp̂t́"
   },
   "lighthouse-core/audits/byte-efficiency/unused-css-rules.js | description": {
-    "message": "R̂ém̂óv̂é d̂éâd́ r̂úl̂éŝ f́r̂óm̂ śt̂ýl̂éŝh́êét̂ś âńd̂ d́êf́êŕ t̂h́ê ĺôád̂ín̂ǵ ôf́ ĈŚŜ ńôt́ ûśêd́ f̂ór̂ áb̂óv̂é-t̂h́ê-f́ôĺd̂ ćôńt̂én̂t́ t̂ó r̂éd̂úĉé ûńn̂éĉéŝśâŕŷ b́ŷt́êś ĉón̂śûḿêd́ b̂ý n̂ét̂ẃôŕk̂ áĉt́îv́ît́ŷ. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/unused-css)."
+    "message": "R̂ém̂óv̂é d̂éâd́ r̂úl̂éŝ f́r̂óm̂ śt̂ýl̂éŝh́êét̂ś âńd̂ d́êf́êŕ t̂h́ê ĺôád̂ín̂ǵ ôf́ ĈŚŜ ńôt́ ûśêd́ f̂ór̂ áb̂óv̂é-t̂h́ê-f́ôĺd̂ ćôńt̂én̂t́ t̂ó r̂éd̂úĉé ûńn̂éĉéŝśâŕŷ b́ŷt́êś ĉón̂śûḿêd́ b̂ý n̂ét̂ẃôŕk̂ áĉt́îv́ît́ŷ. [Ĺêár̂ń m̂ór̂é](https://web.dev/unused-css-rules)."
   },
   "lighthouse-core/audits/byte-efficiency/unused-css-rules.js | title": {
     "message": "R̂ém̂óv̂é ûńûśêd́ ĈŚŜ"
@@ -405,7 +405,7 @@
     "message": "R̂ém̂óv̂é ûńûśêd́ Ĵáv̂áŜćr̂íp̂t́"
   },
   "lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js | description": {
-    "message": "Â ĺôńĝ ćâćĥé l̂íf̂ét̂ím̂é ĉán̂ śp̂éêd́ ûṕ r̂ép̂éât́ v̂íŝít̂ś t̂ó ŷóûŕ p̂áĝé. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/cache-policy)."
+    "message": "Â ĺôńĝ ćâćĥé l̂íf̂ét̂ím̂é ĉán̂ śp̂éêd́ ûṕ r̂ép̂éât́ v̂íŝít̂ś t̂ó ŷóûŕ p̂áĝé. [L̂éâŕn̂ ḿôŕê](https://web.dev/uses-long-cache-ttl)."
   },
   "lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js | displayValue": {
     "message": "{itemCount, plural,\n    =1 {1 r̂éŝóûŕĉé f̂óûńd̂}\n    other {# ŕêśôúr̂ćêś f̂óûńd̂}\n    }"
@@ -417,25 +417,25 @@
     "message": "Ûśêś êf́f̂íĉíêńt̂ ćâćĥé p̂ól̂íĉý ôń ŝt́ât́îć âśŝét̂ś"
   },
   "lighthouse-core/audits/byte-efficiency/uses-optimized-images.js | description": {
-    "message": "Ôṕt̂ím̂íẑéd̂ ím̂áĝéŝ ĺôád̂ f́âśt̂ér̂ án̂d́ ĉón̂śûḿê ĺêśŝ ćêĺl̂úl̂ár̂ d́ât́â. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/optimize-images)."
+    "message": "Ôṕt̂ím̂íẑéd̂ ím̂áĝéŝ ĺôád̂ f́âśt̂ér̂ án̂d́ ĉón̂śûḿê ĺêśŝ ćêĺl̂úl̂ár̂ d́ât́â. [Ĺêár̂ń m̂ór̂é](https://web.dev/uses-optimized-images)."
   },
   "lighthouse-core/audits/byte-efficiency/uses-optimized-images.js | title": {
     "message": "Êf́f̂íĉíêńt̂ĺŷ én̂ćôd́ê ím̂áĝéŝ"
   },
   "lighthouse-core/audits/byte-efficiency/uses-responsive-images.js | description": {
-    "message": "Ŝér̂v́ê ím̂áĝéŝ t́ĥát̂ ár̂é âṕp̂ŕôṕr̂íât́êĺŷ-śîźêd́ t̂ó ŝáv̂é ĉél̂ĺûĺâŕ d̂át̂á âńd̂ ím̂ṕr̂óv̂é l̂óâd́ t̂ím̂é. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/oversized-images)."
+    "message": "Ŝér̂v́ê ím̂áĝéŝ t́ĥát̂ ár̂é âṕp̂ŕôṕr̂íât́êĺŷ-śîźêd́ t̂ó ŝáv̂é ĉél̂ĺûĺâŕ d̂át̂á âńd̂ ím̂ṕr̂óv̂é l̂óâd́ t̂ím̂é. [L̂éâŕn̂ ḿôŕê](https://web.dev/uses-responsive-images)."
   },
   "lighthouse-core/audits/byte-efficiency/uses-responsive-images.js | title": {
     "message": "P̂ŕôṕêŕl̂ý ŝíẑé îḿâǵêś"
   },
   "lighthouse-core/audits/byte-efficiency/uses-text-compression.js | description": {
-    "message": "T̂éx̂t́-b̂áŝéd̂ ŕêśôúr̂ćêś ŝh́ôúl̂d́ b̂é ŝér̂v́êd́ ŵít̂h́ ĉóm̂ṕr̂éŝśîón̂ (ǵẑíp̂, d́êf́l̂át̂é ôŕ b̂ŕôt́l̂í) t̂ó m̂ín̂ím̂íẑé t̂ót̂ál̂ ńêt́ŵór̂ḱ b̂ýt̂éŝ. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/text-compression)."
+    "message": "T̂éx̂t́-b̂áŝéd̂ ŕêśôúr̂ćêś ŝh́ôúl̂d́ b̂é ŝér̂v́êd́ ŵít̂h́ ĉóm̂ṕr̂éŝśîón̂ (ǵẑíp̂, d́êf́l̂át̂é ôŕ b̂ŕôt́l̂í) t̂ó m̂ín̂ím̂íẑé t̂ót̂ál̂ ńêt́ŵór̂ḱ b̂ýt̂éŝ. [Ĺêár̂ń m̂ór̂é](https://web.dev/uses-text-compression)."
   },
   "lighthouse-core/audits/byte-efficiency/uses-text-compression.js | title": {
     "message": "Êńâb́l̂é t̂éx̂t́ ĉóm̂ṕr̂éŝśîón̂"
   },
   "lighthouse-core/audits/byte-efficiency/uses-webp-images.js | description": {
-    "message": "Îḿâǵê f́ôŕm̂át̂ś l̂ík̂é ĴṔÊǴ 2000, ĴṔÊǴ X̂Ŕ, âńd̂ Ẃêb́P̂ óf̂t́êń p̂ŕôv́îd́ê b́êt́t̂ér̂ ćôḿp̂ŕêśŝíôń t̂h́âń P̂ŃĜ ór̂ J́P̂ÉĜ, ẃĥíĉh́ m̂éâńŝ f́âśt̂ér̂ d́ôẃn̂ĺôád̂ś âńd̂ ĺêśŝ d́ât́â ćôńŝúm̂ṕt̂íôń. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/webp)."
+    "message": "Îḿâǵê f́ôŕm̂át̂ś l̂ík̂é ĴṔÊǴ 2000, ĴṔÊǴ X̂Ŕ, âńd̂ Ẃêb́P̂ óf̂t́êń p̂ŕôv́îd́ê b́êt́t̂ér̂ ćôḿp̂ŕêśŝíôń t̂h́âń P̂ŃĜ ór̂ J́P̂ÉĜ, ẃĥíĉh́ m̂éâńŝ f́âśt̂ér̂ d́ôẃn̂ĺôád̂ś âńd̂ ĺêśŝ d́ât́â ćôńŝúm̂ṕt̂íôń. [L̂éâŕn̂ ḿôŕê](https://web.dev/uses-webp-images)."
   },
   "lighthouse-core/audits/byte-efficiency/uses-webp-images.js | title": {
     "message": "Ŝér̂v́ê ím̂áĝéŝ ín̂ ńêx́t̂-ǵêń f̂ór̂ḿât́ŝ"
@@ -453,7 +453,7 @@
     "message": "Ĉón̂t́êńt̂ íŝ śîźêd́ ĉór̂ŕêćt̂ĺŷ f́ôŕ t̂h́ê v́îéŵṕôŕt̂"
   },
   "lighthouse-core/audits/critical-request-chains.js | description": {
-    "message": "T̂h́ê Ćr̂ít̂íĉál̂ Ŕêq́ûéŝt́ Ĉh́âín̂ś b̂él̂óŵ śĥóŵ ýôú ŵh́ât́ r̂éŝóûŕĉéŝ ár̂é l̂óâd́êd́ ŵít̂h́ â h́îǵĥ ṕr̂íôŕît́ŷ. Ćôńŝíd̂ér̂ ŕêd́ûćîńĝ t́ĥé l̂én̂ǵt̂h́ ôf́ ĉh́âín̂ś, r̂éd̂úĉín̂ǵ t̂h́ê d́ôẃn̂ĺôád̂ śîźê óf̂ ŕêśôúr̂ćêś, ôŕ d̂éf̂ér̂ŕîńĝ t́ĥé d̂óŵńl̂óâd́ ôf́ ûńn̂éĉéŝśâŕŷ ŕêśôúr̂ćêś t̂ó îḿp̂ŕôv́ê ṕâǵê ĺôád̂. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/critical-request-chains)."
+    "message": "T̂h́ê Ćr̂ít̂íĉál̂ Ŕêq́ûéŝt́ Ĉh́âín̂ś b̂él̂óŵ śĥóŵ ýôú ŵh́ât́ r̂éŝóûŕĉéŝ ár̂é l̂óâd́êd́ ŵít̂h́ â h́îǵĥ ṕr̂íôŕît́ŷ. Ćôńŝíd̂ér̂ ŕêd́ûćîńĝ t́ĥé l̂én̂ǵt̂h́ ôf́ ĉh́âín̂ś, r̂éd̂úĉín̂ǵ t̂h́ê d́ôẃn̂ĺôád̂ śîźê óf̂ ŕêśôúr̂ćêś, ôŕ d̂éf̂ér̂ŕîńĝ t́ĥé d̂óŵńl̂óâd́ ôf́ ûńn̂éĉéŝśâŕŷ ŕêśôúr̂ćêś t̂ó îḿp̂ŕôv́ê ṕâǵê ĺôád̂. [Ĺêár̂ń m̂ór̂é](https://web.dev/critical-request-chains)."
   },
   "lighthouse-core/audits/critical-request-chains.js | displayValue": {
     "message": "{itemCount, plural,\n    =1 {1 ĉh́âín̂ f́ôún̂d́}\n    other {# ĉh́âín̂ś f̂óûńd̂}\n    }"
@@ -675,7 +675,7 @@
     "message": "N̂ó b̂ŕôẃŝér̂ ér̂ŕôŕŝ ĺôǵĝéd̂ t́ô t́ĥé ĉón̂śôĺê"
   },
   "lighthouse-core/audits/font-display.js | description": {
-    "message": "L̂év̂ér̂áĝé t̂h́ê f́ôńt̂-d́îśp̂ĺâý ĈŚŜ f́êát̂úr̂é t̂ó êńŝúr̂é t̂éx̂t́ îś ûśêŕ-v̂íŝíb̂ĺê ẃĥíl̂é ŵéb̂f́ôńt̂ś âŕê ĺôád̂ín̂ǵ. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/updates/2016/02/font-display)."
+    "message": "L̂év̂ér̂áĝé t̂h́ê f́ôńt̂-d́îśp̂ĺâý ĈŚŜ f́êát̂úr̂é t̂ó êńŝúr̂é t̂éx̂t́ îś ûśêŕ-v̂íŝíb̂ĺê ẃĥíl̂é ŵéb̂f́ôńt̂ś âŕê ĺôád̂ín̂ǵ. [L̂éâŕn̂ ḿôŕê](https://web.dev/font-display)."
   },
   "lighthouse-core/audits/font-display.js | failureTitle": {
     "message": "Êńŝúr̂é t̂éx̂t́ r̂ém̂áîńŝ v́îśîb́l̂é d̂úr̂ín̂ǵ ŵéb̂f́ôńt̂ ĺôád̂"
@@ -750,7 +750,7 @@
     "message": "Ĉát̂éĝór̂ý"
   },
   "lighthouse-core/audits/mainthread-work-breakdown.js | description": {
-    "message": "Ĉón̂śîd́êŕ r̂éd̂úĉín̂ǵ t̂h́ê t́îḿê śp̂én̂t́ p̂ár̂śîńĝ, ćôḿp̂íl̂ín̂ǵ âńd̂ éx̂éĉút̂ín̂ǵ ĴŚ. Ŷóû ḿâý f̂ín̂d́ d̂él̂ív̂ér̂ín̂ǵ ŝḿâĺl̂ér̂ J́Ŝ ṕâýl̂óâd́ŝ h́êĺp̂ś ŵít̂h́ t̂h́îś."
+    "message": "Ĉón̂śîd́êŕ r̂éd̂úĉín̂ǵ t̂h́ê t́îḿê śp̂én̂t́ p̂ár̂śîńĝ, ćôḿp̂íl̂ín̂ǵ âńd̂ éx̂éĉút̂ín̂ǵ ĴŚ. Ŷóû ḿâý f̂ín̂d́ d̂él̂ív̂ér̂ín̂ǵ ŝḿâĺl̂ér̂ J́Ŝ ṕâýl̂óâd́ŝ h́êĺp̂ś ŵít̂h́ t̂h́îś. [L̂éâŕn̂ ḿôŕê](https://web.dev/mainthread-work-breakdown)"
   },
   "lighthouse-core/audits/mainthread-work-breakdown.js | failureTitle": {
     "message": "M̂ín̂ím̂íẑé m̂áîń-t̂h́r̂éâd́ ŵór̂ḱ"
@@ -777,31 +777,31 @@
     "message": "P̂áĝé t̂ŕâńŝít̂íôńŝ d́ôń't̂ f́êél̂ ĺîḱê t́ĥéŷ b́l̂óĉḱ ôń t̂h́ê ńêt́ŵór̂ḱ"
   },
   "lighthouse-core/audits/metrics/estimated-input-latency.js | description": {
-    "message": "Êśt̂ím̂át̂éd̂ Ín̂ṕût́ L̂át̂én̂ćŷ íŝ án̂ éŝt́îḿât́ê óf̂ h́ôẃ l̂ón̂ǵ ŷóûŕ âṕp̂ t́âḱêś t̂ó r̂éŝṕôńd̂ t́ô úŝér̂ ín̂ṕût́, îń m̂íl̂ĺîśêćôńd̂ś, d̂úr̂ín̂ǵ t̂h́ê b́ûśîéŝt́ 5ŝ ẃîńd̂óŵ óf̂ ṕâǵê ĺôád̂. Íf̂ ýôúr̂ ĺât́êńĉý îś ĥíĝh́êŕ t̂h́âń 50 m̂ś, ûśêŕŝ ḿâý p̂ér̂ćêív̂é ŷóûŕ âṕp̂ áŝ ĺâǵĝý. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency)."
+    "message": "Êśt̂ím̂át̂éd̂ Ín̂ṕût́ L̂át̂én̂ćŷ íŝ án̂ éŝt́îḿât́ê óf̂ h́ôẃ l̂ón̂ǵ ŷóûŕ âṕp̂ t́âḱêś t̂ó r̂éŝṕôńd̂ t́ô úŝér̂ ín̂ṕût́, îń m̂íl̂ĺîśêćôńd̂ś, d̂úr̂ín̂ǵ t̂h́ê b́ûśîéŝt́ 5ŝ ẃîńd̂óŵ óf̂ ṕâǵê ĺôád̂. Íf̂ ýôúr̂ ĺât́êńĉý îś ĥíĝh́êŕ t̂h́âń 50 m̂ś, ûśêŕŝ ḿâý p̂ér̂ćêív̂é ŷóûŕ âṕp̂ áŝ ĺâǵĝý. [L̂éâŕn̂ ḿôŕê](https://web.dev/estimated-input-latency)."
   },
   "lighthouse-core/audits/metrics/estimated-input-latency.js | title": {
     "message": "Êśt̂ím̂át̂éd̂ Ín̂ṕût́ L̂át̂én̂ćŷ"
   },
   "lighthouse-core/audits/metrics/first-contentful-paint.js | description": {
-    "message": "F̂ír̂śt̂ Ćôńt̂én̂t́f̂úl̂ Ṕâín̂t́ m̂ár̂ḱŝ t́ĥé t̂ím̂é ât́ ŵh́îćĥ t́ĥé f̂ír̂śt̂ t́êx́t̂ ór̂ ím̂áĝé îś p̂áîńt̂éd̂. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/first-contentful-paint)."
+    "message": "F̂ír̂śt̂ Ćôńt̂én̂t́f̂úl̂ Ṕâín̂t́ m̂ár̂ḱŝ t́ĥé t̂ím̂é ât́ ŵh́îćĥ t́ĥé f̂ír̂śt̂ t́êx́t̂ ór̂ ím̂áĝé îś p̂áîńt̂éd̂. [Ĺêár̂ń m̂ór̂é](https://web.dev/first-contentful-paint)."
   },
   "lighthouse-core/audits/metrics/first-contentful-paint.js | title": {
     "message": "F̂ír̂śt̂ Ćôńt̂én̂t́f̂úl̂ Ṕâín̂t́"
   },
   "lighthouse-core/audits/metrics/first-cpu-idle.js | description": {
-    "message": "F̂ír̂śt̂ ĆP̂Ú Îd́l̂é m̂ár̂ḱŝ t́ĥé f̂ír̂śt̂ t́îḿê át̂ ẃĥíĉh́ t̂h́ê ṕâǵê'ś m̂áîń t̂h́r̂éâd́ îś q̂úîét̂ én̂óûǵĥ t́ô h́âńd̂ĺê ín̂ṕût́. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/first-interactive)."
+    "message": "F̂ír̂śt̂ ĆP̂Ú Îd́l̂é m̂ár̂ḱŝ t́ĥé f̂ír̂śt̂ t́îḿê át̂ ẃĥíĉh́ t̂h́ê ṕâǵê'ś m̂áîń t̂h́r̂éâd́ îś q̂úîét̂ én̂óûǵĥ t́ô h́âńd̂ĺê ín̂ṕût́.  [L̂éâŕn̂ ḿôŕê](https://web.dev/first-cpu-idle)."
   },
   "lighthouse-core/audits/metrics/first-cpu-idle.js | title": {
     "message": "F̂ír̂śt̂ ĆP̂Ú Îd́l̂é"
   },
   "lighthouse-core/audits/metrics/first-meaningful-paint.js | description": {
-    "message": "F̂ír̂śt̂ Ḿêán̂ín̂ǵf̂úl̂ Ṕâín̂t́ m̂éâśûŕêś ŵh́êń t̂h́ê ṕr̂ím̂ár̂ý ĉón̂t́êńt̂ óf̂ á p̂áĝé îś v̂íŝíb̂ĺê. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint)."
+    "message": "F̂ír̂śt̂ Ḿêán̂ín̂ǵf̂úl̂ Ṕâín̂t́ m̂éâśûŕêś ŵh́êń t̂h́ê ṕr̂ím̂ár̂ý ĉón̂t́êńt̂ óf̂ á p̂áĝé îś v̂íŝíb̂ĺê. [Ĺêár̂ń m̂ór̂é](https://web.dev/first-meaningful-paint)."
   },
   "lighthouse-core/audits/metrics/first-meaningful-paint.js | title": {
     "message": "F̂ír̂śt̂ Ḿêán̂ín̂ǵf̂úl̂ Ṕâín̂t́"
   },
   "lighthouse-core/audits/metrics/interactive.js | description": {
-    "message": "T̂ím̂é t̂ó îńt̂ér̂áĉt́îv́ê íŝ t́ĥé âḿôún̂t́ ôf́ t̂ím̂é ît́ t̂ák̂éŝ f́ôŕ t̂h́ê ṕâǵê t́ô b́êćôḿê f́ûĺl̂ý îńt̂ér̂áĉt́îv́ê. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive)."
+    "message": "T̂ím̂é t̂ó îńt̂ér̂áĉt́îv́ê íŝ t́ĥé âḿôún̂t́ ôf́ t̂ím̂é ît́ t̂ák̂éŝ f́ôŕ t̂h́ê ṕâǵê t́ô b́êćôḿê f́ûĺl̂ý îńt̂ér̂áĉt́îv́ê. [Ĺêár̂ń m̂ór̂é](https://web.dev/interactive)."
   },
   "lighthouse-core/audits/metrics/interactive.js | title": {
     "message": "T̂ím̂é t̂ó Îńt̂ér̂áĉt́îv́ê"
@@ -813,7 +813,7 @@
     "message": "M̂áx̂ Ṕôt́êńt̂íâĺ F̂ír̂śt̂ Ín̂ṕût́ D̂él̂áŷ"
   },
   "lighthouse-core/audits/metrics/speed-index.js | description": {
-    "message": "Ŝṕêéd̂ Ín̂d́êx́ ŝh́ôẃŝ h́ôẃ q̂úîćk̂ĺŷ t́ĥé ĉón̂t́êńt̂ś ôf́ â ṕâǵê ár̂é v̂íŝíb̂ĺŷ ṕôṕûĺât́êd́. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/speed-index)."
+    "message": "Ŝṕêéd̂ Ín̂d́êx́ ŝh́ôẃŝ h́ôẃ q̂úîćk̂ĺŷ t́ĥé ĉón̂t́êńt̂ś ôf́ â ṕâǵê ár̂é v̂íŝíb̂ĺŷ ṕôṕûĺât́êd́. [L̂éâŕn̂ ḿôŕê](https://web.dev/speed-index)."
   },
   "lighthouse-core/audits/metrics/speed-index.js | title": {
     "message": "Ŝṕêéd̂ Ín̂d́êx́"
@@ -870,7 +870,7 @@
     "message": "R̂éd̂ír̂éĉt́ŝ H́T̂T́P̂ t́r̂áf̂f́îć t̂ó ĤT́T̂ṔŜ"
   },
   "lighthouse-core/audits/redirects.js | description": {
-    "message": "R̂éd̂ír̂éĉt́ŝ ín̂t́r̂ód̂úĉé âd́d̂ít̂íôńâĺ d̂él̂áŷś b̂éf̂ór̂é t̂h́ê ṕâǵê ćâń b̂é l̂óâd́êd́. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/redirects)."
+    "message": "R̂éd̂ír̂éĉt́ŝ ín̂t́r̂ód̂úĉé âd́d̂ít̂íôńâĺ d̂él̂áŷś b̂éf̂ór̂é t̂h́ê ṕâǵê ćâń b̂é l̂óâd́êd́. [L̂éâŕn̂ ḿôŕê](https://web.dev/redirects)."
   },
   "lighthouse-core/audits/redirects.js | title": {
     "message": "Âv́ôíd̂ ḿûĺt̂íp̂ĺê ṕâǵê ŕêd́îŕêćt̂ś"
@@ -1092,7 +1092,7 @@
     "message": "T̂h́îŕd̂-Ṕâŕt̂ý ûśâǵê"
   },
   "lighthouse-core/audits/time-to-first-byte.js | description": {
-    "message": "T̂ím̂é T̂ó F̂ír̂śt̂ B́ŷt́ê íd̂én̂t́îf́îéŝ t́ĥé t̂ím̂é ât́ ŵh́îćĥ ýôúr̂ śêŕv̂ér̂ śêńd̂ś â ŕêśp̂ón̂śê. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/ttfb)."
+    "message": "T̂ím̂é T̂ó F̂ír̂śt̂ B́ŷt́ê íd̂én̂t́îf́îéŝ t́ĥé t̂ím̂é ât́ ŵh́îćĥ ýôúr̂ śêŕv̂ér̂ śêńd̂ś â ŕêśp̂ón̂śê. [Ĺêár̂ń m̂ór̂é](https://web.dev/time-to-first-byte)."
   },
   "lighthouse-core/audits/time-to-first-byte.js | displayValue": {
     "message": "R̂óôt́ d̂óĉúm̂én̂t́ t̂óôḱ {timeInMs, number, milliseconds} m̂ś"
@@ -1113,7 +1113,7 @@
     "message": "T̂ýp̂é"
   },
   "lighthouse-core/audits/user-timings.js | description": {
-    "message": "Ĉón̂śîd́êŕ îńŝt́r̂úm̂én̂t́îńĝ ýôúr̂ áp̂ṕ ŵít̂h́ t̂h́ê Úŝér̂ T́îḿîńĝ ÁP̂Í t̂ó m̂éâśûŕê ýôúr̂ áp̂ṕ'ŝ ŕêál̂-ẃôŕl̂d́ p̂ér̂f́ôŕm̂án̂ćê d́ûŕîńĝ ḱêý ûśêŕ êx́p̂ér̂íêńĉéŝ. [Ĺêár̂ń m̂ór̂é](https://developers.google.com/web/tools/lighthouse/audits/user-timing)."
+    "message": "Ĉón̂śîd́êŕ îńŝt́r̂úm̂én̂t́îńĝ ýôúr̂ áp̂ṕ ŵít̂h́ t̂h́ê Úŝér̂ T́îḿîńĝ ÁP̂Í t̂ó m̂éâśûŕê ýôúr̂ áp̂ṕ'ŝ ŕêál̂-ẃôŕl̂d́ p̂ér̂f́ôŕm̂án̂ćê d́ûŕîńĝ ḱêý ûśêŕ êx́p̂ér̂íêńĉéŝ. [Ĺêár̂ń m̂ór̂é](https://web.dev/user-timings)."
   },
   "lighthouse-core/audits/user-timings.js | displayValue": {
     "message": "{itemCount, plural,\n    =1 {1 ûśêŕ t̂ím̂ín̂ǵ}\n    other {# ûśêŕ t̂ím̂ín̂ǵŝ}\n    }"
@@ -1125,7 +1125,7 @@
     "message": "Â ṕr̂éĉón̂ńêćt̂ <ĺîńk̂> ẃâś f̂óûńd̂ f́ôŕ \"{securityOrigin}\" b̂út̂ ẃâś n̂ót̂ úŝéd̂ b́ŷ t́ĥé b̂ŕôẃŝér̂. Ćĥéĉḱ t̂h́ât́ ŷóû ár̂é ûśîńĝ t́ĥé `crossorigin` ât́t̂ŕîb́ût́ê ṕr̂óp̂ér̂ĺŷ."
   },
   "lighthouse-core/audits/uses-rel-preconnect.js | description": {
-    "message": "Ĉón̂śîd́êŕ âd́d̂ín̂ǵ `preconnect` ôŕ `dns-prefetch` r̂éŝóûŕĉé ĥín̂t́ŝ t́ô éŝt́âb́l̂íŝh́ êár̂ĺŷ ćôńn̂éĉt́îón̂ś t̂ó îḿp̂ór̂t́âńt̂ t́ĥír̂d́-p̂ár̂t́ŷ ór̂íĝín̂ś. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/fundamentals/performance/resource-prioritization#preconnect)."
+    "message": "Ĉón̂śîd́êŕ âd́d̂ín̂ǵ `preconnect` ôŕ `dns-prefetch` r̂éŝóûŕĉé ĥín̂t́ŝ t́ô éŝt́âb́l̂íŝh́ êár̂ĺŷ ćôńn̂éĉt́îón̂ś t̂ó îḿp̂ór̂t́âńt̂ t́ĥír̂d́-p̂ár̂t́ŷ ór̂íĝín̂ś. [L̂éâŕn̂ ḿôŕê](https://web.dev/uses-rel-preconnect)."
   },
   "lighthouse-core/audits/uses-rel-preconnect.js | title": {
     "message": "P̂ŕêćôńn̂éĉt́ t̂ó r̂éq̂úîŕêd́ ôŕîǵîńŝ"
@@ -1134,7 +1134,7 @@
     "message": "Â ṕr̂él̂óâd́ <l̂ín̂ḱ> ŵáŝ f́ôún̂d́ f̂ór̂ \"{preloadURL}\" b́ût́ ŵáŝ ńôt́ ûśêd́ b̂ý t̂h́ê b́r̂óŵśêŕ. Ĉh́êćk̂ t́ĥát̂ ýôú âŕê úŝín̂ǵ t̂h́ê `crossorigin` át̂t́r̂íb̂út̂é p̂ŕôṕêŕl̂ý."
   },
   "lighthouse-core/audits/uses-rel-preload.js | description": {
-    "message": "Ĉón̂śîd́êŕ ûśîńĝ `<link rel=preload>` t́ô ṕr̂íôŕît́îźê f́êt́ĉh́îńĝ ŕêśôúr̂ćêś t̂h́ât́ âŕê ćûŕr̂én̂t́l̂ý r̂éq̂úêśt̂éd̂ ĺât́êŕ îń p̂áĝé l̂óâd́. [L̂éâŕn̂ ḿôŕê](https://developers.google.com/web/tools/lighthouse/audits/preload)."
+    "message": "Ĉón̂śîd́êŕ ûśîńĝ `<link rel=preload>` t́ô ṕr̂íôŕît́îźê f́êt́ĉh́îńĝ ŕêśôúr̂ćêś t̂h́ât́ âŕê ćûŕr̂én̂t́l̂ý r̂éq̂úêśt̂éd̂ ĺât́êŕ îń p̂áĝé l̂óâd́. [L̂éâŕn̂ ḿôŕê](https://web.dev/uses-rel-preload)."
   },
   "lighthouse-core/audits/uses-rel-preload.js | title": {
     "message": "P̂ŕêĺôád̂ ḱêý r̂éq̂úêśt̂ś"
diff --git a/lighthouse-core/lib/network-recorder.js b/lighthouse-core/lib/network-recorder.js
index 11f68ce0d..70a109dd3 100644
--- a/lighthouse-core/lib/network-recorder.js
+++ b/lighthouse-core/lib/network-recorder.js
@@ -30,7 +30,7 @@ class NetworkRecorder extends EventEmitter {
    * @return {Array<LH.Artifacts.NetworkRequest>}
    */
   getInflightRecords() {
-    return this._records.filter(record => !NetworkRecorder.isNetworkRecordFinished(record));
+    return this._records.filter(record => !record.finished);
   }
 
   getRecords() {
@@ -72,7 +72,7 @@ class NetworkRecorder extends EventEmitter {
 
     for (let i = 0; i < this._records.length; i++) {
       const record = this._records[i];
-      if (NetworkRecorder.isNetworkRecordFinished(record)) continue;
+      if (record.finished) continue;
       if (IGNORED_NETWORK_SCHEMES.includes(record.parsedURL.scheme)) continue;
       inflightRequests++;
     }
@@ -99,28 +99,6 @@ class NetworkRecorder extends EventEmitter {
     }
   }
 
-  /**
-   * QUIC network requests don't always "finish" even when they're done loading data, use recievedHeaders
-   * @see https://github.com/GoogleChrome/lighthouse/issues/5254
-   * @param {LH.Artifacts.NetworkRequest} record
-   * @return {boolean}
-   */
-  static _isQUICAndFinished(record) {
-    const isQUIC = record.responseHeaders && record.responseHeaders
-        .some(header => header.name.toLowerCase() === 'alt-svc' && /quic/.test(header.value));
-    const receivedHeaders = record.timing && record.timing.receiveHeadersEnd > 0;
-    return !!(isQUIC && receivedHeaders && record.endTime);
-  }
-
-  /**
-   * @param {LH.Artifacts.NetworkRequest} record
-   * @return {boolean}
-   */
-  static isNetworkRecordFinished(record) {
-    return record.finished ||
-      NetworkRecorder._isQUICAndFinished(record);
-  }
-
   /**
    * Finds all time periods where the number of inflight requests is less than or equal to the
    * number of allowed concurrent requests.
@@ -141,7 +119,7 @@ class NetworkRecorder extends EventEmitter {
 
       // convert the network record timestamp to ms
       timeBoundaries.push({time: record.startTime * 1000, isStart: true});
-      if (NetworkRecorder.isNetworkRecordFinished(record)) {
+      if (record.finished) {
         timeBoundaries.push({time: record.endTime * 1000, isStart: false});
       }
     });
diff --git a/lighthouse-core/lib/tracehouse/main-thread-tasks.js b/lighthouse-core/lib/tracehouse/main-thread-tasks.js
index e1c53205c..78ce49637 100644
--- a/lighthouse-core/lib/tracehouse/main-thread-tasks.js
+++ b/lighthouse-core/lib/tracehouse/main-thread-tasks.js
@@ -44,104 +44,292 @@ const {taskGroups, taskNameToGroup} = require('./task-groups.js');
 class MainThreadTasks {
   /**
    * @param {LH.TraceEvent} event
-   * @param {TaskNode} [parent]
+   * @param {Pick<LH.TraceEvent, 'ph'|'ts'>} [endEvent]
    * @return {TaskNode}
    */
-  static _createNewTaskNode(event, parent) {
+  static _createNewTaskNode(event, endEvent) {
+    const isCompleteEvent = event.ph === 'X' && !endEvent;
+    const isStartEndEventPair = event.ph === 'B' && endEvent && endEvent.ph === 'E';
+    if (!isCompleteEvent && !isStartEndEventPair) {
+      throw new Error('Invalid parameters for _createNewTaskNode');
+    }
+
+    const startTime = event.ts;
+    const endTime = endEvent ? endEvent.ts : event.ts + Number(event.dur || 0);
+
     const newTask = {
       event,
-      startTime: event.ts,
-      endTime: event.ph === 'X' ? event.ts + Number(event.dur || 0) : NaN,
-      parent: parent,
-      children: [],
+      startTime,
+      endTime,
+      duration: endTime - startTime,
 
       // These properties will be filled in later
+      parent: undefined,
+      children: [],
       attributableURLs: [],
       group: taskGroups.other,
-      duration: NaN,
       selfTime: NaN,
     };
 
-    if (parent) {
-      parent.children.push(newTask);
-    }
-
     return newTask;
   }
 
   /**
-   * @param {LH.TraceEvent[]} mainThreadEvents
+   *
+   * @param {TaskNode} currentTask
+   * @param {number} stopTs
    * @param {PriorTaskData} priorTaskData
+   * @param {Array<LH.TraceEvent>} reverseEventsQueue
+   */
+  static _assignAllTimersUntilTs(
+      currentTask,
+      stopTs,
+      priorTaskData,
+      reverseEventsQueue
+  ) {
+    while (reverseEventsQueue.length) {
+      const nextTimerInstallEvent = reverseEventsQueue.pop();
+      // We're out of events to look at; we're done.
+      if (!nextTimerInstallEvent) break;
+
+      // Timer event is after our current task; push it back on for next time, and we're done.
+      if (nextTimerInstallEvent.ts > stopTs) {
+        reverseEventsQueue.push(nextTimerInstallEvent);
+        break;
+      }
+
+      // Timer event is before the current task, just skip it.
+      if (nextTimerInstallEvent.ts < currentTask.startTime) {
+        continue;
+      }
+
+      // We're right where we need to be, point the timerId to our `currentTask`
+      /** @type {string} */
+      // @ts-ignore - timerId exists on `TimerInstall` events.
+      const timerId = nextTimerInstallEvent.args.data.timerId;
+      priorTaskData.timers.set(timerId, currentTask);
+    }
+  }
+
+  /**
+   * This function takes the start and end events from a thread and creates tasks from them.
+   * We do this by iterating through the start and end event arrays simultaneously. For each start
+   * event we attempt to find its end event.
+   *
+   * Because of this matching of start/end events and the need to be mutating our end events queue,
+   * we reverse the array to more efficiently `.pop()` them off rather than `.shift()` them off.
+   * While it's true the worst case runtime here is O(n^2), ~99.999% of the time the reverse loop is O(1)
+   * because the overwhelmingly common case is that end event for a given start event is simply the very next event in our queue.
+   *
+   * @param {LH.TraceEvent[]} taskStartEvents
+   * @param {LH.TraceEvent[]} taskEndEvents
    * @param {number} traceEndTs
    * @return {TaskNode[]}
    */
-  static _createTasksFromEvents(mainThreadEvents, priorTaskData, traceEndTs) {
+  static _createTasksFromStartAndEndEvents(taskStartEvents, taskEndEvents, traceEndTs) {
     /** @type {TaskNode[]} */
     const tasks = [];
-    /** @type {TaskNode|undefined} */
-    let currentTask;
+    // Create a reversed copy of the array to avoid copying the rest of the queue on every mutation.
+    // i.e. pop() is O(1) while shift() is O(n), we take the earliest ts element off the queue *a lot*
+    // so we'll optimize for having the earliest timestamp events at the end of the array.
+    const taskEndEventsReverseQueue = taskEndEvents.slice().reverse();
+
+    for (let i = 0; i < taskStartEvents.length; i++) {
+      const taskStartEvent = taskStartEvents[i];
+      if (taskStartEvent.ph === 'X') {
+        // Task is a complete X event, we have all the information we need already.
+        tasks.push(MainThreadTasks._createNewTaskNode(taskStartEvent));
+        continue;
+      }
 
-    for (const event of mainThreadEvents) {
-      // Save the timer data, TimerInstall events are instant events `ph === 'I'` so process them first.
-      if (event.name === 'TimerInstall' && currentTask) {
-        /** @type {string} */
-        // @ts-ignore - timerId exists when name is TimerInstall
-        const timerId = event.args.data.timerId;
-        priorTaskData.timers.set(timerId, currentTask);
+      // Task is a B/E event pair, we need to find the matching E event.
+      let matchedEventIndex = -1;
+      let matchingNestedEventCount = 0;
+      let matchingNestedEventIndex = i + 1;
+
+      // We loop through the reversed end events queue from back to front because we still want to
+      // see end events in increasing timestamp order.
+      // While worst case we will loop through all events, the overwhelmingly common case is that
+      // the immediate next event is our event of interest which makes this loop typically O(1).
+      for (let j = taskEndEventsReverseQueue.length - 1; j >= 0; j--) {
+        const endEvent = taskEndEventsReverseQueue[j];
+        // We are considering an end event, so we'll count how many nested events we saw along the way.
+        for (; matchingNestedEventIndex < taskStartEvents.length; matchingNestedEventIndex++) {
+          if (taskStartEvents[matchingNestedEventIndex].ts >= endEvent.ts) break;
+
+          if (taskStartEvents[matchingNestedEventIndex].name === taskStartEvent.name) {
+            matchingNestedEventCount++;
+          }
+        }
+
+        // The event doesn't have a matching name, skip it.
+        if (endEvent.name !== taskStartEvent.name) continue;
+        // The event has a timestamp that is too early, skip it.
+        if (endEvent.ts < taskStartEvent.ts) continue;
+
+        // The event matches our name and happened after start, the last thing to check is if it was for a nested event.
+        if (matchingNestedEventCount > 0) {
+          // If it was for a nested event, decrement our counter and move on.
+          matchingNestedEventCount--;
+          continue;
+        }
+
+        // If it wasn't, we found our matching E event! Mark the index and stop the loop.
+        matchedEventIndex = j;
+        break;
       }
 
-      // Only look at X (Complete), B (Begin), and E (End) events as they have most data
-      if (event.ph !== 'X' && event.ph !== 'B' && event.ph !== 'E') continue;
+      /** @type {Pick<LH.TraceEvent, 'ph'|'ts'>} */
+      let taskEndEvent;
+      if (matchedEventIndex === -1) {
+        // If we couldn't find an end event, we'll assume it's the end of the trace.
+        // If this creates invalid parent/child relationships it will be caught in the next step.
+        taskEndEvent = {ph: 'E', ts: traceEndTs};
+      } else if (matchedEventIndex === taskEndEventsReverseQueue.length - 1) {
+        // Use .pop() in the common case where the immediately next event is needed.
+        // It's ~25x faster, https://jsperf.com/pop-vs-splice.
+        taskEndEvent = /** @type {LH.TraceEvent} */ (taskEndEventsReverseQueue.pop());
+      } else {
+        taskEndEvent = taskEndEventsReverseQueue.splice(matchedEventIndex, 1)[0];
+      }
 
-      // Update currentTask based on the elapsed time.
-      // The next event may be after currentTask has ended.
+      tasks.push(MainThreadTasks._createNewTaskNode(taskStartEvent, taskEndEvent));
+    }
+
+    if (taskEndEventsReverseQueue.length) {
+      throw new Error(
+        `Fatal trace logic error - ${taskEndEventsReverseQueue.length} unmatched end events`
+      );
+    }
+
+    return tasks;
+  }
+
+  /**
+   * This function iterates through the tasks to set the `.parent`/`.children` properties of tasks
+   * according to their implied nesting structure. If any of these relationships seem impossible based on
+   * the timestamps, this method will throw.
+   *
+   * @param {TaskNode[]} sortedTasks
+   * @param {LH.TraceEvent[]} timerInstallEvents
+   * @param {PriorTaskData} priorTaskData
+   */
+  static _createTaskRelationships(sortedTasks, timerInstallEvents, priorTaskData) {
+    /** @type {TaskNode|undefined} */
+    let currentTask;
+    // Create a reversed copy of the array to avoid copying the rest of the queue on every mutation.
+    const timerInstallEventsReverseQueue = timerInstallEvents.slice().reverse();
+
+    for (let i = 0; i < sortedTasks.length; i++) {
+      const nextTask = sortedTasks[i];
+
+      // This inner loop updates what our `currentTask` is at `nextTask.startTime - ε`.
+      // While `nextTask` starts after our `currentTask`, close out the task, popup to the parent, and repeat.
+      // If at the end `currentTask` is undefined, then `nextTask` is a toplevel task.
+      // Otherwise, `nextTask` is a child of `currentTask`.
       while (
         currentTask &&
         Number.isFinite(currentTask.endTime) &&
-        currentTask.endTime <= event.ts
+        currentTask.endTime <= nextTask.startTime
       ) {
+        MainThreadTasks._assignAllTimersUntilTs(
+          currentTask,
+          currentTask.endTime,
+          priorTaskData,
+          timerInstallEventsReverseQueue
+        );
         currentTask = currentTask.parent;
       }
 
-      // If we don't have a current task, start a new one.
-      if (!currentTask) {
-        // We can't start a task with an end event
-        if (event.ph === 'E') {
-          throw new Error('Fatal trace logic error - unexpected end event');
+      // If there's a `currentTask`, `nextTask` must be a child.
+      // Set the `.parent`/`.children` relationships and timer bookkeeping accordingly.
+      if (currentTask) {
+        if (nextTask.endTime > currentTask.endTime) {
+          const timeDelta = nextTask.endTime - currentTask.endTime;
+          // The child task is taking longer than the parent task, which should be impossible.
+          //    If it's less than 1ms, we'll let it slide by increasing the duration of the parent.
+          //    If it's more, throw an error.
+          if (timeDelta < 1000) {
+            currentTask.endTime = nextTask.endTime;
+            currentTask.duration += timeDelta;
+          } else {
+            // If we fell into this error, it's usually because of one of three reasons.
+            //    - We were missing an E event for a child task and we assumed the child ended at the end of the trace.
+            //    - There was slop in the opposite direction (child started 1ms before parent) and the child was assumed to be parent instead.
+            //    - The child timestamp ended more than 1ms after tha parent.
+            // These have more complicated fixes, so handling separately https://github.com/GoogleChrome/lighthouse/pull/9491#discussion_r327331204.
+            throw new Error('Fatal trace logic error - child cannot end after parent');
+          }
         }
 
-        currentTask = MainThreadTasks._createNewTaskNode(event);
-        tasks.push(currentTask);
-
-        continue;
+        nextTask.parent = currentTask;
+        currentTask.children.push(nextTask);
+        MainThreadTasks._assignAllTimersUntilTs(
+          currentTask,
+          nextTask.startTime,
+          priorTaskData,
+          timerInstallEventsReverseQueue
+        );
       }
 
-      if (event.ph === 'X' || event.ph === 'B') {
-        // We're starting a nested event, create it as a child and make it the currentTask
-        const newTask = MainThreadTasks._createNewTaskNode(event, currentTask);
-        tasks.push(newTask);
-        currentTask = newTask;
-      } else {
-        if (currentTask.event.ph !== 'B') {
-          throw new Error(
-            `Fatal trace logic error - expected start event, got ${currentTask.event.ph}`);
-        }
+      currentTask = nextTask;
+    }
 
-        // We're ending an event, update the end time and the currentTask to its parent
-        currentTask.endTime = event.ts;
-        currentTask = currentTask.parent;
-      }
+    if (currentTask) {
+      MainThreadTasks._assignAllTimersUntilTs(
+        currentTask,
+        currentTask.endTime,
+        priorTaskData,
+        timerInstallEventsReverseQueue
+      );
     }
+  }
 
-    // Starting from the last and bottom-most task, we finish any tasks that didn't end yet.
-    while (currentTask && !Number.isFinite(currentTask.endTime)) {
-      // The last event didn't finish before tracing stopped, use traceEnd timestamp instead.
-      currentTask.endTime = traceEndTs;
-      currentTask = currentTask.parent;
+  /**
+   * This function takes the raw trace events sorted in increasing timestamp order and outputs connected task nodes.
+   * To create the task heirarchy we make several passes over the events.
+   *
+   *    1. Create three arrays of X/B events, E events, and TimerInstall events.
+   *    2. Create tasks for each X/B event, throwing if a matching E event cannot be found for a given B.
+   *    3. Sort the tasks by ↑ startTime, ↓ duration.
+   *    4. Match each task to its parent, throwing if there is any invalid overlap between tasks.
+   *
+   * @param {LH.TraceEvent[]} mainThreadEvents
+   * @param {PriorTaskData} priorTaskData
+   * @param {number} traceEndTs
+   * @return {TaskNode[]}
+   */
+  static _createTasksFromEvents(mainThreadEvents, priorTaskData, traceEndTs) {
+    /** @type {Array<LH.TraceEvent>} */
+    const taskStartEvents = [];
+    /** @type {Array<LH.TraceEvent>} */
+    const taskEndEvents = [];
+    /** @type {Array<LH.TraceEvent>} */
+    const timerInstallEvents = [];
+
+    // Phase 1 - Create three arrays of X/B events, E events, and TimerInstall events.
+    for (const event of mainThreadEvents) {
+      if (event.ph === 'X' || event.ph === 'B') taskStartEvents.push(event);
+      if (event.ph === 'E') taskEndEvents.push(event);
+      if (event.name === 'TimerInstall') timerInstallEvents.push(event);
     }
 
-    // At this point we expect all tasks to have a finite startTime and endTime.
-    return tasks;
+    // Phase 2 - Create tasks for each taskStartEvent.
+    const tasks = MainThreadTasks._createTasksFromStartAndEndEvents(
+      taskStartEvents,
+      taskEndEvents,
+      traceEndTs
+    );
+
+    // Phase 3 - Sort the tasks by increasing startTime, decreasing duration.
+    const sortedTasks = tasks.sort(
+      (taskA, taskB) => taskA.startTime - taskB.startTime || taskB.duration - taskA.duration
+    );
+
+    // Phase 4 - Match each task to its parent.
+    MainThreadTasks._createTaskRelationships(sortedTasks, timerInstallEvents, priorTaskData);
+
+    return sortedTasks;
   }
 
   /**
@@ -157,7 +345,6 @@ class MainThreadTasks {
     const childTime = task.children
       .map(child => MainThreadTasks._computeRecursiveSelfTime(child, task))
       .reduce((sum, child) => sum + child, 0);
-    task.duration = task.endTime - task.startTime;
     task.selfTime = task.duration - childTime;
     return task.duration;
   }
@@ -213,7 +400,8 @@ class MainThreadTasks {
 
     task.attributableURLs = attributableURLs;
     task.children.forEach(child =>
-      MainThreadTasks._computeRecursiveAttributableURLs(child, attributableURLs, priorTaskData));
+      MainThreadTasks._computeRecursiveAttributableURLs(child, attributableURLs, priorTaskData)
+    );
   }
 
   /**
@@ -227,14 +415,18 @@ class MainThreadTasks {
   }
 
   /**
-   * @param {LH.TraceEvent[]} traceEvents
+   * @param {LH.TraceEvent[]} mainThreadEvents
    * @param {number} traceEndTs
    * @return {TaskNode[]}
    */
-  static getMainThreadTasks(traceEvents, traceEndTs) {
+  static getMainThreadTasks(mainThreadEvents, traceEndTs) {
     const timers = new Map();
     const priorTaskData = {timers};
-    const tasks = MainThreadTasks._createTasksFromEvents(traceEvents, priorTaskData, traceEndTs);
+    const tasks = MainThreadTasks._createTasksFromEvents(
+      mainThreadEvents,
+      priorTaskData,
+      traceEndTs
+    );
 
     // Compute the recursive properties we couldn't compute earlier, starting at the toplevel tasks
     for (const task of tasks) {
diff --git a/lighthouse-core/scripts/dogfood-lhci.sh b/lighthouse-core/scripts/dogfood-lhci.sh
index af064aa99..d07327ede 100755
--- a/lighthouse-core/scripts/dogfood-lhci.sh
+++ b/lighthouse-core/scripts/dogfood-lhci.sh
@@ -1,6 +1,6 @@
 #!/bin/bash
 
-set -euox pipefail
+set -eox pipefail
 
 # This script requires LHCI_CANARY_SERVER_URL and LHCI_CANARY_SERVER_TOKEN variables to be set.
 
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index c20f2fc2b..63e78a7ad 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -80,7 +80,7 @@
             "title": "`<audio>` elements contain a `<track>` element with `[kind=\"captions\"]`"
         },
         "bootup-time": {
-            "description": "Consider reducing the time spent parsing, compiling, and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/bootup).",
+            "description": "Consider reducing the time spent parsing, compiling, and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://web.dev/bootup-time).",
             "details": {
                 "headings": [
                     {
@@ -232,7 +232,7 @@
             "title": "Content is sized correctly for the viewport"
         },
         "critical-request-chains": {
-            "description": "The Critical Request Chains below show you what resources are loaded with a high priority. Consider reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/critical-request-chains).",
+            "description": "The Critical Request Chains below show you what resources are loaded with a high priority. Consider reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load. [Learn more](https://web.dev/critical-request-chains).",
             "details": {
                 "chains": {
                     "75994.33": {
@@ -565,7 +565,7 @@
             "title": "`[id]` attributes on the page are unique"
         },
         "efficient-animated-content": {
-            "description": "Large GIFs are inefficient for delivering animated content. Consider using MPEG4/WebM videos for animations and PNG/WebP for static images instead of GIF to save network bytes. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/replace-animated-gifs-with-video/)",
+            "description": "Large GIFs are inefficient for delivering animated content. Consider using MPEG4/WebM videos for animations and PNG/WebP for static images instead of GIF to save network bytes. [Learn more](https://web.dev/efficient-animated-content)",
             "details": {
                 "headings": [],
                 "items": [],
@@ -630,7 +630,7 @@
             "title": "Browser errors were logged to the console"
         },
         "estimated-input-latency": {
-            "description": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).",
+            "description": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://web.dev/estimated-input-latency).",
             "displayValue": "20\u00a0ms",
             "id": "estimated-input-latency",
             "numericValue": 16.0,
@@ -700,7 +700,7 @@
             "title": "Final Screenshot"
         },
         "first-contentful-paint": {
-            "description": "First Contentful Paint marks the time at which the first text or image is painted. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-contentful-paint).",
+            "description": "First Contentful Paint marks the time at which the first text or image is painted. [Learn more](https://web.dev/first-contentful-paint).",
             "displayValue": "4.0\u00a0s",
             "id": "first-contentful-paint",
             "numericValue": 3969.135,
@@ -709,7 +709,7 @@
             "title": "First Contentful Paint"
         },
         "first-cpu-idle": {
-            "description": "First CPU Idle marks the first time at which the page's main thread is quiet enough to handle input. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-interactive).",
+            "description": "First CPU Idle marks the first time at which the page's main thread is quiet enough to handle input.  [Learn more](https://web.dev/first-cpu-idle).",
             "displayValue": "4.9\u00a0s",
             "id": "first-cpu-idle",
             "numericValue": 4927.278,
@@ -718,7 +718,7 @@
             "title": "First CPU Idle"
         },
         "first-meaningful-paint": {
-            "description": "First Meaningful Paint measures when the primary content of a page is visible. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint).",
+            "description": "First Meaningful Paint measures when the primary content of a page is visible. [Learn more](https://web.dev/first-meaningful-paint).",
             "displayValue": "4.0\u00a0s",
             "id": "first-meaningful-paint",
             "numericValue": 3969.136,
@@ -741,7 +741,7 @@
             "title": "Interactive controls are keyboard focusable"
         },
         "font-display": {
-            "description": "Leverage the font-display CSS feature to ensure text is user-visible while webfonts are loading. [Learn more](https://developers.google.com/web/updates/2016/02/font-display).",
+            "description": "Leverage the font-display CSS feature to ensure text is user-visible while webfonts are loading. [Learn more](https://web.dev/font-display).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -1041,7 +1041,7 @@
             "title": "Web app manifest does not meet the installability requirements"
         },
         "interactive": {
-            "description": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).",
+            "description": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://web.dev/interactive).",
             "displayValue": "4.9\u00a0s",
             "id": "interactive",
             "numericValue": 4927.278,
@@ -1377,7 +1377,7 @@
             "title": "Tasks"
         },
         "mainthread-work-breakdown": {
-            "description": "Consider reducing the time spent parsing, compiling and executing JS. You may find delivering smaller JS payloads helps with this.",
+            "description": "Consider reducing the time spent parsing, compiling and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://web.dev/mainthread-work-breakdown)",
             "details": {
                 "headings": [
                     {
@@ -2009,7 +2009,7 @@
             "title": "Offscreen content is hidden from assistive technology"
         },
         "offscreen-images": {
-            "description": "Consider lazy-loading offscreen and hidden images after all critical resources have finished loading to lower time to interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/offscreen-images).",
+            "description": "Consider lazy-loading offscreen and hidden images after all critical resources have finished loading to lower time to interactive. [Learn more](https://web.dev/offscreen-images).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -2191,7 +2191,7 @@
             "title": "Page transitions don't feel like they block on the network"
         },
         "redirects": {
-            "description": "Redirects introduce additional delays before the page can be loaded. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/redirects).",
+            "description": "Redirects introduce additional delays before the page can be loaded. [Learn more](https://web.dev/redirects).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -2212,7 +2212,7 @@
             "title": "Does not redirect HTTP traffic to HTTPS"
         },
         "render-blocking-resources": {
-            "description": "Resources are blocking the first paint of your page. Consider delivering critical JS/CSS inline and deferring all non-critical JS/styles. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/blocking-resources).",
+            "description": "Resources are blocking the first paint of your page. Consider delivering critical JS/CSS inline and deferring all non-critical JS/styles. [Learn more](https://web.dev/render-blocking-resources).",
             "details": {
                 "headings": [
                     {
@@ -2430,7 +2430,7 @@
             "title": "Does not register a service worker that controls page and `start_url`"
         },
         "speed-index": {
-            "description": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/speed-index).",
+            "description": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more](https://web.dev/speed-index).",
             "displayValue": "4.4\u00a0s",
             "id": "speed-index",
             "numericValue": 4417.0,
@@ -2607,7 +2607,7 @@
             "title": "Third-Party usage"
         },
         "time-to-first-byte": {
-            "description": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/ttfb).",
+            "description": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://web.dev/time-to-first-byte).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -2631,7 +2631,7 @@
             "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).",
+            "description": "Large network payloads cost users real money and are highly correlated with long load times. [Learn more](https://web.dev/total-byte-weight).",
             "details": {
                 "headings": [
                     {
@@ -2697,7 +2697,7 @@
             "title": "Avoids enormous network payloads"
         },
         "unminified-css": {
-            "description": "Minifying CSS files can reduce network payload sizes. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/minify-css).",
+            "description": "Minifying CSS files can reduce network payload sizes. [Learn more](https://web.dev/unminified-css).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -2712,7 +2712,7 @@
             "title": "Minify CSS"
         },
         "unminified-javascript": {
-            "description": "Minifying JavaScript files can reduce payload sizes and script parse time. [Learn more](https://developers.google.com/speed/docs/insights/MinifyResources).",
+            "description": "Minifying JavaScript files can reduce payload sizes and script parse time. [Learn more](https://web.dev/unminified-javascript).",
             "details": {
                 "headings": [
                     {
@@ -2752,7 +2752,7 @@
             "warnings": []
         },
         "unused-css-rules": {
-            "description": "Remove dead rules from stylesheets and defer the loading of CSS not used for above-the-fold content to reduce unnecessary bytes consumed by network activity. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).",
+            "description": "Remove dead rules from stylesheets and defer the loading of CSS not used for above-the-fold content to reduce unnecessary bytes consumed by network activity. [Learn more](https://web.dev/unused-css-rules).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -2774,7 +2774,7 @@
             "title": "HTML5 landmark elements are used to improve navigation"
         },
         "user-timings": {
-            "description": "Consider instrumenting your app with the User Timing API to measure your app's real-world performance during key user experiences. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/user-timing).",
+            "description": "Consider instrumenting your app with the User Timing API to measure your app's real-world performance during key user experiences. [Learn more](https://web.dev/user-timings).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -2871,7 +2871,7 @@
             "title": "Does not use HTTP/2 for all of its resources"
         },
         "uses-long-cache-ttl": {
-            "description": "A long cache lifetime can speed up repeat visits to your page. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/cache-policy).",
+            "description": "A long cache lifetime can speed up repeat visits to your page. [Learn more](https://web.dev/uses-long-cache-ttl).",
             "details": {
                 "headings": [
                     {
@@ -2978,7 +2978,7 @@
             "title": "Serve static assets with an efficient cache policy"
         },
         "uses-optimized-images": {
-            "description": "Optimized images load faster and consume less cellular data. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/optimize-images).",
+            "description": "Optimized images load faster and consume less cellular data. [Learn more](https://web.dev/uses-optimized-images).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -3030,7 +3030,7 @@
             "title": "Does not use passive listeners to improve scrolling performance"
         },
         "uses-rel-preconnect": {
-            "description": "Consider adding `preconnect` or `dns-prefetch` resource hints to establish early connections to important third-party origins. [Learn more](https://developers.google.com/web/fundamentals/performance/resource-prioritization#preconnect).",
+            "description": "Consider adding `preconnect` or `dns-prefetch` resource hints to establish early connections to important third-party origins. [Learn more](https://web.dev/uses-rel-preconnect).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -3045,7 +3045,7 @@
             "warnings": []
         },
         "uses-rel-preload": {
-            "description": "Consider using `<link rel=preload>` to prioritize fetching resources that are currently requested later in page load. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/preload).",
+            "description": "Consider using `<link rel=preload>` to prioritize fetching resources that are currently requested later in page load. [Learn more](https://web.dev/uses-rel-preload).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -3059,7 +3059,7 @@
             "title": "Preload key requests"
         },
         "uses-responsive-images": {
-            "description": "Serve images that are appropriately-sized to save cellular data and improve load time. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/oversized-images).",
+            "description": "Serve images that are appropriately-sized to save cellular data and improve load time. [Learn more](https://web.dev/uses-responsive-images).",
             "details": {
                 "headings": [],
                 "items": [],
@@ -3075,7 +3075,7 @@
             "warnings": []
         },
         "uses-text-compression": {
-            "description": "Text-based resources should be served with compression (gzip, deflate or brotli) to minimize total network bytes. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/text-compression).",
+            "description": "Text-based resources should be served with compression (gzip, deflate or brotli) to minimize total network bytes. [Learn more](https://web.dev/uses-text-compression).",
             "details": {
                 "headings": [
                     {
@@ -3118,7 +3118,7 @@
             "title": "Enable text compression"
         },
         "uses-webp-images": {
-            "description": "Image formats like JPEG 2000, JPEG XR, and WebP often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/webp).",
+            "description": "Image formats like JPEG 2000, JPEG XR, and WebP often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://web.dev/uses-webp-images).",
             "details": {
                 "headings": [
                     {
diff --git a/readme.md b/readme.md
index 179652b2c..925c123e4 100644
--- a/readme.md
+++ b/readme.md
@@ -315,6 +315,7 @@ Other awesome open source projects that use Lighthouse.
 * **[lighthouse-badges](https://github.com/emazzotta/lighthouse-badges)** - Generate gh-badges (shields.io) based on Lighthouse performance.
 * **[lighthouse-batch](https://www.npmjs.com/package/lighthouse-batch)** - Run Lighthouse over a number of sites and generate a summary of their metrics/scores.
 * **[lighthouse-ci](https://github.com/andreasonny83/lighthouse-ci)** - Run Lighthouse and assert scores satisfy your custom thresholds.
+* **[lighthouse-ci-action](https://github.com/treosh/lighthouse-ci-action)** - A Github Action that makes it easy to run Lighthouse in CI and keep your pages small using performance budgets.
 * **[lighthouse-cron](https://github.com/thearegee/lighthouse-cron)** - Cron multiple batch Lighthouse audits and emit results for sending to remote server.
 * **[lighthouse-gh-reporter](https://github.com/carlesnunez/lighthouse-gh-reporter)** - Run Lighthouse in CI and report back in a comment on your pull requests
 * **[lighthouse-hue](https://github.com/ebidel/lighthouse-hue)** - Set the color of Philips Hue lights based on a Lighthouse score
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index 50b188531..762eb9714 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -225,6 +225,8 @@ declare global {
       export interface ScriptElement {
         type: string | null
         src: string | null
+        /** The `id` property of the script element; null if it had no `id` or if `source` is 'network'. */
+        id: string | null
         async: boolean
         defer: boolean
         /** Path that uniquely identifies the node in the DOM */


Test patch:
diff --git a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
index 32e759932..308d9eb04 100644
--- a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
+++ b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
@@ -18,7 +18,7 @@
 
 <!-- AMP-style stylesheet script -->
 <!-- based on https://github.com/ampproject/amphtml/blob/38f1bdf4cc385f9a25cf2979abf215952880b876/src/font-stylesheet-timeout.js#L87-L103 -->
-<script type="text/javascript">
+<script type="text/javascript" id="amp-style-styling-script">
   setTimeout(() => {
     const stylesheet = document.getElementById('amp-style-link');
     stylesheet.media = 'not-matching';
@@ -48,13 +48,13 @@
 <link rel="alternate stylesheet" href="./empty.css">
 
 <!-- FAIL: block rendering -->
-<script src="./dbw_tester.js"></script>
+<script src="./dbw_tester.js" id="dbw-tester-script"></script>
 
 <!-- PASS: 'modules' are deferred by default and don't block rendering -->
 <script type="module" src="./empty_module.js?delay=500"></script>
 
 <!-- FAIL(errors-in-console): exception thrown -->
-<script type="text/javascript">throw new Error('An error');</script>
+<script type="text/javascript" id="error-time">throw new Error('A distinctive error');</script>
 
 <!-- Add non-functional script to appear to be a Wordpress page for Stack Packs. -->
 <script type="example" src="fake-script-wp-includes"></script>
diff --git a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
index c6a86c2d9..d04756bba 100644
--- a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
+++ b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
@@ -109,7 +109,7 @@ module.exports = [
               },
               {
                 source: 'Runtime.exception',
-                description: 'Error: An error\n    at http://localhost:10200/dobetterweb/dbw_tester.html:57:38',
+                description: /^Error: A distinctive error\s+at http:\/\/localhost:10200\/dobetterweb\/dbw_tester.html:\d+:\d+$/,
                 url: 'http://localhost:10200/dobetterweb/dbw_tester.html',
               },
             ],
diff --git a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
index b6656de78..103ee8187 100644
--- a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
@@ -77,6 +77,12 @@ describe('Byte efficiency base audit', () => {
       const result = estimate({transferSize: 1000, resourceType}, 100);
       assert.equal(result, 100);
     });
+
+    it('should not error when resource size is 0', () => {
+      const resourceType = 'Other';
+      const result = estimate({transferSize: 1000, resourceSize: 0, resourceType}, 100);
+      assert.equal(result, 100);
+    });
   });
 
   it('should format details', () => {
diff --git a/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js b/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
index ff3c7c236..be8b64b28 100644
--- a/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
@@ -122,6 +122,7 @@ describe('Best Practices: unused css rules audit', () => {
       {
         url: 'file://a.css',
         transferSize: 100 * 1024,
+        resourceSize: 100 * 1024,
         resourceType: 'Stylesheet',
       },
     ];
@@ -191,6 +192,36 @@ describe('Best Practices: unused css rules audit', () => {
       });
     });
 
+    it('handles phantom network records without size data', async () => {
+      const result = await UnusedCSSAudit.audit_(getArtifacts({
+        CSSUsage: {rules: [
+          {styleSheetId: 'a', used: true, startOffset: 0, endOffset: 60000}, // 40000 * 3 * 50% = 60000
+        ], stylesheets: [
+          {
+            header: {styleSheetId: 'a', sourceURL: 'file://a.html'},
+            content: `${generate('123', 40000)}`, // stylesheet size of 40000 * 3 uncompressed bytes
+          },
+        ]},
+      }), [
+        {
+          url: 'file://a.html',
+          transferSize: 100 * 1024 * 0.5, // compression ratio of 0.5
+          resourceSize: 100 * 1024,
+          resourceType: 'Document', // this is a document so it'll use the compressionRatio but not the raw size
+        },
+        {
+          url: 'file://a.html',
+          transferSize: 0,
+          resourceSize: 0,
+          resourceType: 'Document',
+        },
+      ]);
+
+      expect(result.items).toMatchObject([
+        {totalBytes: 40000 * 3 * 0.5, wastedPercent: 50},
+      ]);
+    });
+
     it('does not include empty or small sheets', () => {
       return UnusedCSSAudit.audit_(getArtifacts({
         CSSUsage: {rules: [
diff --git a/lighthouse-core/test/lib/network-recorder-test.js b/lighthouse-core/test/lib/network-recorder-test.js
index 68c187ab0..bb318434e 100644
--- a/lighthouse-core/test/lib/network-recorder-test.js
+++ b/lighthouse-core/test/lib/network-recorder-test.js
@@ -313,9 +313,7 @@ describe('network recorder', function() {
       ];
 
       const periods = NetworkRecorder.findNetworkQuietPeriods(records, 0);
-      assert.deepStrictEqual(periods, [
-        {start: 2000, end: Infinity},
-      ]);
+      assert.deepStrictEqual(periods, []);
     });
   });
 });
diff --git a/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js b/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
index a4346ebc6..a4a28283c 100644
--- a/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
+++ b/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
@@ -11,6 +11,7 @@ const MainThreadTasks = require('../../../lib/tracehouse/main-thread-tasks.js');
 const TraceProcessor = require('../../../lib/tracehouse/trace-processor.js');
 const taskGroups = require('../../../lib/tracehouse/task-groups.js').taskGroups;
 const pwaTrace = require('../../fixtures/traces/progressive-app.json');
+const noTracingStartedTrace = require('../../fixtures/traces/no-tracingstarted-m74.json');
 const TracingProcessor = require('../../../lib/tracehouse/trace-processor.js');
 const assert = require('assert');
 
@@ -59,6 +60,11 @@ describe('Main Thread Tasks', () => {
     assert.equal(Math.round(totalTime), 396);
   });
 
+  it('should handle slightly trace events that slightly overlap', () => {
+    const tasks = run(noTracingStartedTrace);
+    expect(tasks).toHaveLength(425);
+  });
+
   it('should compute parent/child correctly', () => {
     /*
     An artistic rendering of the below trace:
@@ -77,12 +83,12 @@ describe('Main Thread Tasks', () => {
     traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
 
     const tasks = run({traceEvents});
-    assert.equal(tasks.length, 3);
+    expect(tasks).toHaveLength(3);
 
     const taskA = tasks.find(task => task.event.name === 'TaskA');
     const taskB = tasks.find(task => task.event.name === 'TaskB');
     const taskC = tasks.find(task => task.event.name === 'TaskC');
-    assert.deepStrictEqual(taskA, {
+    expect(taskA).toEqual({
       parent: undefined,
       attributableURLs: [],
 
@@ -95,7 +101,7 @@ describe('Main Thread Tasks', () => {
       group: taskGroups.other,
     });
 
-    assert.deepStrictEqual(taskB, {
+    expect(taskB).toEqual({
       parent: taskA,
       attributableURLs: [],
 
@@ -147,11 +153,54 @@ describe('Main Thread Tasks', () => {
     assert.deepStrictEqual(taskD.attributableURLs, ['urlB.1', 'urlB.2', 'urlC', 'urlD']);
   });
 
+  it('should compute attributableURLs correctly across timers', () => {
+    const baseTs = 1241250325;
+    const url = s => ({args: {data: {url: s}}});
+    const stackFrames = f => ({args: {data: {stackTrace: f.map(url => ({url}))}}});
+    const timerId = id => ({args: {data: {timerId: id}}});
+
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA██████████████████████████████████████████████
+          ████████████████TaskB███████████████████                  █Timer Fire█
+               ████EvaluateScript██████                               █TaskE█
+                   | <-- Timer Install
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, ...url('about:blank')},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, ...stackFrames(['urlB.1', 'urlB.2'])},
+      {ph: 'X', name: 'EvaluateScript', pid, tid, ts: baseTs + 10e3, dur: 30e3, ...url('urlC')},
+      {ph: 'I', name: 'TimerInstall', pid, tid, ts: baseTs + 15e3, ...timerId(1)},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 55e3},
+      {ph: 'X', name: 'TimerFire', pid, tid, ts: baseTs + 75e3, dur: 10e3, ...timerId(1)},
+      {ph: 'X', name: 'TaskE', pid, tid, ts: baseTs + 80e3, dur: 5e3, ...stackFrames(['urlD'])},
+    ];
+
+    traceEvents.forEach(evt => {
+      evt.cat = 'devtools.timeline';
+      evt.args = evt.args || args;
+    });
+
+    const tasks = run({traceEvents});
+    const taskA = tasks.find(task => task.event.name === 'TaskA');
+    const taskB = tasks.find(task => task.event.name === 'TaskB');
+    const taskC = tasks.find(task => task.event.name === 'EvaluateScript');
+    const taskD = tasks.find(task => task.event.name === 'TimerFire');
+    const taskE = tasks.find(task => task.event.name === 'TaskE');
+
+    expect(taskA.attributableURLs).toEqual([]);
+    expect(taskB.attributableURLs).toEqual(['urlB.1', 'urlB.2']);
+    expect(taskC.attributableURLs).toEqual(['urlB.1', 'urlB.2', 'urlC']);
+    expect(taskD.attributableURLs).toEqual(['urlB.1', 'urlB.2', 'urlC']);
+    expect(taskE.attributableURLs).toEqual(['urlB.1', 'urlB.2', 'urlC', 'urlD']);
+  });
+
   it('should handle the last trace event not ending', () => {
     /*
     An artistic rendering of the below trace:
     █████████████████████████████TaskA████████████|
-          ████████████████TaskB███████████████████|
+      ████████████████████TaskB███████████████████|
                                             █TaskC|
                                                   ^ trace abruptly ended
     */
@@ -161,6 +210,7 @@ describe('Main Thread Tasks', () => {
       {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
       {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
       {ph: 'B', name: 'TaskC', pid, tid, ts: baseTs + 100e3, args},
+      {ph: 'I', name: 'MarkerToPushOutTraceEnd', pid, tid, ts: baseTs + 110e3, args},
     ];
 
     traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
@@ -178,8 +228,8 @@ describe('Main Thread Tasks', () => {
       children: [taskB],
       event: traceEvents[3],
       startTime: 0,
-      endTime: 100,
-      duration: 100,
+      endTime: 110,
+      duration: 110,
       selfTime: 5,
       group: taskGroups.other,
     });
@@ -191,14 +241,22 @@ describe('Main Thread Tasks', () => {
       children: [taskC],
       event: traceEvents[4],
       startTime: 5,
-      endTime: 100,
-      duration: 95,
+      endTime: 110,
+      duration: 105,
       selfTime: 95,
       group: taskGroups.other,
     });
   });
 
   it('should handle nested events *starting* at the same timestamp correctly', () => {
+    /*
+    An artistic rendering of the below trace:
+    █████████████TaskA█████████████
+    ███████TaskB██████
+    █TaskC█
+                                   █████████████TaskD█████████████
+
+    */
     const traceEvents = [
       ...boilerplateTrace,
       {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs, args},
@@ -221,6 +279,13 @@ describe('Main Thread Tasks', () => {
   });
 
   it('should handle nested events *ending* at the same timestamp correctly', () => {
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA████████████|
+      ████████████████████TaskB███████████████████|
+                                            █TaskC|
+                                                  ^ trace abruptly ended
+    */
     const traceEvents = [
       ...boilerplateTrace,
       {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
@@ -268,6 +333,141 @@ describe('Main Thread Tasks', () => {
     ]);
   });
 
+  it('should handle incorrectly sorted events at task start', () => {
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA██████████████████████████████████████████████
+    █████████████████TaskB███████████████████
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs, args},
+      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 50e3, args},
+    ];
+
+    traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
+
+    const tasks = run({traceEvents});
+    const [taskA, taskB] = tasks;
+    expect(tasks).toEqual([
+      {
+        parent: undefined,
+        attributableURLs: [],
+
+        children: [taskB],
+        event: traceEvents.find(event => event.name === 'TaskA'),
+        startTime: 0,
+        endTime: 100,
+        duration: 100,
+        selfTime: 50,
+        group: taskGroups.other,
+      },
+      {
+        parent: taskA,
+        attributableURLs: [],
+
+        children: [],
+        event: traceEvents.find(event => event.name === 'TaskB'),
+        startTime: 0,
+        endTime: 50,
+        duration: 50,
+        selfTime: 50,
+        group: taskGroups.other,
+      },
+    ]);
+  });
+
+  it('should handle out-of-order 0 duration tasks', () => {
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA██████████████████|█TaskB█  <-- duration of 0
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
+      {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs + 100e3, args},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 100e3, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 100e3, args},
+    ];
+
+    traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
+
+    const tasks = run({traceEvents});
+    expect(tasks).toEqual([
+      {
+        parent: undefined,
+        attributableURLs: [],
+
+        children: [],
+        event: traceEvents.find(event => event.name === 'TaskA'),
+        startTime: 0,
+        endTime: 100,
+        duration: 100,
+        selfTime: 100,
+        group: taskGroups.other,
+      },
+      {
+        parent: undefined,
+        attributableURLs: [],
+
+        children: [],
+        event: traceEvents.find(event => event.name === 'TaskB' && event.ph === 'B'),
+        startTime: 100,
+        endTime: 100,
+        duration: 0,
+        selfTime: 0,
+        group: taskGroups.other,
+      },
+    ]);
+  });
+
+  it('should handle child events that extend <1ms beyond parent event', () => {
+    /*
+    An artistic rendering of the below trace:
+    ████████████████TaskA██████████████████
+            █████████TaskB██████████████████
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 25e3, args},
+      {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs + 100e3 - 50, args}, // this is invalid, but happens in practice
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 100e3, args},
+    ];
+
+    traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
+
+    const tasks = run({traceEvents});
+    const [taskA, taskB] = tasks;
+    expect(tasks).toEqual([
+      {
+        parent: undefined,
+        attributableURLs: [],
+
+        children: [taskB],
+        event: traceEvents.find(event => event.name === 'TaskA'),
+        startTime: 0,
+        endTime: 100,
+        duration: 100,
+        selfTime: 25,
+        group: taskGroups.other,
+      },
+      {
+        parent: taskA,
+        attributableURLs: [],
+
+        children: [],
+        event: traceEvents.find(event => event.name === 'TaskB' && event.ph === 'B'),
+        startTime: 25,
+        endTime: 100,
+        duration: 75,
+        selfTime: 75,
+        group: taskGroups.other,
+      },
+    ]);
+  });
+
   const invalidEventSets = [
     [
       // TaskA overlaps with TaskB, X first
@@ -281,6 +481,13 @@ describe('Main Thread Tasks', () => {
       {ph: 'X', name: 'TaskB', pid, tid, ts: baseTs + 5e3, dur: 100e3, args},
       {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs + 90e3, args},
     ],
+    [
+      // TaskA overlaps with TaskB, both B/E
+      {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
+      {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs + 90e3, args},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 95e3, args},
+    ],
     [
       // TaskA is missing a B event
       {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs, args},
@@ -292,6 +499,12 @@ describe('Main Thread Tasks', () => {
       {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
       {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 10e3, args},
     ],
+    [
+      {ph: 'I', name: 'MarkerToPushOutTraceEnd', pid, tid, ts: baseTs + 110e3, args},
+      // TaskB is missing an E event within an X
+      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 10e3, args},
+    ],
   ];
 
   for (const invalidEvents of invalidEventSets) {
diff --git a/lighthouse-core/test/report/html/renderer/report-renderer-test.js b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
index 85ac26f97..821e65aa4 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -220,15 +220,16 @@ describe('ReportRenderer', () => {
     const container = renderer._dom._document.body;
     const output = renderer.renderReport(sampleResults, container);
 
+    const DOCS_ORIGINS = ['https://developers.google.com', 'https://web.dev'];
     const utmChannels = [...output.querySelectorAll('a[href*="utm_source=lighthouse"')]
       .map(a => new URL(a.href))
-      .filter(url => url.origin === 'https://developers.google.com')
+      .filter(url => DOCS_ORIGINS.includes(url.origin))
       .map(url => url.searchParams.get('utm_medium'));
 
-    assert.ok(utmChannels.length > 20);
-    utmChannels.forEach(anchorChannel => {
-      assert.strictEqual(anchorChannel, lhrChannel);
-    });
+    assert.ok(utmChannels.length > 100);
+    for (const utmChannel of utmChannels) {
+      assert.strictEqual(utmChannel, lhrChannel);
+    }
   });
 
   it('renders `not_applicable` audits as `notApplicable`', () => {
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 7c3f04e18..c6d9e7eca 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -243,6 +243,7 @@
     {
       "type": "text/javascript",
       "src": null,
+      "id": "amp-style-styling-script",
       "async": false,
       "defer": false,
       "source": "head",
@@ -253,6 +254,7 @@
     {
       "type": null,
       "src": "http://localhost:10200/dobetterweb/dbw_tester.js",
+      "id": "dbw-tester-script",
       "async": false,
       "defer": false,
       "source": "head",
@@ -263,6 +265,7 @@
     {
       "type": "module",
       "src": "http://localhost:10200/dobetterweb/empty_module.js?delay=500",
+      "id": null,
       "async": false,
       "defer": false,
       "source": "head",
@@ -273,6 +276,7 @@
     {
       "type": "text/javascript",
       "src": null,
+      "id": "error-time",
       "async": false,
       "defer": false,
       "source": "head",
@@ -283,6 +287,7 @@
     {
       "type": null,
       "src": "http://localhost:10200/dobetterweb/fcp-delayer.js?delay=5000",
+      "id": null,
       "async": false,
       "defer": false,
       "source": "head",
@@ -293,6 +298,7 @@
     {
       "type": null,
       "src": null,
+      "id": null,
       "async": false,
       "defer": false,
       "source": "body",
@@ -303,6 +309,7 @@
     {
       "type": null,
       "src": null,
+      "id": null,
       "async": false,
       "defer": false,
       "source": "body",
@@ -313,6 +320,7 @@
     {
       "type": null,
       "src": null,
+      "id": null,
       "async": false,
       "defer": false,
       "source": "body",
@@ -323,6 +331,7 @@
     {
       "type": null,
       "src": "http://localhost:10200/zone.js",
+      "id": null,
       "async": false,
       "defer": false,
       "source": "body",
@@ -333,6 +342,7 @@
     {
       "type": null,
       "src": "http://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js",
+      "id": null,
       "async": false,
       "defer": false,
       "source": "body",
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 3ea69984e..4ae841dec 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -74,7 +74,7 @@
     "first-contentful-paint": {
       "id": "first-contentful-paint",
       "title": "First Contentful Paint",
-      "description": "First Contentful Paint marks the time at which the first text or image is painted. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-contentful-paint).",
+      "description": "First Contentful Paint marks the time at which the first text or image is painted. [Learn more](https://web.dev/first-contentful-paint).",
       "score": 0.51,
       "scoreDisplayMode": "numeric",
       "numericValue": 3969.135,
@@ -83,7 +83,7 @@
     "first-meaningful-paint": {
       "id": "first-meaningful-paint",
       "title": "First Meaningful Paint",
-      "description": "First Meaningful Paint measures when the primary content of a page is visible. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint).",
+      "description": "First Meaningful Paint measures when the primary content of a page is visible. [Learn more](https://web.dev/first-meaningful-paint).",
       "score": 0.51,
       "scoreDisplayMode": "numeric",
       "numericValue": 3969.136,
@@ -100,7 +100,7 @@
     "speed-index": {
       "id": "speed-index",
       "title": "Speed Index",
-      "description": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/speed-index).",
+      "description": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more](https://web.dev/speed-index).",
       "score": 0.74,
       "scoreDisplayMode": "numeric",
       "numericValue": 4417,
@@ -185,7 +185,7 @@
     "estimated-input-latency": {
       "id": "estimated-input-latency",
       "title": "Estimated Input Latency",
-      "description": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).",
+      "description": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://web.dev/estimated-input-latency).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 16,
@@ -262,7 +262,7 @@
     "time-to-first-byte": {
       "id": "time-to-first-byte",
       "title": "Server response times are low (TTFB)",
-      "description": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/ttfb).",
+      "description": "Time To First Byte identifies the time at which your server sends a response. [Learn more](https://web.dev/time-to-first-byte).",
       "score": 1,
       "scoreDisplayMode": "binary",
       "numericValue": 570.5630000000001,
@@ -277,7 +277,7 @@
     "first-cpu-idle": {
       "id": "first-cpu-idle",
       "title": "First CPU Idle",
-      "description": "First CPU Idle marks the first time at which the page's main thread is quiet enough to handle input. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-interactive).",
+      "description": "First CPU Idle marks the first time at which the page's main thread is quiet enough to handle input.  [Learn more](https://web.dev/first-cpu-idle).",
       "score": 0.72,
       "scoreDisplayMode": "numeric",
       "numericValue": 4927.278,
@@ -286,7 +286,7 @@
     "interactive": {
       "id": "interactive",
       "title": "Time to Interactive",
-      "description": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).",
+      "description": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://web.dev/interactive).",
       "score": 0.78,
       "scoreDisplayMode": "numeric",
       "numericValue": 4927.278,
@@ -295,7 +295,7 @@
     "user-timings": {
       "id": "user-timings",
       "title": "User Timing marks and measures",
-      "description": "Consider instrumenting your app with the User Timing API to measure your app's real-world performance during key user experiences. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/user-timing).",
+      "description": "Consider instrumenting your app with the User Timing API to measure your app's real-world performance during key user experiences. [Learn more](https://web.dev/user-timings).",
       "score": null,
       "scoreDisplayMode": "notApplicable",
       "details": {
@@ -307,7 +307,7 @@
     "critical-request-chains": {
       "id": "critical-request-chains",
       "title": "Minimize Critical Requests Depth",
-      "description": "The Critical Request Chains below show you what resources are loaded with a high priority. Consider reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/critical-request-chains).",
+      "description": "The Critical Request Chains below show you what resources are loaded with a high priority. Consider reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load. [Learn more](https://web.dev/critical-request-chains).",
       "score": null,
       "scoreDisplayMode": "informative",
       "displayValue": "12 chains found",
@@ -453,7 +453,7 @@
     "redirects": {
       "id": "redirects",
       "title": "Avoid multiple page redirects",
-      "description": "Redirects introduce additional delays before the page can be loaded. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/redirects).",
+      "description": "Redirects introduce additional delays before the page can be loaded. [Learn more](https://web.dev/redirects).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
@@ -634,7 +634,7 @@
     "mainthread-work-breakdown": {
       "id": "mainthread-work-breakdown",
       "title": "Minimizes main-thread work",
-      "description": "Consider reducing the time spent parsing, compiling and executing JS. You may find delivering smaller JS payloads helps with this.",
+      "description": "Consider reducing the time spent parsing, compiling and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://web.dev/mainthread-work-breakdown)",
       "score": 0.96,
       "scoreDisplayMode": "numeric",
       "numericValue": 1548.5690000000002,
@@ -696,7 +696,7 @@
     "bootup-time": {
       "id": "bootup-time",
       "title": "JavaScript execution time",
-      "description": "Consider reducing the time spent parsing, compiling, and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/bootup).",
+      "description": "Consider reducing the time spent parsing, compiling, and executing JS. You may find delivering smaller JS payloads helps with this. [Learn more](https://web.dev/bootup-time).",
       "score": 0.92,
       "scoreDisplayMode": "numeric",
       "numericValue": 1150.573,
@@ -762,7 +762,7 @@
     "uses-rel-preload": {
       "id": "uses-rel-preload",
       "title": "Preload key requests",
-      "description": "Consider using `<link rel=preload>` to prioritize fetching resources that are currently requested later in page load. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/preload).",
+      "description": "Consider using `<link rel=preload>` to prioritize fetching resources that are currently requested later in page load. [Learn more](https://web.dev/uses-rel-preload).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
@@ -777,7 +777,7 @@
     "uses-rel-preconnect": {
       "id": "uses-rel-preconnect",
       "title": "Preconnect to required origins",
-      "description": "Consider adding `preconnect` or `dns-prefetch` resource hints to establish early connections to important third-party origins. [Learn more](https://developers.google.com/web/fundamentals/performance/resource-prioritization#preconnect).",
+      "description": "Consider adding `preconnect` or `dns-prefetch` resource hints to establish early connections to important third-party origins. [Learn more](https://web.dev/uses-rel-preconnect).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
@@ -793,7 +793,7 @@
     "font-display": {
       "id": "font-display",
       "title": "All text remains visible during webfont loads",
-      "description": "Leverage the font-display CSS feature to ensure text is user-visible while webfonts are loading. [Learn more](https://developers.google.com/web/updates/2016/02/font-display).",
+      "description": "Leverage the font-display CSS feature to ensure text is user-visible while webfonts are loading. [Learn more](https://web.dev/font-display).",
       "score": 1,
       "scoreDisplayMode": "binary",
       "warnings": [],
@@ -2172,7 +2172,7 @@
     "uses-long-cache-ttl": {
       "id": "uses-long-cache-ttl",
       "title": "Serve static assets with an efficient cache policy",
-      "description": "A long cache lifetime can speed up repeat visits to your page. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/cache-policy).",
+      "description": "A long cache lifetime can speed up repeat visits to your page. [Learn more](https://web.dev/uses-long-cache-ttl).",
       "score": 0.58,
       "scoreDisplayMode": "numeric",
       "numericValue": 103455,
@@ -2279,7 +2279,7 @@
     "total-byte-weight": {
       "id": "total-byte-weight",
       "title": "Avoids enormous network payloads",
-      "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).",
+      "description": "Large network payloads cost users real money and are highly correlated with long load times. [Learn more](https://web.dev/total-byte-weight).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 160738,
@@ -2345,7 +2345,7 @@
     "offscreen-images": {
       "id": "offscreen-images",
       "title": "Defer offscreen images",
-      "description": "Consider lazy-loading offscreen and hidden images after all critical resources have finished loading to lower time to interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/offscreen-images).",
+      "description": "Consider lazy-loading offscreen and hidden images after all critical resources have finished loading to lower time to interactive. [Learn more](https://web.dev/offscreen-images).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
@@ -2362,7 +2362,7 @@
     "render-blocking-resources": {
       "id": "render-blocking-resources",
       "title": "Eliminate render-blocking resources",
-      "description": "Resources are blocking the first paint of your page. Consider delivering critical JS/CSS inline and deferring all non-critical JS/styles. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/blocking-resources).",
+      "description": "Resources are blocking the first paint of your page. Consider delivering critical JS/CSS inline and deferring all non-critical JS/styles. [Learn more](https://web.dev/render-blocking-resources).",
       "score": 0.46,
       "scoreDisplayMode": "numeric",
       "numericValue": 1129,
@@ -2419,7 +2419,7 @@
     "unminified-css": {
       "id": "unminified-css",
       "title": "Minify CSS",
-      "description": "Minifying CSS files can reduce network payload sizes. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/minify-css).",
+      "description": "Minifying CSS files can reduce network payload sizes. [Learn more](https://web.dev/unminified-css).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
@@ -2435,7 +2435,7 @@
     "unminified-javascript": {
       "id": "unminified-javascript",
       "title": "Minify JavaScript",
-      "description": "Minifying JavaScript files can reduce payload sizes and script parse time. [Learn more](https://developers.google.com/speed/docs/insights/MinifyResources).",
+      "description": "Minifying JavaScript files can reduce payload sizes and script parse time. [Learn more](https://web.dev/unminified-javascript).",
       "score": 0.88,
       "scoreDisplayMode": "numeric",
       "numericValue": 150,
@@ -2475,7 +2475,7 @@
     "unused-css-rules": {
       "id": "unused-css-rules",
       "title": "Remove unused CSS",
-      "description": "Remove dead rules from stylesheets and defer the loading of CSS not used for above-the-fold content to reduce unnecessary bytes consumed by network activity. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/unused-css).",
+      "description": "Remove dead rules from stylesheets and defer the loading of CSS not used for above-the-fold content to reduce unnecessary bytes consumed by network activity. [Learn more](https://web.dev/unused-css-rules).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
@@ -2491,7 +2491,7 @@
     "uses-webp-images": {
       "id": "uses-webp-images",
       "title": "Serve images in next-gen formats",
-      "description": "Image formats like JPEG 2000, JPEG XR, and WebP often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/webp).",
+      "description": "Image formats like JPEG 2000, JPEG XR, and WebP often provide better compression than PNG or JPEG, which means faster downloads and less data consumption. [Learn more](https://web.dev/uses-webp-images).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
@@ -2537,7 +2537,7 @@
     "uses-optimized-images": {
       "id": "uses-optimized-images",
       "title": "Efficiently encode images",
-      "description": "Optimized images load faster and consume less cellular data. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/optimize-images).",
+      "description": "Optimized images load faster and consume less cellular data. [Learn more](https://web.dev/uses-optimized-images).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
@@ -2554,7 +2554,7 @@
     "uses-text-compression": {
       "id": "uses-text-compression",
       "title": "Enable text compression",
-      "description": "Text-based resources should be served with compression (gzip, deflate or brotli) to minimize total network bytes. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/text-compression).",
+      "description": "Text-based resources should be served with compression (gzip, deflate or brotli) to minimize total network bytes. [Learn more](https://web.dev/uses-text-compression).",
       "score": 0.75,
       "scoreDisplayMode": "numeric",
       "numericValue": 300,
@@ -2597,7 +2597,7 @@
     "uses-responsive-images": {
       "id": "uses-responsive-images",
       "title": "Properly size images",
-      "description": "Serve images that are appropriately-sized to save cellular data and improve load time. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/oversized-images).",
+      "description": "Serve images that are appropriately-sized to save cellular data and improve load time. [Learn more](https://web.dev/uses-responsive-images).",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
@@ -2614,7 +2614,7 @@
     "efficient-animated-content": {
       "id": "efficient-animated-content",
       "title": "Use video formats for animated content",
-      "description": "Large GIFs are inefficient for delivering animated content. Consider using MPEG4/WebM videos for animations and PNG/WebP for static images instead of GIF to save network bytes. [Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/replace-animated-gifs-with-video/)",
+      "description": "Large GIFs are inefficient for delivering animated content. Consider using MPEG4/WebM videos for animations and PNG/WebP for static images instead of GIF to save network bytes. [Learn more](https://web.dev/efficient-animated-content)",
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,