---
title: "Defer for Contact Form 7: Deferring CF7’s Forced Scripts and Styles"
description: "Contact Form 7 is the most popular form plugin for WordPress — and one of the most common culprits behind a poor PageSpeed score. The…"
url: "https://rowan-web.ru/en/blog/defer-for-contact-form-7/"
date_modified: "2026-09-06T16:23:11+03:00"
language: "en-US"
---
Contact Form 7 is the most popular form plugin for WordPress — and one of the most common culprits behind a poor PageSpeed score. The problem isn’t the form itself, but how the plugin loads its assets: unconditionally, in the head, synchronously, regardless of whether the page actually has a form on it. Here’s how I went looking for a fix, why the off-the-shelf solutions didn’t work, and what I ended up building.

The long search: three files that refused to go away
----------------------------------------------------

Open the Network tab on any page with a form — and often on every page of the site, unless you explicitly disable autoloading with the `WPCF7_LOAD_JS` and `WPCF7_LOAD_CSS` constants — and the same three requests are always there:

```
/wp-content/plugins/contact-form-7/includes/css/styles.css?ver=6.1.3
/wp-content/plugins/contact-form-7/includes/swv/js/index.js?ver=5.8.7
/wp-content/plugins/contact-form-7/includes/js/index.js?ver=5.8.7
```

The first instinct is to bolt on any generic async CSS/JS plugin and call it done. In practice it’s messier than that. Generic optimizers either broke the form outright — defer the `contact-form-7` script and client-side validation or the AJAX submission stops working — or they couldn’t touch the real problem at all: most of them match by URL or handle in the already-rendered HTML, while part of CF7’s problem sits one level deeper, in the `wp-hooks` and `wp-i18n` dependencies shared by dozens of other plugins and blocks. A blanket “defer anything that looks like contact-form-7” either missed those dependencies entirely, or hit them too broadly and broke unrelated functionality.

Digging through `wp_scripts()` and Query Monitor turned up an unexpected twist. Two of the three JS files above — `swv/js/index.js` and `js/index.js` — are already registered by CF7 itself with `in_footer` set to true. They don’t block the first paint at all; they just happen to sit in the Network panel next to everything else. The real bottlenecks turned out to be the form’s CSS file, which the browser is spec-bound to wait for before rendering, and the `wp-hooks` / `wp-i18n` dependency scripts printed in the head. None of the ready-made async plugins were looking at the problem from this angle — all of them were fighting the three visible files instead of the two actual causes of the block.

Why a plain defer doesn’t work here
-----------------------------------

CSS is straightforward — a `defer` attribute on a script tag has no effect on stylesheet blocking, so that needs a different trick. `wp-hooks` and `wp-i18n` hide a more interesting trap.

WordPress itself attaches an inline block to `wp-i18n` via `wp_add_inline_script` — usually a call to `wp.i18n.setLocaleData`. It’s printed as a separate script tag right after the main one and runs synchronously, in document order. Add a plain `defer` to the external `wp-i18n.js`, and the browser postpones its execution until DOM parsing is done — but the inline block right next to it fires immediately, before the `window.wp` object even exists. The result is a “wp is not defined” console error and broken functionality wherever it depends on localization.

Hence the fix: don’t touch the script attributes at all — move the whole handle, inline addition included, to the footer by switching its loading group:

```
add_action('wp_enqueue_scripts', 'cf7defer_move_deps_to_footer', 100);
function cf7defer_move_deps_to_footer() {
    foreach (array('wp-hooks', 'wp-i18n') as $handle) {
        if (wp_script_is($handle, 'registered')) {
            wp_scripts()->add_data($handle, 'group', 1); // group 1 = footer
        }
    }
}
```

The print order — script, then its inline block — stays exactly the same; the whole pair just ends up at the end of the document now, after the first paint, but still synchronous and with no race between the dependency and the code that relies on it.

For CSS, it’s the classic loadCSS trick: the `media` attribute is printed as `print`, which doesn’t block rendering, and `onload` switches it back to `all`:

```
add_filter('style_loader_tag', 'cf7defer_async_css', 10, 2);
function cf7defer_async_css($html, $handle) {
    if ('contact-form-7' === $handle || 'contact-form-7-rtl' === $handle) {
        return str_replace(
            "rel='stylesheet'",
            "rel='stylesheet' media='print' onload="this.media='all'"",
            $html
        );
    }
    return $html;
}
```

The cost: the form can flash unstyled for roughly 50 milliseconds. It doesn’t affect the logic — validation, AJAX submission — and it removes the render-blocking entirely.

What made it into the free version
----------------------------------

Both techniques are bundled into the Defer for Contact Form 7 plugin, as two settings on the Contact → Performance page, both enabled by default: moving `wp-hooks` and `wp-i18n` to the footer, and loading the form’s CSS asynchronously.

- The plugin has a hard dependency on Contact Form 7 being active — without it, it does nothing at all except show a notice in the admin.
- Both optimizations are idempotent: if the theme is already doing something similar, applying them again won’t break anything.
- jQuery is deliberately left untouched. Deferring it is only safe when every script on the site that depends on it is also deferred or already in the footer — and on an arbitrary site that’s never guaranteed.

Pro version: lazy-loaded reCAPTCHA v3 without the performance hit
-----------------------------------------------------------------

Since the whole plugin started as a performance project, the built-in spam protection couldn’t be done the usual way. Contact Form 7’s stock reCAPTCHA integration loads Google’s script — synchronously, same as before — which puts a third-party script right back in the critical path and undoes all the work above. That approach costs roughly 20 PageSpeed points, give or take, which is not a small hit.

The paid version takes a different approach. Google’s script isn’t loaded upfront — it’s pulled in only on the user’s first interaction with the form, on `focusin`, `pointerdown` or `touchstart`, which is outside the render’s critical path entirely, so it never shows up in Lighthouse or PageSpeed Insights. A v3 token is valid for about 120 seconds and can’t be reused, so form submission is intercepted before CF7’s own handlers ever run: the submit is paused, a fresh token is fetched via `grecaptcha.execute()`, and only then does the same submit event continue. The score check happens server-side, on the `wpcf7_spam` hook, with a configurable threshold — 0.5 by default. If Google is unreachable, the plugin fails open, so a network hiccup never costs a real submission.

The reCAPTCHA badge is hidden, and the disclaimer about Google’s privacy policy and terms of use — required by Google’s own terms — is added under the form automatically. The plugin also checks whether CF7’s own built-in reCAPTCHA integration is enabled at the same time and warns about the conflict: leave both switched on, and `api.js` loads twice, defeating the whole point of lazy loading.

The bottom line
---------------

The free version closes a specific, often-overlooked gap: Contact Form 7 puts blocking CSS and a couple of JS dependencies in the head of every page without asking, and generic async optimizers can’t handle them — they either miss and hit already-harmless footer files, or hit too broadly and break localization. The Pro version carries the same idea over to spam protection: reCAPTCHA v3 that doesn’t charge performance for doing its job.

The plugin is already in the WordPress.org repository, under the slug [defer-for-contact-form-7](https://wordpress.org/plugins/defer-for-contact-form-7/).

[Full list of this site's AI-readable pages](https://rowan-web.ru/llms.txt)
