# "Unload event listeners are deprecated and will be removed": how to fix it

**Audit ID:** `deprecations` · **Category:** Best Practices

<!--QA-->
> **Quick answer:** Chrome no longer fires the unload event by default, so Lighthouse lists any unload listener under "Uses deprecated APIs" and the console prints "Permissions policy violation: unload is not allowed in this document". Fix it by replacing unload with pagehide for cleanup and visibilitychange for saving state, sending analytics with sendBeacon or fetch keepalive, and upgrading old jQuery and vendor scripts that register the listener for you.
<!--/QA-->

This row appears under the Lighthouse audit **Uses deprecated APIs** when a script on the page registers a listener for the `unload` event. Chrome deprecated the event because it blocks the back/forward cache and never fired reliably on mobile, and in 2026 it finished turning the event off by default. The same problem shows up in the Chrome console as "[Violation] Permissions policy violation: unload is not allowed in this document." In our study of 269 Shopify storefronts, 148 of them (55.0%) failed the deprecations audit. This page covers what the two messages mean, how to find the script that registers the listener, what to use instead, and what to do when the listener belongs to a vendor.

## TL;DR

- **What:** A script called `addEventListener('unload', ...)`, set `window.onunload`, or used jQuery's `$(window).unload()`. Chrome reports the listener as a deprecation, and the handler no longer runs for most visitors.
- **Why it matters:** One deprecation row fails the audit. The deeper cost is the back/forward cache: a page with an unload handler is not cached, so pressing Back reloads it from scratch instead of restoring it instantly. Any work the handler did, such as sending the last analytics event, is already lost on mobile and now on desktop too.
- **Fix:** Replace `unload` with `pagehide` for cleanup and `visibilitychange` for saving state. Send beacons with `navigator.sendBeacon()` or `fetch()` with `keepalive`. Upgrade jQuery 1.x and 2.x and old vendor SDKs, which register the listener for you.

## What do the two messages mean?

| Where you see it | Exact text | What triggered it |
|---|---|---|
| Lighthouse, Best Practices, "Uses deprecated APIs" | `Unload event listeners are deprecated and will be removed.` | A script registered an unload listener during the audit. The Source column gives the file and line. |
| Chrome console | `[Violation] Permissions policy violation: unload is not allowed in this document.` | A script registered an unload listener in a document whose permissions policy blocks `unload`, which is now Chrome's default. The listener is accepted but will not fire. |
| DevTools, Application, Back/forward cache | `Unload handler` under **Actionable** | The page cannot enter the back/forward cache because of the listener. |

All three point at the same line of code. The deprecations audit is binary: one row fails it, and there is no threshold. It carries a small weight in the Best Practices score and does not touch Performance.

## Why did Chrome deprecate the unload event?

Two reasons, both documented in Chrome's deprecation plan:

1. **It blocks the back/forward cache.** The cache keeps a page alive in memory when the user navigates away, so Back and Forward restore it instantly. A page with an unload listener cannot be kept alive, because the browser would have to run the handler at an unknown later time. Every page with the listener pays a full reload on Back.
2. **It never fired reliably.** Mobile browsers kill background tabs without running `unload`, and desktop browsers skip it in several navigation paths. Code that depended on it, such as "send the final analytics ping on unload", silently lost data for years.

Chrome rolled the change out through a permissions policy. The `unload` policy's default moved from allow to deny: first for sites that opted in (Chrome 115, 2023), then for the top 50 sites (Chrome 135 to 142, 2025), then for all origins in steps from 1% of page loads in Chrome 146 (March 2026) to 100% in Chrome 155 (October 2026). Today the event does not fire in Chrome on desktop or Android unless the site opts back in. Chrome on iOS uses WebKit and still fires it.

## How do I find the script that registers the unload listener?

1. **Read the Source column in the Lighthouse row.** It gives the URL and line. A file on your domain is yours. A minified vendor file at line 1 is a third party.
2. **Search your own code** for these patterns:

```text
addEventListener('unload'
addEventListener("unload"
window.onunload
onunload=
$(window).unload(
$(window).on('unload'
jQuery(window).unload(
```

3. **Check jQuery.** jQuery 1.x and 2.x register an unload listener internally to clean up event data in old Internet Explorer. We saw this in a live PageSpeed run: the row pointed at `jquery/1.12.4/jquery.min.js`, line 1. jQuery 3 removed that code, and it also removed the `.unload()` shorthand.
4. **Check vendor scripts.** Older analytics SDKs, session recorders, chat widgets, A/B testing tools, and ad scripts registered unload listeners to flush their queues. The Source URL names the vendor.
5. **Confirm in DevTools.** Open the **Application** panel, choose **Back/forward cache**, and click **Test back/forward cache**. An unload handler appears under **Actionable**. The same reason is exposed to scripts as `unload-listener` in `performance.getEntriesByType('navigation')[0].notRestoredReasons`.

## What should I use instead of unload?

### 1. `pagehide` for cleanup

`pagehide` fires when the user navigates away or closes the tab normally, including when the page enters the back/forward cache, and it does not block the cache. It does not fire when the browser discards a background tab or kills its process, which is common on mobile. `event.persisted` is `true` when the page is being cached, so you can skip teardown that a restored page would need.

