Repository: GoogleChrome/lighthouse (js)

Issue:
Create a Performance audit for avoiding the unload event
**Feature request summary**

Warn web developers that they should avoid `unload` events and use `visibilitychange` instead.

I am willing to work on this. Seems like a relatively easy audit to implement.

**What is the motivation or use case for changing this?**

https://developers.google.com/web/updates/2018/07/page-lifecycle-api#legacy-lifecycle-apis-to-avoid

**How is this beneficial to Lighthouse?**

It's an easy-to-detect change that could help improve the reliability and performance of websites.

P.S. `How is this beneficial to Lighthouse?` is a strange phrasing. Seems like it should be `How is this beneficial to the web?`

Pull request:
new_audit: add no-unload-listeners audit
Fixes #10848. ~~WIP for now~~ All should feel free to comment on the details to highlight and advice the audit should provide.

Right now the audit fails if any `'unload'` listener is registered, and recommends using `'pagehide'` or `'visibilitychange'` instead (leaving it up to the (eventually) linked docs to explain which of the two you might choose to use). The audit table is ~just using the plain old violations-style table~ using `source-location`, happy to iterate:

<img width="896" alt="unload event LH audit screenshot" src="https://user-images.githubusercontent.com/316891/87192723-12eba380-c2c5-11ea-8b9b-7e152429a9df.png">

live example:
https://googlechrome.github.io/lighthouse/viewer/?gist=12c8d8d522e867564a4c3a526f4981bd

<details>
<summary>old example</summary>
<img width="888" alt="unload event LH audit screenshot" src="https://user-images.githubusercontent.com/316891/87186103-e3369e80-c2b8-11ea-8f6b-32c9acb5bad3.png">

live example (scroll down to Best Practices)
https://googlechrome.github.io/lighthouse/viewer/?gist=aeb90c472c6c079e0429faf370bb83b1
</details>

<hr>

for the new gatherer, this information didn't really fit into an existing one, and I didn't really want to open us up to just collecting all the event listeners, so I kept it somewhat narrow in scope. I did include other unload-adjacent events because I get the feeling from talking to @philipwalton that there is more analysis we can do here about bfcache-worthiness, but if that doesn't materialize I can also narrow it down even more. I'm also open to completely different ideas :)

