Design every observer-driven feature as an enhancement of a baseline that already works: content visible by default, a real "Next page" link under infinite scroll, native loading="lazy" or noscript images under custom loaders, CSS position: sticky under scripted headers — so a failed script, a slow bundle or a missing API degrades the experience instead of breaking it.

Problem / Scenario Context

An e-commerce category page relies on observers for four features: reveal animations, a custom image loader, infinite scroll for the product grid and a scripted sticky filter bar. One morning a CDN misconfiguration serves the main JavaScript bundle with the wrong MIME type. The HTML loads, but every section below the hero is invisible (reveal styles hide it), every product image is a grey box (no loader), the grid ends after 24 products (no way to reach page 2), and the filter bar scrolls away. Revenue drops sharply for two hours; the page was server-rendered and still unusable.

Server rendering is not the same as working without JavaScript. The SSR & Hydration Observer Safety topic covers hydration timing; this page covers the fallbacks.

Mechanics Explanation

JavaScript fails more often than teams assume: bundles blocked by extensions, corporate proxies or content filters; errors thrown early in startup; slow networks where the page is used before scripts arrive; old embedded browsers missing APIs. An observer-driven feature fails in one of two ways:

  • Fail closed: the baseline state is broken until the script runs — content hidden, images empty, navigation missing. When the script does not run, the page is broken.
  • Fail open: the baseline state is usable — content visible, images loading natively, a real link to the next page. The script improves it: animation, smarter loading, seamless scrolling.

The difference is entirely in where the "hidden" or "missing" state is declared. If the stylesheet hides content and the script reveals it, the page fails closed. If the script adds a class that opts into hiding and then reveals, it fails open. The same inversion applies to every feature.

Failing Closed Versus Failing OpenTwo columns. Failing closed means reveal styles hide content in the base stylesheet, images have only data-src, infinite scroll has no link to page two, and the sticky bar is scripted; if JavaScript fails the page is broken. Failing open means content is visible until script opts in, images use native lazy loading or noscript, a real next-page link exists, and CSS sticky provides the bar; if JavaScript fails the page still works.Fails closedReveal CSS hides content by defaultImages have only data-srcGrid ends at 24 items, no link onwardFilter bar sticky only via scriptFails openContent visible until JS opts inNative lazy loading or noscript imagesReal Next page link under the sentinelposition: sticky in CSS

Comparison Table: Baselines for Common Observer Features

Feature Baseline without JS Enhancement with observers
Reveal animations content visible .js opt-in hides, observer reveals with motion
Lazy images loading="lazy" or <noscript> img custom margins, blur-up placeholders
Infinite scroll <a href="?page=2">Next page</a> sentinel replaces the link with seamless loading
Sticky header / filter bar position: sticky stuck-state styling, hide on scroll down
Autoplay video poster + controls play when visible, pause when hidden
Lazy components server-rendered static content hydration or mounting on visibility
Analytics impressions none (acceptable) observer-based impressions

Minimal Reproducible Example

HTML
<style>.reveal { opacity: 0; }   .reveal.in { opacity: 1; transition: opacity .4s; }</style>
<section class="reveal"></section>
<img data-src="/p/1.jpg" alt="Linen shirt" width="400" height="500">
<ul id="grid">…24 products…</ul>
<div id="sentinel"></div>
<script type="module" src="/app.js"></script>   <!-- if this fails, the page is broken -->

Block /app.js in DevTools and reload: hidden sections, empty images and a dead end at product 24.

Production-Safe Solution

HTML
<html class="no-js">
<head>
  <script>document.documentElement.classList.replace('no-js', 'js');</script>
  <style>
    .js .reveal:not(.in) { opacity: 0; transform: translateY(12px); }   /* only once JS runs */
    .reveal { transition: opacity .4s, transform .4s; }
    .filter-bar { position: sticky; top: 0; }                          /* CSS baseline */
  </style>
</head>
<body>
  <section class="reveal"></section>

  <!-- Native lazy loading as the baseline; the enhancer may swap to a blur-up loader. -->
  <img src="/p/1.jpg" loading="lazy" decoding="async" alt="Linen shirt" width="400" height="500">

  <ul id="grid">…24 products…</ul>
  <nav class="pagination" aria-label="Pagination">
    <a id="next-page" href="?page=2" rel="next">Next page</a>
  </nav>
  <script type="module" src="/app.js"></script>
</body>
</html>
TypeScript
// app.js — enhancements that assume the baseline already works.
(window as Window & { __enhanced?: boolean }).__enhanced = true;   // tells the safety net we started
if ('IntersectionObserver' in window) {
  // Reveal: the .js class already opted in; if this module fails, content stays visible
  // except for the brief window before it runs — so add a safety timeout.
  const io = new IntersectionObserver((es, o) => es.forEach((e) => {
    if (e.isIntersecting) { e.target.classList.add('in'); o.unobserve(e.target); }
  }), { rootMargin: '0px 0px 10% 0px' });
  document.querySelectorAll('.reveal').forEach((el) => io.observe(el));

  // Infinite scroll: enhance the real link into a sentinel.
  const next = document.querySelector<HTMLAnchorElement>('#next-page');
  if (next) {
    let url: string | null = next.href;
    const loader = new IntersectionObserver(async ([e]) => {
      if (!e.isIntersecting || !url) return;
      const html = await fetch(url).then((r) => r.text());
      const doc = new DOMParser().parseFromString(html, 'text/html');
      document.querySelector('#grid')!.append(...doc.querySelectorAll('#grid > li'));
      url = doc.querySelector<HTMLAnchorElement>('#next-page')?.href ?? null;
      if (url) next.href = url; else loader.disconnect();
      history.replaceState(null, '', url ?? location.href);
    }, { rootMargin: '800px 0px' });
    loader.observe(next);
  }
}
HTML
<!-- Safety net for the reveal opt-in: if the enhancer has not run within 3 s, show everything. -->
<script>setTimeout(() => { if (!window.__enhanced) document.documentElement.classList.remove('js'); }, 3000);</script>

