Browser and device support.
"Which browsers do we support?" is a product question before it is a technical one. Railmoor answers it with a short written policy: three tiers drawn from its own traffic, plus 212 station kiosks stuck on a two-year-old engine. The code then keeps that promise the same way everywhere: start from HTML that works, ask the browser what it can do, and add the better version only where the answer is yes.
The idea.
Compatibility is decided twice. First as a policy, a written list of who must get what; then in code, which has to keep that promise on engines and screens you will never hold in your hand.
Railmoor sells regional rail tickets from railmoor.example. Most of its visitors arrive on a phone or a laptop running a browser that updated itself last month. A smaller group arrives in person: at 212 stations there is a ticket kiosk, a portrait touchscreen with no mouse and no typing keyboard, only a tactile arrow keypad beside the screen. The kiosk vendor froze its software on a Chromium 128 build from August 2024 and will not ship another one before the contract renews. Those kiosks produce 3.4% of Railmoor's sessions and 14% of its tickets.
Without a policy, every compatibility bug turns into an argument. Does the seat map have to animate on the kiosk? Must the site work in a browser from 2019? Is a layout glitch on a tablet in landscape a release blocker? A support policy settles these questions once. It names tiers: the browsers where everything must work and is tested on every change, the ones where the core task must work but extras may be missing, and the ones Railmoor promises nothing about. The tiers come from Railmoor's own traffic and from what each group is worth to the business, not from a list copied from another company.
The engineering half is how one codebase honours those tiers without forking. Railmoor starts every page from HTML that works on its own, asks the browser whether it has a capability before using it, and adds the richer version only where the answer is yes. The kiosk then gets the same site as everyone else, minus the few pieces its engine cannot do, plus a fallback wherever the missing piece matters.
Where Railmoor's sessions come from
- Chrome / Edge, width 582, Full tier, value 58.2%, last two major versions, desktop and Android
- Safari, width 274, Full tier, value 27.4%, last two major versions, macOS and iOS
- Firefox, width 39, Full tier, value 3.9%, last two majors plus ESR
- Samsung, width 16, Full tier, value 1.6%, Samsung Internet, current version
- Kiosks, width 34, Full tier, pinned engine, value 3.4%, Chromium 128, touch plus arrow keypad; 14% of tickets
- Older, width 46, Functional: core task only, value 4.6%, older versions of the browsers above
- width 9, Unsupported, value 0.9%, everything else
- full tier: 94.5% of sessions, from 0 to 945
- 5.5%, from 945 to 1,000
- Full tier
- Full tier, pinned engine
- Functional: core task only
- Unsupported
- Chrome / Edge
- last two major versions, desktop and Android
- Safari
- last two major versions, macOS and iOS
- Firefox
- last two majors plus ESR
- Samsung
- Samsung Internet, current version
- Kiosks
- Chromium 128, touch plus arrow keypad; 14% of tickets
- Older
- older versions of the browsers above
- rest
- everything else
Railmoor's support matrix
| Client | Share | Tier | What must work | How it is tested |
|---|---|---|---|---|
| Chrome and Edge, last 2 majors (desktop, Android) | 58.2% | Full | Every journey with every enhancement | Automated Chromium run on every pull request |
| Safari, last 2 majors (macOS, iOS) | 27.4% | Full | Every journey with every enhancement | Automated WebKit run on every pull request; a real iPhone weekly |
| Firefox, last 2 majors and ESR | 3.9% | Full | Every journey with every enhancement | Automated Firefox run on every pull request |
| Samsung Internet, current | 1.6% | Full | Every journey with every enhancement | Real Android phone before each release |
| Station kiosks: Chromium 128, portrait touchscreen, no mouse | 3.4% | Full (pinned engine) | Every journey; features newer than August 2024 through fallbacks | A pinned Chromium 128 binary in CI, and one real kiosk in the office |
| Older versions of the browsers above | 4.6% | Functional | Search, times and prices, the server-rendered checkout. No animation, plain controls | Monthly spot check |
| Anything else | 0.9% | Unsupported | Nothing promised. The plain HTML form usually works anyway, so no blocking wall | Not tested |
How it works.
Six parts: tiers drawn from data, a shared vocabulary for when a feature is safe, detecting capabilities at run time, layering the page so the core works first, build targets that decide what gets transpiled or polyfilled, and input modes, the part people forget.
Tiers from traffic
Start from the server's access logs, not only from the analytics script. A browser the site breaks in may never run that script, so analytics under-counts exactly the users you are failing. Group requests by engine and major version, and put devices with fixed software (kiosks, TVs, in-app web views, company-managed laptops) in rows of their own, since they will not update on your schedule.
Then draw the lines. The full tier is what you test on every change: current evergreen browsers plus any group whose business value earns a place. The functional tier is where the core task (search, choose, pay) must work but extras may be missing. The unsupported tier gets no testing and no promises, which is different from being blocked: a page built in layers usually still works there, and a "browser not supported" wall turns an accidental success into a certain failure. Write the policy down, give it an owner, and revisit it every quarter, because the browsers move even when your code does not.
Checking every feature against every browser table is slow, and teams used to argue about what "supported" meant. Baseline is the web platform's shared answer. It watches a core set of browsers: Chrome on desktop and Android, Edge, Firefox on desktop and Android, and Safari on macOS and iOS. A feature is newly available once the current version of every one of them supports it, and it is labelled with that year (Baseline 2025). Thirty months later it becomes widely available, meaning most sites can use it without a fallback. Before either milestone it has limited availability.
Baseline describes browsers, not your users. Railmoor's kiosks do not care what the latest Safari supports; they care what Chromium 128 supports. So Baseline is the default line for the full tier, and any row with a pinned engine gets checked by hand against the features you plan to use.
Four features Railmoor wants, on the Baseline clock
- width 2, Limited availability
- newly, Mar 2022, width 30, Newly available
- widely, Sep 2024, width 52, Widely available
- Oct 2026, edge at 57
- limited, width 23, Limited availability
- newly, Dec 2023, width 30, Newly available
- widely, Jun 2026, width 31, Widely available
- Oct 2026, edge at 57
- limited, width 38, Limited availability
- newly, Mar 2025, width 30, Newly available
- widely, Sep 2027, width 16, Widely available
- Oct 2026, edge at 57
- limited, width 47, Limited availability
- newly, Dec 2025, width 30, Newly available
- widely, Jun 2028, width 7, Widely available
- Oct 2026, edge at 57
- Limited availability
- Newly available
- Widely available
Detect, then enhance
From a need to working code, without asking which browser this is
Three kinds of detection Railmoor uses
// Booking-summary dialog: the button has command="show-modal" commandfor="summary".
const button = document.querySelector('#review');
const dialog = document.querySelector('#summary');
if (!('command' in HTMLButtonElement.prototype)) {
// Chromium 128 on the kiosks ignores command/commandfor, so wire it up by hand.
button.addEventListener('click', () => dialog.showModal());
}
// Journey length "1 hr, 13 min": load the polyfill only where it is missing.
if (!('DurationFormat' in Intl)) {
await import('./polyfills/duration-format.js');
}
/* The railcard picker: styled where the engine allows it, native elsewhere. */
@supports (appearance: base-select) {
.railcard,
.railcard::picker(select) {
appearance: base-select;
}
}
/* Seat map: highlight a carriage row that holds a selected seat. */
@supports selector(:has(a)) {
.carriage:has(.seat[aria-selected="true"]) {
outline: 2px solid var(--accent);
}
}
const canStyleSelect = CSS.supports('appearance', 'base-select');
const touchFirst = matchMedia('(pointer: coarse)').matches;
Enhance upwards, not downwards
Progressive enhancement builds upwards. The first layer is HTML that completes the task by itself: Railmoor's timetable search is a form that sends a GET to /timetable and gets a page of results back. CSS makes it look right, and a script, if it runs and if the browser passes its checks, turns the form into an in-place search with a station combobox and a calendar. Each layer is optional to the one below it.
Graceful degradation starts from the finished experience and patches it downwards: build for the newest browsers, then find what breaks elsewhere and add workarounds. It can reach a similar result, but the core task only works in older browsers if someone remembered to test there. Both have a place. Enhancement suits pages whose core is a form, a list or a document; degradation is honest for things with no simpler form, like a live seat map drawn on a canvas, where the fallback is a plain list of free seats rather than a lesser seat map.
How one Railmoor page arrives at its final form
Every path ends in a page that works; the only question is how rich it is. The failure path (script blocked or crashed) lands on the core page, not a blank one.
| From → To | Event | Guard | Action | Actor |
|---|---|---|---|---|
| Server HTML arrives → Styled core page | stylesheet loads | |||
| Styled core page → Script checks features | module script runs | |||
| Styled core page → Core page stays | script blocked or fails | network or content blocker | ||
| Script checks features → Enhanced page | all checks pass | use native APIs | ||
| Script checks features → Enhanced page | a check fails | fallback exists | load fallback | |
| Script checks features → Core page stays | a check fails | no fallback | leave the HTML alone |
- Server HTML arrivesstart
- The form and results already work
- Styled core page
- CSS applied; feature queries decide the extras
- Enhanced pageend
- In-place search and dialogs
- Core page staysend
- Full page loads per search
Build targets decide what gets rewritten
Detection handles features at run time; the build handles them before shipping. One file, .browserslistrc, lists the browsers the full tier covers, and every tool reads it: Babel's preset-env rewrites newer syntax and, with useBuiltIns set to usage, adds core-js imports only in files that use an API the targets lack; Autoprefixer adds the CSS prefixes those targets still need; eslint-plugin-compat flags calls to APIs they do not have. Change the policy and every tool follows.
Three different gaps need three different answers. Syntax (optional chaining, class fields) can be transpiled. A missing API (Intl.DurationFormat) can often be polyfilled with script. Platform behaviour (a styleable select, the top layer, a new media feature) usually cannot be recreated at all, so it needs a fallback or nothing. The old trick of shipping two bundles, a modern one with type="module" and a legacy one with nomodule, belongs to the era when a large share of traffic could not run modules; every browser in a current full tier can, so one modern bundle plus targeted fallbacks is simpler.
Railmoor's build targets
# Full tier: what Baseline calls widely available today…
baseline widely available
# …plus the station kiosks, written out so the line above can move on without them.
chrome 128
{
"presets": [
["@babel/preset-env", { "useBuiltIns": "usage", "corejs": "3.40" }]
]
}
Which gap, which fix
| Kind of gap | Railmoor example | Fix | Cost |
|---|---|---|---|
| New syntax | a?.b, class fields | Transpiled at build time from the browserslist targets | Slightly larger code, paid by every browser in the targets |
| Missing API | Intl.DurationFormat | Polyfill, loaded only when a check finds it missing | Bytes and a request, only on the browsers that need it |
| Missing declarative wiring | command / commandfor | A few lines of script that do the same job on click | Code to keep until the fallback can be deleted |
| Platform behaviour | appearance: base-select | No polyfill is possible; gate the styling with @supports and keep the native control | Nothing, except a plainer look |
Input modes, not device names
Screen size says nothing reliable about input. A laptop can have a touchscreen, a tablet can have a keyboard and trackpad, and the kiosk is a large screen whose only pointer is a finger. CSS media features describe the input directly. pointer tells you how precise the primary pointing device is (fine for a mouse, coarse for a finger, none), and hover tells you whether it can hover conveniently. any-pointer and any-hover ask the same about every input attached, which is how you notice the stylus on a touch laptop. In script, pointer events report pointerType as mouse, pen or touch per event, so one handler can serve all three.
The rule that follows: hover may add a hint, but it must never be the only way to reach an action. Anything revealed on hover also needs to appear on keyboard focus, and on a device with hover: none it should simply be visible. Tooltips shown on hover or focus have their own WCAG 2.2 rule (1.4.13): they must be dismissible, stay up while the pointer moves onto them, and stay visible until the pointer or focus leaves, the user dismisses them, or they stop being relevant. Target size and zoom are covered with the other perceivable-UI requirements in Announcing changes and respecting preferences.
What each query says about the Railmoor clients
| Query | Asks about | Laptop with mouse | Phone | Kiosk |
|---|---|---|---|---|
| pointer | The primary input's precision | fine | coarse | coarse |
| hover | Whether the primary input can hover | hover | none | none |
| any-pointer: fine | Any attached input is precise | matches | no match | no match |
| any-hover: hover | Any attached input can hover | matches | no match | no match |
Actions that do not depend on hover
/* Row actions (Seat map, Save) are visible by default… */
.departure .actions { opacity: 1; }
/* …and tucked away only where hovering is easy, never hidden from focus. */
@media (hover: hover) and (pointer: fine) {
.departure:not(:hover, :focus-within) .actions { opacity: 0; }
}
/* Bigger hit areas for fingers. */
@media (pointer: coarse) {
.departure .actions button { min-block-size: 44px; }
}
In practice.
Railmoor's booking journey on three clients: a laptop on a current browser, a phone, and a station kiosk frozen on Chromium 128. Same code, same URLs, different layers switched on.
What the kiosk's Chromium 128 can and cannot do
| Feature Railmoor uses | Baseline | In Chromium 128? | What Railmoor does |
|---|---|---|---|
| <dialog> with showModal() for the booking summary | Widely available (Baseline since March 2022) | yes | Use it as is |
| :has() to highlight a carriage with a chosen seat | Widely available since June 2026 | yes | Use it as is |
| popover for the fare-rules hint | Baseline 2025 (January 2025) | yes, since Chromium 114 | Use it as is: Chromium had it long before the other engines |
| command / commandfor to open the summary | Baseline 2025 (December 2025) | no, Chrome 135 | A click listener calls showModal() when the check fails |
| Intl.DurationFormat for "1 hr, 13 min" | Baseline 2025 (March 2025) | no, Chrome 129 | A polyfill, loaded only when the check fails |
| appearance: base-select for the railcard picker | Chromium first, from Chrome 135 | no | Nothing: @supports skips the styling and the native select remains |
The audit held two surprises. The features the team feared most (the modal dialog and :has()) were already in the kiosk's engine; Baseline dates track the slowest core browser, and Chromium had often shipped years earlier. The features that were actually missing were small and recent, and each had a cheap answer: a few lines of script, one lazily loaded polyfill, and one styling rule that simply does not apply.
The timetable search on three clients
On the laptop every check passes. From and To are comboboxes, Date opens a calendar, Railcard is a styled select, and results replace the form's lower half without a page load.
Fully enhanced. Laptop · current Chrome: Form “Find a train”. Fields: From = “Ashcombe Junction”; To = “Kestrel Bay”; Date = “Wed 14 Oct”; Railcard = “None” (focused). Button: “Find trains”. Links: Plan with more options. Note on railcard: styled with appearance: base-select Note on from: station combobox (script) Station kiosk · Chromium 128, touch: Form “Find a train”. Fields: From = “Ashcombe Junction”; To = “Kestrel Bay”; Date = “Wed 14 Oct”; Railcard = “None”. Button: “Find trains”. Links: Plan with more options. Phone · script did not load: Form “Find a train”. Fields: From = “Ashcombe Junction”; To = “Kestrel Bay”; Date = “Wed 14 Oct”; Railcard = “None”. Button: “Find trains”. Links: Plan with more options.
- Enhanced only where @supports allows
- A real submit button, not a click handler on a div
One URL, two ways to fetch it
- Note over Page in the browser and railmoor.example: Without script: the form does all the work
- Traveller → Page in the browser: taps Find trains
- Page in the browser → railmoor.example: GET /timetable?from=ashcombe-jn&to=kestrel-bay&date=2026-10-14
- railmoor.example → Page in the browser (reply): 200 full HTML page with 8 trains
- Note over Page in the browser and railmoor.example: With script: the same request, intercepted
- Traveller → Page in the browser: taps Find trains
- Note over Page in the browser: submit handler: preventDefault(), show a busy state
- Page in the browser → railmoor.example: fetch(): GET /timetable?…&partial=results
- railmoor.example → Page in the browser (reply): 200 results fragment
- Note over Page in the browser: swap the results in; history.pushState(/timetable?…) so Back and shared links work
Row actions on a mouse and on a finger
The pointer rests on the 08:40 row, so its actions fade in. With a mouse this is a tidy list; the same actions also appear when the row gets keyboard focus.
Hover on the laptop. Laptop · mouse: List “Ashcombe Junction to Kestrel Bay”: 3 items. Sort: Departs. Count: 8 trains. 1. 08:12 → 09:25 (Direct · 1 hr, 13 min) · Platform 3 2. 08:40 → 10:02 (1 change · 1 hr, 22 min) · Platform 1 Seat map, Save. Hovered: overlay actions shown. 3. 09:05 → 10:18 (Direct · 1 hr, 13 min) · Platform 3 Kiosk · touch: List “Ashcombe Junction to Kestrel Bay”: 3 items. Sort: Departs. Count: 8 trains. 1. 08:12 → 09:25 (Direct · 1 hr, 13 min) · Platform 3 2. 08:40 → 10:02 (1 change · 1 hr, 22 min) · Platform 1 3. 09:05 → 10:18 (Direct · 1 hr, 13 min) · Platform 3
- Actions: visible unless hover is easy
- Pro:Kiosks get every fix and feature the day everyone else does
- Pro:Each fallback is small and can be deleted when the kiosks update
- Pro:Other old browsers benefit from the same checks for free
- Con:A pinned Chromium 128 must stay in the CI matrix
- Con:Fallback code needs an owner and a removal date
Two builds to test and deploy, which drift apart; Sniffing which build to serve brings back user-agent checks
Out of Railmoor's control until the contract renews; Kiosks would fail in the meantime
Every user pays for the oldest client; the web moves on without you
Saying it in an interview
Where the system flows use this
Trade-offs.
Reach costs bytes, test time and code you must later delete. The choices below are where Railmoor paid those costs and where it declined to.
- Pro:Answers the real question (does this API exist here?)
- Pro:Right for browsers you have never heard of, including new ones
- Pro:Keeps working when a browser fixes or adds a feature
- Con:A check can pass while the feature is buggy; those cases need a test, not a guess
- Con:Some things (a buggy layout, a rendering quirk) cannot be detected
Browsers include each other's names in their UA strings; Version rules go stale with every release; Browsers not in the rules get the worst branch even when capable
Chromium-based browsers only; Still describes the brand, not the capability; fine for analytics, not for deciding features
Main bundle size by build target
Data
| Build target | KB gzip |
|---|---|
| Baseline widely available | 142 |
| + chrome 128 | 142 |
| + polyfill for everyone | 151 |
| ES5 with full core-js | 196 |
What the test matrix costs
- Engine targets
- 4Chromium, Firefox, WebKit, pinned Chromium 128
- Viewports
- 3phone, laptop, portrait kiosk
- Input modes
- 2mouse + keyboard, touch
- Critical journeys
- 8search, seat choice, pay, change booking…
- Minutes per journey run
- 1.5
- Parallel CI workers
- 8
- Every combination4 × 3 × 2 × 8192 runsfrom Engine targets, Viewports, Input modes and Critical journeys
- Serial time for all of them192 × 1.5288 minfrom Every combination and Minutes per journey run
- Combinations that exist (phone + touch and laptop + mouse on 3 engines, plus the kiosk)3 × 2 + 17 combinationsfrom Engine targets, Viewports and Input modes
- Runs per pull request7 × 856 runsfrom Combinations that exist (phone + touch and laptop + mouse on 3 engines, plus the kiosk) and Critical journeys
- Wall-clock on 8 workers56 × 1.5 / 810.5 minfrom Runs per pull request, Minutes per journey run and Parallel CI workers
- Testing the product of every dimension wastes most of the effort on combinations nobody uses; test the pairings your traffic shows.
- Automated engines are not real devices. Keep a weekly pass on a real iPhone and on the office kiosk for the things emulation misses: the on-screen keyboard, touch latency, a glare-bright screen.
How compatibility breaks after launch
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| A check passes but the feature misbehaves | The native path runs and a flow breaks in one engine version | Error reports grouped by engine and version; the pinned engine in CI | Test the behaviour, not only its presence; add a targeted check for the known bug | Other engines unaffected |
| Hover-only affordance ships | Touch and keyboard users cannot reach an action | A touch run and a keyboard-only run of every journey in CI | Actions visible by default, hidden only under (hover: hover) and (pointer: fine) | Mouse users never notice, which is why it ships |
| The Baseline query moves past a pinned client | New syntax reaches the kiosks and the script fails to parse | The pinned Chromium 128 CI run goes red | Name pinned engines explicitly in .browserslistrc | The core HTML page still works while it is fixed |
| Fallbacks never get deleted | Dead code and polyfills ship for years | A quarterly policy review against the traffic table | Give every fallback an owner and a removal condition (kiosks updated, share under 0.5%) | Nothing breaks; the bundle just grows |