Patch:
diff --git a/lighthouse-core/audits/no-unload-listeners.js b/lighthouse-core/audits/no-unload-listeners.js
new file mode 100644
index 000000000..099b02787
--- /dev/null
+++ b/lighthouse-core/audits/no-unload-listeners.js
@@ -0,0 +1,94 @@
+/**
+ * @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const Audit = require('./audit.js');
+const i18n = require('./../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Descriptive title of a Lighthouse audit that checks if a web page has 'unload' event listeners and finds none. */
+  title: 'Avoids `unload` event listeners',
+  /** Descriptive title of a Lighthouse audit that checks if a web page has 'unload' event listeners and finds that it is using them. */
+  failureTitle: 'Registers an `unload` listener',
+  /** Description of a Lighthouse audit that tells the user why pages should not use the 'unload' event. This is displayed after a user expands the section to see more. 'Learn More' becomes link text to additional documentation. */
+  description: 'The `unload` event does not fire reliably and listening for it can prevent browser optimizations like the Back-Forward Cache. Consider using the `pagehide` or `visibilitychange` events instead. [Learn More](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#the-unload-event)',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
+class NoUnloadListeners extends Audit {
+  /**
+   * @return {LH.Audit.Meta}
+   */
+  static get meta() {
+    return {
+      id: 'no-unload-listeners',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
+      requiredArtifacts: ['GlobalListeners', 'JsUsage'],
+    };
+  }
+
+  /**
+   * @param {LH.Artifacts} artifacts
+   * @return {LH.Audit.Product}
+   */
+  static audit(artifacts) {
+    const unloadListeners = artifacts.GlobalListeners.filter(l => l.type === 'unload');
+    if (!unloadListeners.length) {
+      return {
+        score: 1,
+      };
+    }
+
+    /** @type {LH.Audit.Details.Table['headings']} */
+    const headings = [
+      {key: 'source', itemType: 'source-location', text: str_(i18n.UIStrings.columnURL)},
+    ];
+
+    // Look up scriptId to script URL via the JsUsage artifact.
+    /** @type {Array<[string, string]>} */
+    const scriptIdToUrlEntries = Object.values(artifacts.JsUsage)
+      .reduce((acc, usage) => acc.concat(usage), []) // single-level arr.flat().
+      .map(usage => [usage.scriptId, usage.url]);
+    const scriptIdToUrl = new Map(scriptIdToUrlEntries);
+
+    /** @type {Array<{source: LH.Audit.Details.ItemValue}>} */
+    const tableItems = unloadListeners.map(listener => {
+      const url = scriptIdToUrl.get(listener.scriptId);
+
+      // If we can't find a url, still show something so the user can manually
+      // look for where an `unload` handler is being created.
+      if (!url) {
+        return {
+          source: {
+            type: 'url',
+            value: '(unknown)',
+          },
+        };
+      }
+
+      return {
+        source: {
+          type: 'source-location',
+          url,
+          urlProvider: 'network',
+          line: listener.lineNumber,
+          column: listener.columnNumber,
+        },
+      };
+    });
+
+    return {
+      score: 0,
+      details: Audit.makeTableDetails(headings, tableItems),
+    };
+  }
+}
+
+module.exports = NoUnloadListeners;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index b2af8b14b..19a06ce9c 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -147,6 +147,7 @@ const defaultConfig = {
       'script-elements',
       'iframe-elements',
       'main-document-content',
+      'global-listeners',
       'dobetterweb/appcache',
       'dobetterweb/doctype',
       'dobetterweb/domstats',
@@ -236,6 +237,7 @@ const defaultConfig = {
     'largest-contentful-paint-element',
     'layout-shift-elements',
     'long-tasks',
+    'no-unload-listeners',
     'manual/pwa-cross-browser',
     'manual/pwa-page-transitions',
     'manual/pwa-each-page-has-url',
@@ -556,6 +558,7 @@ const defaultConfig = {
         {id: 'doctype', weight: 1, group: 'best-practices-browser-compat'},
         {id: 'charset', weight: 1, group: 'best-practices-browser-compat'},
         // General Group
+        {id: 'no-unload-listeners', weight: 1, group: 'best-practices-general'},
         {id: 'appcache-manifest', weight: 1, group: 'best-practices-general'},
         {id: 'js-libraries', weight: 0, group: 'best-practices-general'},
         {id: 'deprecations', weight: 1, group: 'best-practices-general'},
diff --git a/lighthouse-core/gather/gatherers/global-listeners.js b/lighthouse-core/gather/gatherers/global-listeners.js
new file mode 100644
index 000000000..60deb0579
--- /dev/null
+++ b/lighthouse-core/gather/gatherers/global-listeners.js
@@ -0,0 +1,60 @@
+/**
+ * @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/**
+ * @fileoverview
+ * A gatherer to collect information about the event listeners registered on the
+ * global object. For now, the scope is narrowed to events that occur on and
+ * around page unload, but this can be expanded in the future.
+ */
+
+const Gatherer = require('./gatherer.js');
+
+class GlobalListeners extends Gatherer {
+  /**
+   * @param {LH.Crdp.DOMDebugger.EventListener} listener
+   * @return {listener is {type: 'pagehide'|'unload'|'visibilitychange'} & LH.Crdp.DOMDebugger.EventListener}
+   */
+  static _filterForAllowlistedTypes(listener) {
+    return listener.type === 'pagehide' ||
+      listener.type === 'unload' ||
+      listener.type === 'visibilitychange';
+  }
+
+  /**
+   * @param {LH.Gatherer.PassContext} passContext
+   * @return {Promise<LH.Artifacts['GlobalListeners']>}
+   */
+  async afterPass(passContext) {
+    const driver = passContext.driver;
+
+    // Get a RemoteObject handle to `window`.
+    const {result: {objectId}} = await driver.sendCommand('Runtime.evaluate', {
+      expression: 'window',
+      returnByValue: false,
+    });
+    if (!objectId) {
+      throw new Error('Error fetching information about the global object');
+    }
+
+    // And get all its listeners of interest.
+    const {listeners} = await driver.sendCommand('DOMDebugger.getEventListeners', {objectId});
+    return listeners
+      .filter(GlobalListeners._filterForAllowlistedTypes)
+      .map(listener => {
+        const {type, scriptId, lineNumber, columnNumber} = listener;
+        return {
+          type,
+          scriptId,
+          lineNumber,
+          columnNumber,
+        };
+      });
+  }
+}
+
+module.exports = GlobalListeners;
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index baa514244..4c8780666 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -959,6 +959,15 @@
   "lighthouse-core/audits/network-server-latency.js | title": {
     "message": "Server Backend Latencies"
   },
+  "lighthouse-core/audits/no-unload-listeners.js | description": {
+    "message": "The `unload` event does not fire reliably and listening for it can prevent browser optimizations like the Back-Forward Cache. Consider using the `pagehide` or `visibilitychange` events instead. [Learn More](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#the-unload-event)"
+  },
+  "lighthouse-core/audits/no-unload-listeners.js | failureTitle": {
+    "message": "Registers an `unload` listener"
+  },
+  "lighthouse-core/audits/no-unload-listeners.js | title": {
+    "message": "Avoids `unload` event listeners"
+  },
   "lighthouse-core/audits/offline-start-url.js | description": {
     "message": "A service worker enables your web app to be reliable in unpredictable network conditions. [Learn more](https://web.dev/offline-start-url/)."
   },
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index a131b71a0..3356d3ea5 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -959,6 +959,15 @@
   "lighthouse-core/audits/network-server-latency.js | title": {
     "message": "Ŝér̂v́êŕ B̂áĉḱêńd̂ Ĺât́êńĉíêś"
   },
+  "lighthouse-core/audits/no-unload-listeners.js | description": {
+    "message": "T̂h́ê `unload` év̂én̂t́ d̂óêś n̂ót̂ f́îŕê ŕêĺîáb̂ĺŷ án̂d́ l̂íŝt́êńîńĝ f́ôŕ ît́ ĉán̂ ṕr̂év̂én̂t́ b̂ŕôẃŝér̂ óp̂t́îḿîźât́îón̂ś l̂ík̂é t̂h́ê B́âćk̂-F́ôŕŵár̂d́ Ĉáĉh́ê. Ćôńŝíd̂ér̂ úŝín̂ǵ t̂h́ê `pagehide` ór̂ `visibilitychange` év̂én̂t́ŝ ín̂śt̂éâd́. [L̂éâŕn̂ Ḿôŕê](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#the-unload-event)"
+  },
+  "lighthouse-core/audits/no-unload-listeners.js | failureTitle": {
+    "message": "R̂éĝíŝt́êŕŝ án̂ `unload` ĺîśt̂én̂ér̂"
+  },
+  "lighthouse-core/audits/no-unload-listeners.js | title": {
+    "message": "Âv́ôíd̂ś `unload` êv́êńt̂ ĺîśt̂én̂ér̂ś"
+  },
   "lighthouse-core/audits/offline-start-url.js | description": {
     "message": "Â śêŕv̂íĉé ŵór̂ḱêŕ êńâb́l̂éŝ ýôúr̂ ẃêb́ âṕp̂ t́ô b́ê ŕêĺîáb̂ĺê ín̂ ún̂ṕr̂éd̂íĉt́âb́l̂é n̂ét̂ẃôŕk̂ ćôńd̂ít̂íôńŝ. [Ĺêár̂ń m̂ór̂é](https://web.dev/offline-start-url/)."
   },
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index d99aa030f..6cf7c9cbc 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -112,12 +112,14 @@ declare global {
       FontSize: Artifacts.FontSize;
       /** Screenshot of the entire page (rather than just the above the fold content). */
       FullPageScreenshot: Artifacts.FullPageScreenshot | null;
-      /** The issues surfaced in the devtools Issues panel */
-      InspectorIssues: Artifacts.InspectorIssues;
+      /** Information about event listeners registered on the global object. */
+      GlobalListeners: Array<Artifacts.GlobalListener>;
       /** The page's document body innerText if loaded with JavaScript disabled. */
       HTMLWithoutJavaScript: {bodyText: string, hasNoScript: boolean};
       /** Whether the page ended up on an HTTPS page after attempting to load the HTTP version. */
       HTTPRedirect: {value: boolean};
+      /** The issues surfaced in the devtools Issues panel */
+      InspectorIssues: Artifacts.InspectorIssues;
       /** JS coverage information for code used during page load. Keyed by URL. */
       JsUsage: Record<string, Crdp.Profiler.ScriptCoverage[]>;
       /** Parsed version of the page's Web App Manifest, or null if none found. */
@@ -695,6 +697,18 @@ declare global {
         observedSpeedIndex: number;
         observedSpeedIndexTs: number;
       }
+
+      /** Information about an event listener registered on the global object. */
+      export interface GlobalListener {
+        /** Event listener type, limited to those events currently of interest. */
+        type: 'pagehide'|'unload'|'visibilitychange';
+        /** The DevTools protocol script identifier. */
+        scriptId: string;
+        /** Line number in the script (0-based). */
+        lineNumber: number;
+        /** Column number in the script (0-based). */
+        columnNumber: number;
+      }
     }
   }
 }


Test patch:
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index 82db81ff1..e9adc1148 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -159,6 +159,9 @@ Object {
     Object {
       "path": "long-tasks",
     },
+    Object {
+      "path": "no-unload-listeners",
+    },
     Object {
       "path": "manual/pwa-cross-browser",
     },
@@ -747,6 +750,11 @@ Object {
           "id": "charset",
           "weight": 1,
         },
+        Object {
+          "group": "best-practices-general",
+          "id": "no-unload-listeners",
+          "weight": 1,
+        },
         Object {
           "group": "best-practices-general",
           "id": "appcache-manifest",
@@ -1323,6 +1331,9 @@ Object {
         Object {
           "path": "main-document-content",
         },
+        Object {
+          "path": "global-listeners",
+        },
         Object {
           "path": "dobetterweb/appcache",
         },
diff --git a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
index 403af919f..729c4bc6e 100644
--- a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
+++ b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
@@ -383,6 +383,12 @@ function isOnHttps() {
   document.body.appendChild(img); // PASS
 }
 
+function noUnloadListenersTest() {
+  window.addEventListener('unload', () => {
+    console.log('unload on unload');
+  });
+}
+
 // Figure out which tests to fun.
 const params = new URLSearchParams(location.search);
 if (location.search === '') {
@@ -398,6 +404,7 @@ if (location.search === '') {
   deprecationsTest();
   passwordInputsCanBePastedIntoTest();
   isOnHttps();
+  noUnloadListenersTest();
 } else {
   if (params.has('documentWrite')) {
     documentWriteTest();
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/dobetterweb/dbw-expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/dobetterweb/dbw-expectations.js
index 4a24fbe68..f3ab16b71 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/dobetterweb/dbw-expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/dobetterweb/dbw-expectations.js
@@ -197,6 +197,12 @@ const expectations = [
           },
         },
       ],
+      GlobalListeners: [{
+        type: 'unload',
+        scriptId: /^\d+$/,
+        lineNumber: '>300',
+        columnNumber: '>30',
+      }],
     },
     lhr: {
       requestedUrl: 'http://localhost:10200/dobetterweb/dbw_tester.html',
@@ -413,6 +419,20 @@ const expectations = [
             ],
           },
         },
+        'no-unload-listeners': {
+          score: 0,
+          details: {
+            items: [{
+              source: {
+                type: 'source-location',
+                url: 'http://localhost:10200/dobetterweb/dbw_tester.html',
+                urlProvider: 'network',
+                line: '>300',
+                column: '>30',
+              },
+            }],
+          },
+        },
       },
     },
   },
