All WordPress HTML Templates Forms & Webhooks AI & Tools
HTML Templates

Progressive Enhancement in a JavaScript World

Progressive enhancement in 2026: the real JS failure modes (chunk errors, blocklists, proxies), working fallback code, and how to test it in CI properly.

A real working moment illustrating the theme of an article about progressive enhancement. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

A client’s lead form stopped converting on a Tuesday. No deploy, no error in the logs, no complaints. Just a 30% drop in submissions that held for nine days until someone noticed. The cause: the bundle was named forms-analytics.js, and an EasyList rule had started matching it. Ad blockers were silently eating the only code path that could submit the form.

That form had a perfectly good action attribute sitting unused, because the submit handler called preventDefault() unconditionally. Four lines of defensive code would have turned a nine-day outage into a slightly less pretty experience. This is what progressive enhancement buys you in 2026, and it has almost nothing to do with users who switch JavaScript off.

Nobody switches JavaScript off. JavaScript switches itself off, constantly, for boring infrastructural reasons. Your error tracker never hears about it, because the error tracker is also JavaScript.

Key Takeaways

  • The real failure modes are chunk-load errors after a deploy, blocklist false positives, corporate TLS-inspecting proxies, timeouts on bad mobile networks and third-party script injectors. Users disabling JS is the least interesting case on that list.
  • GOV.UK’s often-cited 2013 experiment found roughly 1.1% of users didn’t receive working JavaScript, and only about a fifth of those had deliberately disabled it. The absolute number has moved, the ratio of “broken” to “disabled” hasn’t.
  • Progressive enhancement is a build order, not a support matrix. Server-rendered HTML with real form action, href and HTTP semantics first, then JS intercepts to improve the same flow.
  • The platform now covers a lot of what used to need JS: <dialog>, the popover attribute, :has(), details as an accordion with name, scroll-driven animations and cross-document View Transitions.
  • Some interfaces genuinely cannot degrade. A collaborative canvas editor is not a progressive enhancement problem, it’s a JS application, and pretending otherwise wastes a sprint.

The failure modes that actually happen

Forget the hypothetical user with JS disabled. Here is what breaks on production sites, in rough order of frequency.

Chunk load failures after deploy. You ship a new build, hashes change, old chunks get purged from the CDN. A user who loaded the page four minutes before your deploy clicks a route and hits Failed to fetch dynamically imported module. If that route was the only way to reach checkout, you have just lost a sale to a cache invalidation. This is the single most common JS-availability bug in SPA-shaped sites and it has nothing to do with the user’s browser.

Blocklist collateral damage. Filenames and paths containing ads, analytics, track, pixel or gtm get matched by filter rules. uBlock Origin has a lot of users and the lists are maintained by humans making judgement calls.

Corporate middleboxes. TLS-inspecting proxies in hospitals, schools and government networks mangle or strip scripts, sometimes just truncating responses over a size threshold. One NHS trust’s proxy consistently broke a 480KB bundle and nothing else on a site we worked on.

Networks that are bad in a specific way. Not slow, but lossy. A request that hangs for 40 seconds then fails is worse than a 3G connection that is merely slow, because your loading spinner will spin forever while the HTML was already there, fully parseable.

Injected scripts. Hotel captive portals, some mobile carriers and plenty of browser extensions inject code that throws before your bundle runs. One uncaught TypeError at the top of the main thread and nothing after it executes.

If you want numbers for your own site, the measurement is simple and almost nobody does it. Put a <noscript> beacon in your document and compare its hit count against an equivalent beacon fired from JS:

<!-- Fires only when scripting is unavailable or blocked -->
<noscript><img src="/beacon.gif?js=0" alt="" width="1" height="1"></noscript>

That catches the “disabled” and “blocked at document level” cases, not the “threw an error” case. For that, pair it with a window.onerror and unhandledrejection handler registered in an inline script before any bundle loads, plus a server-side count of page views. The gap between page views and successful JS-ready pings is your real number. On content-heavy sites we typically see something between 0.3% and 1.5%, and the high end is almost always a bundle problem rather than a browser problem.

A real working moment that fits an article section about

Progressive enhancement is a build order, not a browser policy

The useful definition is boring: build the thing with HTTP and HTML so it works, then add JavaScript that intercepts the same flow and makes it better. The layers share one source of truth, the HTML. You are not maintaining two implementations.

Where teams go wrong is treating this as a compatibility checkbox at the end of a sprint. It is not a checkbox, it is the order you write the code in. A form you build as a fetch call and then retrofit an action onto will always have a second code path on the server that nobody tests. A form you build as a plain POST and then intercept has exactly one.

