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:
- Chrome up to version 109 (released January 2023)
- Firefox ESR up to version 115 (supported until mid-2025)
- Safari 12 (ancient)
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:
- Chrome 89+ (March 2021)
- Firefox 108+ (December 2022)
- Safari 16.4+ (March 2023)
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:
- Chrome 80+ (February 2020)
- Firefox 74+ (March 2020)
- Safari 13.1+ (March 2020)
- Edge 80+ (February 2020)
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:
- es-module-shims loads and intercepts module requests
- Import map is processed by the polyfill instead of natively
- Module specifiers are resolved to actual URLs
- 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:
- es-module-shims.js is ~20KB gzipped
- Loads asynchronously
- Cached by the browser
- Only processes import map once
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:
- ✅ Chrome 103 on macOS 10.12 works perfectly
- ✅ Firefox ESR 115 on macOS 10.12 works perfectly
- ✅ Modern browsers (Chrome 119+, Firefox 121+, Safari 17.2+) don't load the polyfill
- ✅ No 426 errors for ES2020-capable browsers
- ✅ All existing functionality continues to work
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:
- ES2020 JavaScript features (determined by esbuild target)
- WebSocket support (for live scoring features)
- Import maps (polyfillable)
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:
- Loaded the polyfill for everyone - simple but wasteful for 95%+ of users
- Not supported older browsers - clean but excludes users on older systems
- Conditionally load based on detection - best of both worlds
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:
- The polyfill decision happens on the server before any JavaScript runs
- We know exactly which browser versions need the polyfill
- The detection is simple and reliable
- False positives just load an unnecessary 20KB script (not catastrophic)
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:
- Older hardware can't run newer operating systems
- Budget constraints prevent purchasing new devices
- Some users simply don't upgrade frequently
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:
- Check your esbuild/Babel target - what JavaScript version are you actually using?
- Identify the browser versions that support your target but lack import maps
- Add conditional polyfill loading using the pattern above
- 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.