diff --git a/lighthouse-core/test/audits/no-unload-listeners-test.js b/lighthouse-core/test/audits/no-unload-listeners-test.js
new file mode 100644
index 000000000..54571b1ea
--- /dev/null
+++ b/lighthouse-core/test/audits/no-unload-listeners-test.js
@@ -0,0 +1,77 @@
+/**
+ * @license Copyright 2020 The Lighthouse Authors. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the 'License'); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const NoUnloadListeners = require('../../audits/no-unload-listeners.js');
+
+/* eslint-env jest */
+
+const testJsUsage = {
+  'https://example.com/1.js': [
+    {scriptId: '12', url: 'https://example.com/1.js', functions: []},
+    {scriptId: '13', url: 'https://example.com/1.js', functions: []},
+    {scriptId: '16', url: 'https://example.com/1.js', functions: []},
+    {scriptId: '17', url: 'https://example.com/1.js', functions: []},
+  ],
+  'https://example.com/2.js': [
+    {scriptId: '22', url: 'https://example.com/2.js', functions: []},
+    {scriptId: '23', url: 'https://example.com/2.js', functions: []},
+    {scriptId: '26', url: 'https://example.com/2.js', functions: []},
+    {scriptId: '27', url: 'https://example.com/2.js', functions: []},
+  ],
+};
+
+describe('No Unload Listeners', () => {
+  it('passes when there were no listeners', () => {
+    const artifacts = {JsUsage: testJsUsage, GlobalListeners: []};
+    const result = NoUnloadListeners.audit(artifacts);
+    expect(result).toEqual({score: 1});
+  });
+
+  it('passes when there were no `unload` listeners', () => {
+    const GlobalListeners = [{
+      type: 'DOMContentLoaded', scriptId: '12', lineNumber: 5, columnNumber: 0,
+    }];
+    const artifacts = {JsUsage: testJsUsage, GlobalListeners};
+    const result = NoUnloadListeners.audit(artifacts);
+    expect(result).toEqual({score: 1});
+  });
+
+  it('fails when there are unload listeners and matches them to script locations', () => {
+    const GlobalListeners = [
+      {type: 'unload', scriptId: '16', lineNumber: 10, columnNumber: 30},
+      {type: 'unload', scriptId: '23', lineNumber: 0, columnNumber: 0},
+    ];
+    const artifacts = {JsUsage: testJsUsage, GlobalListeners};
+    const result = NoUnloadListeners.audit(artifacts);
+    expect(result.score).toEqual(0);
+    expect(result.details.items).toMatchObject([
+      {
+        source: {type: 'source-location', url: 'https://example.com/1.js', urlProvider: 'network', line: 10, column: 30},
+      }, {
+        source: {type: 'source-location', url: 'https://example.com/2.js', urlProvider: 'network', line: 0, column: 0},
+      },
+    ]);
+  });
+
+  it('fails when there are unload listeners and has a fallback if script URL is not found', () => {
+    const GlobalListeners = [
+      {type: 'DOMContentLoaded', scriptId: '12', lineNumber: 5, columnNumber: 0},
+      {type: 'unload', scriptId: 'notascriptid', lineNumber: 10, columnNumber: 30},
+      {type: 'unload', scriptId: '22', lineNumber: 1, columnNumber: 100},
+    ];
+    const artifacts = {JsUsage: testJsUsage, GlobalListeners};
+    const result = NoUnloadListeners.audit(artifacts);
+    expect(result.score).toEqual(0);
+    expect(result.details.items).toMatchObject([
+      {
+        source: {type: 'url', value: '(unknown)'},
+      }, {
+        source: {type: 'source-location', url: 'https://example.com/2.js', urlProvider: 'network', line: 1, column: 100},
+      },
+    ]);
+  });
+});
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 34dba5f59..676152cdb 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -390,7 +390,60 @@
       "requestId": "75994.22"
     }
   ],