Graceful degradation is the opposite direction and the distinction still matters. Graceful degradation means building the rich version and ensuring it fails politely: a @supports fallback, a skeleton that resolves to readable text, an error boundary that renders a retry button. That is legitimate engineering and you need it. But it degrades to “less broken”, not to “functional”. Progressive enhancement degrades to functional. Pick deliberately, per feature, and know which one you chose.

In practice almost every real project is a mix. The rule on client work: anything on the critical path to revenue or legal obligation gets progressive enhancement. Navigation, search, add to cart, checkout, contact forms, login, accessibility statements, cookie preferences. Everything else can degrade gracefully, and that is fine.

What the platform does for you now

A large slice of the JavaScript written between 2015 and 2021 is now deletable. These are all baseline across Chromium, Firefox and Safari:

  • <dialog> with showModal() gets you focus trapping, inert background, Escape-to-close and the ::backdrop pseudo-element. Baseline since Safari 15.4.
  • The popover attribute plus popovertarget gives you declarative tooltips, menus and dropdowns with light-dismiss and top-layer stacking, with zero script. Firefox 125 completed the set in April 2024.
  • :has() kills most of the “add a class to the parent on change” JS. Form validity styling, “does this card contain an image”, nav state: all CSS now.
  • <details name="faq"> gives you an exclusive accordion. One attribute replaces an accordion plugin.
  • @view-transition { navigation: auto; } gives cross-document transitions on multi-page sites in Chromium and Safari, with Firefox having shipped same-document support during 2025. The SPA-feel argument for client-side routing is much weaker than it was.

This is why the multi-page architecture is back on the table for marketing sites, documentation and most of what agencies actually build. We rebuilt a 60-page site last year from a Next.js app to server-rendered HTML plus about 9KB of JS, and the only thing we missed was the router. The View Transition handled it. If you are choosing an architecture for that kind of site, our static hosting comparison covers where the deploy story lands.

The enhancement pattern, in actual code

Here is a contact form that works with no JavaScript at all, then gets enhanced. It posts to a WebForms endpoint, so there is no backend to write and the no-JS path is a real HTTP POST rather than a fiction:

<form id="contact" method="post" action="https://webforms.to/submit/YOURFORMID">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required autocomplete="email">

  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>

  <!-- Where the enhanced version writes status. Empty, polite, always in the DOM. -->
  <p class="form-status" role="status" aria-live="polite"></p>

  <button type="submit">Send</button>
</form>
const form = document.getElementById('contact');
const status = form.querySelector('.form-status');

form.addEventListener('submit', async (event) => {
  // Only take over if we can actually finish the job.
  if (!window.fetch || !window.FormData) return;

  event.preventDefault();
  const button = form.querySelector('button[type=submit]');
  button.disabled = true;
  status.textContent = 'Sending...';

  try {
    const res = await fetch(form.action, {
      method: 'POST',
      body: new FormData(form),
      headers: { Accept: 'application/json' }
    });
    if (!res.ok) throw new Error(res.status);
    status.textContent = 'Thanks, we will reply within one working day.';
    form.reset();
  } catch (err) {
    // Hand control back to the browser instead of dead-ending the user.
    status.textContent = 'Submitting the slow way...';
    form.removeEventListener('submit', arguments.callee);
    form.submit();
  } finally {
    button.disabled = false;
  }
});

The catch block is the part people skip. If your fetch fails for any reason, fall back to a native submit rather than showing “Something went wrong, please try again” to a user who will not try again. In production, use a named handler and removeEventListener properly rather than arguments.callee, which fails in strict mode and modules.

The CSS gate

Never hide content with CSS that only JavaScript can reveal. Gate it on JS having run:

<head>
  <script>document.documentElement.classList.add('js')</script>
</head>
/ Accordion panels stay open and readable unless JS is alive to control them /
.js [data-accordion-panel][hidden] { display: none; }
html:not(.js) [data-accordion-panel] { display: block !important; }

That inline script in <head> runs before first paint, so there is no flash of expanded content. It is the same reasoning as the class-flipping in a font loading strategy: the decision has to happen before the browser paints, or you have traded a broken state for a visible jump.

Where this advice does not apply

A javascript free website is a reasonable goal for a blog, a brochure site, documentation or a landing page. It is not a reasonable goal for a Figma competitor, a video editor, a spreadsheet, a trading dashboard or a collaborative whiteboard. If your product’s core value is a continuous interaction loop over a shared mutable state, HTML has nothing to degrade to. A <noscript> message saying “this app needs JavaScript” is the honest, correct answer.