```js
// Before: never fires in current Chrome, blocks bfcache everywhere.
window.addEventListener('unload', () => {
  socket.close();
});

// After: fires on normal navigation, lets the page be cached.
window.addEventListener('pagehide', (event) => {
  socket.close();
  if (event.persisted) {
    // The page may come back from the cache; reconnect in pageshow.
  }
});
window.addEventListener('pageshow', (event) => {
  if (event.persisted) socket = connect();
});
```

### 2. `visibilitychange` for saving state and sending analytics

The moment the document becomes hidden is the last state change a page can reliably observe. After it, the browser can discard the tab or kill its process without firing any more events, so even this handler is not guaranteed to run on every exit. Send the final analytics event here, and use a transport that survives the page going away. For data you cannot lose, such as drafts, save as the data changes (on input, debounced) and treat the hidden handler as a last flush, not the only save.

```js
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    localStorage.setItem('draft', editor.value);
    navigator.sendBeacon('/analytics', JSON.stringify({ event: 'session_end' }));
    // or: fetch('/analytics', { method: 'POST', body, keepalive: true });
  }
});
```

`sendBeacon` and `fetch` with `keepalive: true` let the request finish after the page is gone. A plain `fetch` or `XMLHttpRequest` in an unload path is cancelled.

### 3. `beforeunload` only for unsaved changes

`beforeunload` also blocks the back/forward cache while it is registered. Add it only when the page has unsaved changes, and remove it as soon as the changes are saved. Never use it for analytics or cleanup.

```js
function onBeforeUnload(e) { e.preventDefault(); }
editor.addEventListener('input', () => window.addEventListener('beforeunload', onBeforeUnload));
form.addEventListener('submit', () => window.removeEventListener('beforeunload', onBeforeUnload));
```

## Can I turn the unload event back on?

Yes, for your own document, with a response header. Chrome keeps this opt-in for the foreseeable future, but it does not make the deprecation row go away and it keeps your page out of the back/forward cache.

```http
Permissions-Policy: unload=self
```

Allow it inside a cross-origin iframe as well:

```http
Permissions-Policy: unload=(self "https://embed.example.com")
```

```html
<iframe src="https://embed.example.com/widget" allow="unload"></iframe>
```

Use the header as a bridge while you migrate a large codebase, not as the fix. Managed Chrome deployments can also keep unload on for staff with the `ForcePermissionPolicyUnloadDefaultEnabled` enterprise policy, which changes nothing for your public visitors.

## How do I fix it in Shopify, WordPress, or React?

- **Shopify:** the row usually points at a theme script that calls `$(window).unload()` or at an app embed. Replace the call in the theme with `pagehide`, and if the theme still ships jQuery 1.x, upgrade it or remove the dependency. App rows are the vendor's code: update the app, or remove it if it does not earn its place.
- **WordPress:** core ships jQuery 3, so a jQuery 1.x row means a plugin or theme enqueues its own copy. Find it with the Source URL, update or replace the plugin, and do not install a "jQuery migrate" style downgrade to silence errors. Page builders and sliders from before 2020 are the usual source.
- **React, Vue, and other frameworks:** an effect cleanup that listens for `unload` to flush state should listen for `pagehide` instead, and state that must survive should be written on `visibilitychange`. Libraries that offer an `onUnload` hook usually have a `pagehide` option in their current version.

## Can I fix an unload listener in a third-party script?

You cannot edit vendor code. Your options:

1. **Update the SDK.** Most vendors replaced unload with `pagehide` and `visibilitychange` in 2023 and 2024. The current version of the script is the fix.
2. **Load it later.** The listener still registers, so this does not clear the row, but it keeps the vendor's other work off the critical path.
3. **Report it.** Send the vendor the file URL and line from the Lighthouse row.
4. **Remove the tag** if it does not earn its place. This is the only fix you fully control.

Do not add `Permissions-Policy: unload=self` to make a vendor's handler run again. It costs you the back/forward cache on every page and the handler still does not run for mobile visitors.

## How do I verify the fix?

1. Re-run Lighthouse. "Avoids deprecated APIs" should appear in the passed audits, unless another deprecation row remains.
2. Reload with the console open. The "Permissions policy violation: unload is not allowed in this document" message should be gone.
3. Run **Test back/forward cache** in the DevTools Application panel. The result should be "Successfully served from back/forward cache", with no unload handler under Actionable.
4. Navigate away and press Back. The page should restore instantly, with scroll position and form state intact.

## What mistakes should I avoid?

- **Replacing `unload` with `beforeunload`.** It blocks the back/forward cache in the same way and prompts the user. Use `pagehide` and `visibilitychange`.
- **Sending analytics with a plain `fetch` in `pagehide`.** The request is cancelled when the page goes away. Use `sendBeacon` or `keepalive: true`.
- **Opting back in with the header and calling it fixed.** The row stays, the cache stays off, and mobile visitors still never run the handler.
- **Silencing the console message instead of removing the listener.** The violation is a symptom. The listener is the problem.

## Related audits

- [Browser errors were logged to the console](/audits/errors-in-console): the permissions policy violation is logged alongside the other console errors
- [Issues were logged in the Issues panel in Chrome DevTools](/audits/inspector-issues): the Issues panel lists the same deprecation with a link to the source
- [Avoid serving legacy JavaScript to modern browsers](/audits/legacy-javascript): the same old jQuery and vendor bundles that register unload also ship legacy polyfills

---

Audit your URL at https://lighthouse-md.com.