-  "JsUsage": [],
+  "JsUsage": {
+    "http://localhost:58599/dobetterweb/dbw_tester.html": [
+      {
+        "scriptId": "12",
+        "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+        "functions": []
+      },
+      {
+        "scriptId": "13",
+        "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+        "functions": []
+      },
+      {
+        "scriptId": "16",
+        "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+        "functions": []
+      },
+      {
+        "scriptId": "17",
+        "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+        "functions": []
+      },
+      {
+        "scriptId": "19",
+        "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+        "functions": []
+      },
+      {
+        "scriptId": "20",
+        "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+        "functions": []
+      },
+      {
+        "scriptId": "21",
+        "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+        "functions": []
+      },
+      {
+        "scriptId": "22",
+        "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+        "functions": []
+      },
+      {
+        "scriptId": "23",
+        "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+        "functions": []
+      },
+      {
+        "scriptId": "44",
+        "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+        "functions": []
+      }
+    ]
+  },
   "CSSUsage": {
     "rules": [
       {
@@ -2409,5 +2462,13 @@
   "InspectorIssues": {
     "blockedByResponse": [],
     "mixedContent": []
-  }
+  },
+  "GlobalListeners": [
+    {
+      "type": "unload",
+      "scriptId": "23",
+      "lineNumber": 386,
+      "columnNumber": 36
+    }
+  ]
 }
\ No newline at end of file
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 6d2af2c99..3b8cf8c37 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -1857,6 +1857,34 @@
         ]
       }
     },