The mistake happens in the middle. Teams building a content site with three interactive widgets reach for an app framework because one dashboard screen needs it, and now the About page ships 180KB of runtime. Split the build. Server-render the content, isolate the app at /app, accept two deploy targets. The ongoing cost is lower than it looks, because the content side stops needing framework upgrades entirely.

The other place this advice bends: internal tools behind SSO on managed laptops. You control the browser, the network and the extensions. Spend the effort elsewhere.

Testing it without lying to yourself

“It works with JS off” is the easy test and the least representative one. Run these instead:

  1. Block your own bundle. In Chrome DevTools, right-click the main JS request in the Network panel and choose Block request URL. Reload. This simulates the blocklist and proxy cases, which is what actually happens. CSS still loads, so you see the real degraded state.
  2. Throw before your bundle. Temporarily inject <script>null.x</script> above your entry point in a staging build. Verify the page is still usable. If your nav collapses, you have found a real bug.
  3. Delete a chunk. Deploy, then remove a lazy chunk from the CDN while an old tab is open. Click through. You should get a full page navigation, not a dead route. Add a global vite:preloadError or equivalent handler that calls location.reload() once.
  4. Lynx or curl plus a formatter. curl -s https://example.com | sed 's/<[^>]*>//g' tells you in two seconds whether your content is in the HTML. If it is empty, Googlebot’s rendering budget is now your SEO strategy.
  5. Keyboard only, no mouse, in the degraded state. This is where custom div-based controls reveal themselves.

Put at least the first and the fourth in CI. A Playwright test that blocks **/*.js and asserts the contact form still has a non-empty action attribute takes ten minutes to write and catches the regression that cost that client nine days.

What this means for your framework choice

Astro, Enhance, Remix-style form handling, Rails plus Turbo, Phoenix LiveView, HTMX and plain server-rendered PHP all make the enhanced-HTML path the default. React with a client-only router and React Server Components make it possible but not automatic, and the defaults push you the other way. Vue and Svelte sit in between depending on how you set them up.

Honest position: if you are building a content or marketing site in 2026 and you reach for a client-rendered SPA, you are choosing a harder version of a solved problem. Bootstrap 5 plus server-rendered templates still covers an enormous share of real commercial work, which is roughly the argument we made in Bootstrap 5 in 2026. It is also why Canvas ships components built on native dialog and CSS state where possible rather than JS-gated markup.

And if a framework’s documentation shows you a form without an action attribute, treat that as a signal about where its priorities sit.

Frequently Asked Questions

Isn’t progressive enhancement obsolete now that everyone has JavaScript?

Everyone has a JavaScript engine. Not everyone receives your JavaScript. The failures are network, CDN, blocklist and proxy failures rather than browser capability failures, and they hit a small but commercially significant slice of traffic that your client-side error tracking cannot see by definition.

What’s the difference between progressive enhancement and graceful degradation in practice?

Progressive enhancement starts from working HTML and layers JS on top, so the fallback is functional. Graceful degradation starts from the rich version and makes its failure modes polite, so the fallback is readable but often not usable. Use the first for revenue and compliance paths, the second for everything decorative.

Does a javascript free website rank better in search?

Not directly, but server-rendered HTML removes an entire category of indexing risk. Googlebot does render JavaScript, with a deferred rendering queue and a budget, and other crawlers including many AI retrieval bots are far less capable. If your content only exists after hydration, you are betting your visibility on someone else’s crawler budget.

How much extra time does this add to a project?

On a server-rendered stack, close to zero, because you are writing the HTML anyway. On a client-rendered stack, it can add 20% to 30% to interactive feature work because you are effectively building the same flow twice. That cost is the real argument for choosing a server-first framework at the start rather than retrofitting.

Do I still need a <noscript> tag?

Only for two things: a measurement beacon, and an honest message on genuinely JS-dependent applications. Using <noscript> to duplicate content is a maintenance trap, because the duplicate drifts out of sync and never gets tested. Make the primary markup work instead.

What to do on Monday

Pick your three highest-value user flows. Open DevTools, block your main bundle, and walk each one. If any of them dead-end, you have a specific bug with a specific fix: usually an action, an href or a catch block that hands control back to the browser.

Then add the <noscript> beacon so you stop arguing about percentages and start reading them. The number will be small. It will also be larger than anyone on the team guessed, and it will be concentrated in exactly the users you cannot afford to lose.

chunk load error after deploy form that works without javascript graceful degradation vs progressive enhancement javascript free website noscript beacon measure javascript failures progressive enhancement progressive enhancement 2026 server rendered html vs spa for content sites