intertwingly

It’s just data

Supporting Older Browsers with Import Maps


A user reported getting HTTP 426 "Upgrade Required" errors when trying to access the showcase application using Chrome 103 on macOS 10.12.6 (Sierra). Looking at the server logs, we saw:

user_agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6)
            AppleWebKit/537.36 (KHTML, like Gecko)
            Chrome/103.0.0.0 Safari/537.36
status: 426

The application was checking browser versions and intentionally returning HTTP 426 ("Upgrade Required") with a warning message instead of blocking older browsers completely:

MODERN_BROWSER = {
  "Chrome" => 119,
  "Safari" => 17.2,
  "Firefox" => 121,
  "Internet Explorer" => false,
  "Opera" => 104
}

def browser_warn
  user_agent = UserAgent.parse(request.user_agent)
  min_version = MODERN_BROWSER[user_agent.browser]
  return if min_version == nil
  if min_version == false || user_agent.version < UserAgent::Version.new(min_version.to_s)
    browser = "You are running #{user_agent.browser} #{user_agent.version}."
    if user_agent.browser == 'Safari' and user_agent.platform == 'Macintosh'
      "#{browser} Please upgrade your operating system or switch to a different browser."
    else
      "#{browser} Please upgrade your browser."
    end
  end
end

In the controllers, the app would render with status 426 if the browser was too old:

@browser_warn = browser_warn
render :heatlist, status: (@browser_warn ? :upgrade_required : :ok)

This approach lets users see what's wrong and still attempt to use the application, rather than showing a cryptic error or blank page. The page loads, displays the warning message prominently, and the browser decides whether to show the warning based on the 426 status code.

But wait - the user was stuck. macOS 10.12.6 can only run:

Why These Versions Were Required

The showcase application uses Rails' importmap-rails gem, which enables using JavaScript modules without a build step. Import maps are a web standard that maps module specifiers to URLs:

<script type="importmap">
{
  "imports": {
    "@hotwired/stimulus": "/assets/stimulus-1234.js",
    "@hotwired/turbo": "/assets/turbo-5678.js"
  }
}
</script>

This allows clean ES module imports in the application:

import { Controller } from "@hotwired/stimulus"

The browser requirements matched when each browser added native import maps support:

Rails' default MODERN_BROWSER constant was set to versions from 2024, which seemed unnecessarily aggressive.

The Build Target Strategy

The showcase application was already designed with broad browser compatibility in mind. I had previously created lib/tasks/esbuild.rake specifically to transpile JavaScript controllers to a lower target:

Rake::Task['assets:precompile'].enhance do
  Dir.chdir 'public/assets/controllers' do
    files = Dir['*.js'] - Dir['*.js.map'].map {|file| File.basename(file, '.map')}

    unless files.empty?
      sh "esbuild", *files,
        *%w(--outdir=. --allow-overwrite --minify --target=es2020 --sourcemap)
    end
  end
end

The --target=es2020 flag means the JavaScript features are already being transpiled to a 5-year-old standard. This means the actual requirement is browsers that support ES2020, not the latest and greatest:

ES2020 Browser Support:

Both Chrome 103 and Firefox ESR 115 fully support ES2020. The only missing piece was import maps.

The Solution: Conditional Polyfill

The importmap-rails documentation mentions es-module-shims, a polyfill that adds import maps support to older browsers. The key insight: we don't need to load this polyfill for modern browsers that have native support.

Here's the implementation:

1. Define Browser Version Ranges

# Browsers that support ES2020 but need es-module-shims for import maps
NEEDS_IMPORTMAP_SHIM = {
  "Chrome" => [80, 89],    # Chrome 80-88 need shim (89+ has native)
  "Firefox" => [74, 108],  # Firefox 74-107 need shim (108+ has native)
  "Safari" => [13.1, 16.4], # Safari 13.1-16.3 need shim (16.4+ has native)
  "Opera" => [67, 76]      # Opera 67-75 need shim (76+ has native)
}

2. Browser Detection Helper

def needs_importmap_shim?
  user_agent = UserAgent.parse(request.user_agent)
  range = NEEDS_IMPORTMAP_SHIM[user_agent.browser]
  return false if range.nil?

  version = user_agent.version
  min_version = UserAgent::Version.new(range[0].to_s)
  max_version = UserAgent::Version.new(range[1].to_s)

  version >= min_version && version < max_version
end
helper_method :needs_importmap_shim?

3. Conditional Script Tag

In app/views/layouts/application.html.erb:

<% if needs_importmap_shim? %>
<script async src="https://ga.jspm.io/npm:es-module-shims@1.8.2/dist/es-module-shims.js"
        data-turbo-track="reload"></script>
<% end %>
<%= javascript_importmap_tags %>

4. Updated Browser Requirements

# Minimum versions supporting ES2020 (esbuild target) + WebSockets
# Import maps are polyfilled via es-module-shims for browsers in range
MODERN_BROWSER = {
  "Chrome" => 80,      # ES2020 support (February 2020)
  "Safari" => 13.1,    # ES2020 support (March 2020)
  "Firefox" => 74,     # ES2020 support (March 2020)
  "Internet Explorer" => false,
  "Opera" => 67        # ES2020 support (based on Chromium 80)
}

How It Works

The polyfill uses service workers and dynamic import rewriting to provide import maps support in older browsers. When a browser that needs the polyfill loads the page:

  1. es-module-shims loads and intercepts module requests
  2. Import map is processed by the polyfill instead of natively
  3. Module specifiers are resolved to actual URLs
  4. Modules load normally using the polyfill's resolution

Modern browsers skip step 1 entirely - they never load the polyfill script. The async attribute ensures the polyfill doesn't block page rendering.

Performance Impact

For modern browsers: Zero impact - they never download or execute the polyfill.

For older browsers: Minimal impact:

The trade-off is very favorable: modern browsers get optimal performance, while older browsers gain compatibility with a small overhead.

The Results

After deploying this change:

The user who reported the issue can now access the application using either their existing Chrome 103 or Firefox ESR 115, which receives security updates until mid-2025.

Lessons Learned

1. Match Requirements to Reality

The original browser requirements were based on Rails' recommendations for "modern" browsers, but our actual requirements were more modest:

By aligning the requirements with what the application actually needs, we expanded browser support by 3-4 years without compromising functionality.

2. Conditional Loading is Better Than "All or Nothing"

We could have:

The conditional approach provides compatibility without penalizing modern browsers.

3. User-Agent Detection Still Has Value

While feature detection is generally preferred for browser compatibility, User-Agent detection makes sense here:

4. Proactive Transpilation Enables Compatibility

By transpiling JavaScript to ES2020 from the start, the application was already prepared to support older browsers. The issue wasn't the JavaScript features themselves - it was the delivery mechanism (import maps). Having the transpilation layer in place meant we only needed to polyfill one missing piece rather than rewriting the application.

Why This Matters

Users on older systems aren't always there by choice:

By supporting browsers from 2020 (now 5 years old), we balance modern development practices with inclusive access. The ES2020 feature set is robust enough for contemporary applications while being widely supported.

And thanks to conditional polyfill loading, users on modern browsers pay no penalty for this backward compatibility.

Try It Yourself

If you're using importmap-rails and want to support older browsers:

  1. Check your esbuild/Babel target - what JavaScript version are you actually using?
  2. Identify the browser versions that support your target but lack import maps
  3. Add conditional polyfill loading using the pattern above
  4. Lower your minimum browser requirements to match your actual JavaScript requirements

Your users on older systems will thank you, and your users on modern browsers won't even notice.