+    "no-unload-listeners": {
+      "id": "no-unload-listeners",
+      "title": "Registers an `unload` listener",
+      "description": "The `unload` event does not fire reliably and listening for it can prevent browser optimizations like the Back-Forward Cache. Consider using the `pagehide` or `visibilitychange` events instead. [Learn More](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#the-unload-event)",
+      "score": 0,
+      "scoreDisplayMode": "binary",
+      "details": {
+        "type": "table",
+        "headings": [
+          {
+            "key": "source",
+            "itemType": "source-location",
+            "text": "URL"
+          }
+        ],
+        "items": [
+          {
+            "source": {
+              "type": "source-location",
+              "url": "http://localhost:58599/dobetterweb/dbw_tester.html",
+              "urlProvider": "network",
+              "line": 386,
+              "column": 36
+            }
+          }
+        ]
+      }
+    },
     "pwa-cross-browser": {
       "id": "pwa-cross-browser",
       "title": "Site works cross-browser",
@@ -4657,6 +4685,11 @@
           "weight": 1,
           "group": "best-practices-browser-compat"
         },
+        {
+          "id": "no-unload-listeners",
+          "weight": 1,
+          "group": "best-practices-general"
+        },
         {
           "id": "appcache-manifest",
           "weight": 1,
@@ -4679,7 +4712,7 @@
         }
       ],
       "id": "best-practices",
-      "score": 0.08
+      "score": 0.07
     },
     "seo": {
       "title": "SEO",
@@ -5474,6 +5507,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:audit:no-unload-listeners",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:pwa-cross-browser",
@@ -6335,6 +6374,7 @@
         "audits[network-rtt].details.headings[0].text",
         "audits[network-server-latency].details.headings[0].text",
         "audits[long-tasks].details.headings[0].text",
+        "audits[no-unload-listeners].details.headings[0].text",
         "audits[uses-long-cache-ttl].details.headings[0].text",
         "audits[total-byte-weight].details.headings[0].text",
         "audits[render-blocking-resources].details.headings[0].label",
@@ -6743,6 +6783,12 @@
       "lighthouse-core/lib/i18n/i18n.js | columnDuration": [
         "audits[long-tasks].details.headings[2].text"
       ],
+      "lighthouse-core/audits/no-unload-listeners.js | failureTitle": [
+        "audits[no-unload-listeners].title"
+      ],
+      "lighthouse-core/audits/no-unload-listeners.js | description": [
+        "audits[no-unload-listeners].description"
+      ],
       "lighthouse-core/audits/manual/pwa-cross-browser.js | title": [
         "audits[pwa-cross-browser].title"
       ],