The page now works in every failure mode. If the script never loads, the js class is still set by the inline head script, so a short timeout removes it again — the safety net for the one opt-in that was taken before knowing whether the enhancer would run (the enhancer sets window.__enhanced = true when it starts). The "Next page" link is a real link that also serves crawlers; the enhancer turns it into the infinite-scroll sentinel, using server-rendered page HTML so no separate API is needed.

Layers of an Enhanced Product GridThree stacked layers. The HTML and CSS baseline delivers visible content, native lazy images, sticky filters and a real next-page link. The observer enhancement layer adds reveal motion, seamless infinite scroll and custom preloading. A safety net removes the opt-in class if the enhancement has not started within a few seconds.HTML + CSS baselineVisible content, native lazy images, sticky filters, real Next page link.Observer enhancementsReveal motion, seamless infinite scroll, smarter preloading.Safety netRemove the .js opt-in if enhancements have not started in 3 s.

Testing the Baseline

Fallbacks rot unless tested. Three cheap checks catch most regressions:

  • JavaScript disabled — Playwright's javaScriptEnabled: false context loads the page and asserts content visibility, image src presence and the next-page link.
  • Enhancer blocked — route the main bundle to a 404 (page.route('**/app.js', r => r.abort())) while leaving inline scripts running; this exercises the opt-in safety net, the most fragile case.
  • API missing — delete window.IntersectionObserver in an init script and confirm the feature-detection branches keep the baseline.
TypeScript
test('grid works when the enhancer fails to load', async ({ page }) => {
  await page.route('**/app.js', (r) => r.abort());
  await page.goto('/category/shirts');
  await page.waitForTimeout(3100);                                 // safety net fires
  await expect(page.locator('.reveal').first()).toHaveCSS('opacity', '1');
  await expect(page.locator('#next-page')).toHaveAttribute('href', /page=2/);
});

The visual regression guide covers screenshot testing of the enhanced state.

Failure Modes and What Users SeeA grid of failure modes against four features. With JavaScript disabled, content is visible, images load natively, pagination uses links and filters are sticky via CSS. With the enhancer blocked, the same holds after the safety net fires. With IntersectionObserver missing, feature detection keeps the baseline. With everything working, users get motion, seamless scrolling and smarter loading.ContentImagesPaginationFiltersJS disabledvisiblenative lazylinksCSS stickyEnhancer blockedvisible after 3 snative lazylinksCSS stickyNo IO APIvisiblenative lazylinksCSS stickyAll workingrevealedsmarter preloadseamlessstuck styling

Verification Steps

  • Disable JavaScript and complete a purchase path on the category page.
  • Block the main bundle and confirm the safety net restores content within seconds.
  • Remove IntersectionObserver in a test and confirm no errors and a working baseline.
  • Check crawlers' view (View Source) for real links and image src attributes.
  • Run these as automated tests on every build.

Common Mistakes to Avoid

  • Hiding content in the base stylesheet. It makes readability depend on JavaScript.
  • Infinite scroll with no link to the next page. Users and crawlers without JS hit a dead end.
  • Images with only data-src. Nothing loads without the script.
  • Opting in early without a safety net. An inline class set before the enhancer loads needs a timeout fallback.

FAQ

Is progressive enhancement still relevant when everyone has JavaScript?

Everyone has JavaScript until it fails to load, throws early, is blocked by an extension or proxy, or arrives late on a slow network. Enhancement makes those cases degrade gracefully instead of breaking the page.

Why set the js class inline if it can cause hidden content?

Setting it inline prevents a flash of unhidden, then hidden, content before the enhancer runs. The timeout safety net covers the case where the class is set but the enhancer never arrives.

Does a Next page link hurt the infinite-scroll experience?

No. The enhancer uses the link as its sentinel and keeps its href pointing at the next page, so JavaScript users get seamless loading and everyone else gets working pagination. Crawlers can follow it too.

What is the scripting media feature?

@media (scripting: none) matches when scripting is disabled, letting CSS apply no-JS styles without a class. It does not help when scripting is enabled but the bundle fails, which is why the class plus safety net is still useful.

Should analytics have a fallback?

Not necessarily. Missing impressions when JavaScript fails is acceptable; the important baselines are the ones users see and navigate.

How does this relate to server-side rendering?

SSR delivers content as HTML, which is a prerequisite. Progressive enhancement is the additional discipline of making sure that HTML is usable without the client code that follows it.


↑ Back to SSR & Hydration Observer Safety