Engineering
Three Browser Workarounds That Are Now Technical Debt
A feature becomes Baseline Widely Available once it has worked in every major browser engine for 30 months. Existing code doesn’t break on that date. One reason to keep the workaround goes away. If your browser support follows Baseline, code written around the missing feature may now be technical debt you can remove.
Since October 2026, you can revisit three workarounds in legacy codebases: a manual request cancellation helper, a flex container used only to centre an empty state, and a character counter. Here are the dates from the web-features v3.44.0 data:
| Feature | Widely Available since | First versions: Chrome / Edge, Firefox, Safari |
|---|---|---|
AbortSignal.any() | 19 September 2026 | 116, 124, 17.4 |
align-content in block layout | 16 October 2026 | 123, 125, 17.4 |
Intl.Segmenter | 16 October 2026 | 87, 125, 14.1 |
AbortSignal.timeout() | 18 October 2026 | 124, 100, 16 |
For each one we show what we used to write, what we can write now, what that changes, and what to do if you still support older browsers. The request and character-count demos use implementations covered by tests in the repository. The alignment demo measures the layout in your browser. The last section shows how to find out about the next transition without reading release notes.
1. Request deadlines: AbortSignal.timeout() and AbortSignal.any()
A manual cancellation helper creates a controller, starts a timer, forwards the caller’s cancel signal and cleans up afterwards.
The example loads a user, their roles and recent activity as one group. Leaving the route, pressing Stop or reaching the deadline must cancel every request still waiting. Starting again cancels the previous load, and an error in one request stops the others. Both source files implement that contract. The live diagram runs the native version.
Live example
Cancel three requests with one signal
This example runs in your browser and needs JavaScript. The code it runs is below.
// Before: forward the router's signal, manage the deadline and clean up both.
let current;
export function cancelProfile() {
current?.abort();
}
export async function loadProfile(navigationSignal, { timeout = 2000 } = {}) {
current?.abort(); // a new load cancels the previous one
const load = new AbortController();
current = load;
const signal = load.signal;
// The router owns this signal. Its abort event may have happened before this call.
if (navigationSignal.aborted) throw navigationSignal.reason;
const onNavigationAbort = () => load.abort(navigationSignal.reason);
navigationSignal.addEventListener('abort', onNavigationAbort, { once: true });
const timer = setTimeout(
() => load.abort(new DOMException('The deadline expired.', 'TimeoutError')),
timeout,
);
const get = (path) =>
fetch(path, { signal })
.then((response) => {
if (!response.ok) throw new Error(`${path} answered ${response.status}`);
return response.json();
})
.catch((error) => {
load.abort(error); // all or nothing: stop the other requests
throw error;
});
try {
const [user, roles, activity] = await Promise.all([
get('/demo/user'),
get('/demo/roles'),
get('/demo/activity'),
]);
return { user, roles, activity };
} finally {
clearTimeout(timer);
navigationSignal.removeEventListener('abort', onNavigationAbort);
}
}// Now: the platform combines the router's signal, Stop and the deadline.
let current;
export function cancelProfile() {
current?.abort();
}
export async function loadProfile(navigationSignal, { timeout = 2000 } = {}) {
current?.abort(); // a new load cancels the previous one
const load = new AbortController();
current = load;
const signal = AbortSignal.any([
navigationSignal,
load.signal,
AbortSignal.timeout(timeout),
]);
signal.throwIfAborted(); // do not start work if navigation already cancelled it
const get = (path) =>
fetch(path, { signal })
.then((response) => {
if (!response.ok) throw new Error(`${path} answered ${response.status}`);
return response.json();
})
.catch((error) => {
load.abort(error); // all or nothing: stop the other requests
throw error;
});
const [user, roles, activity] = await Promise.all([
get('/demo/user'),
get('/demo/roles'),
get('/demo/activity'),
]);
return { user, roles, activity };
}What changes
Both implementations already handle cancelled navigation, deadlines and competing cancellation reasons. As code refactoring goes, the gain here is simple: less application code to maintain.
In before.js, the application checks whether navigation has already been cancelled, forwards later abort events and creates a timer. It then removes the listener and clears the timer in finally. In now.js, AbortSignal.any() combines the inputs and throwIfAborted() checks their state before work starts. AbortSignal.timeout() supplies the deadline without an application-owned timer.
The manual version creates a TimeoutError for its deadline, while the native version gets one from the API. Stop produces AbortError in both. The original proposal discusses why applications need to distinguish a timeout from intentional cancellation. Both versions also read the response bodies before completing the load and explicitly abort the remaining requests on failure. Promise.all() does not cancel its members.
AbortSignal.timeout() counts active time and can pause while a page sits in the back-forward cache, so it isn’t a wall-clock guarantee. Aborting a request doesn’t stop work on the server either. If the operation changes data, “we stopped waiting” doesn’t mean “it failed”, and retrying automatically may not be safe.
To find this debt, search for shapes rather than function names: setTimeout and .abort( in the same function, Promise.race with a timer (the worst variant, because the request keeps running after the race settles), a hand-written “merge signals” helper, or an HTTP client wrapper that handles its timeout option separately from signal. Before you replace any of them, write down what else the helper does, such as telemetry, retries, error labels or a library adapter, because those jobs need a new owner.
A timeout signal starts counting when you create it. Both versions in the example create a fresh deadline for each load. If you add retries, create another deadline inside each attempt instead of reusing the previous signal.
Fallback for older browsers
The native version needs both features, so its real minimum is the later of the two: Chrome and Edge 124, Firefox 124, Safari 17.4. If you support anything older, feature-detect both methods and provide a fallback for each one that is missing. For example, here is a fallback helper for AbortSignal.any():
export function anySignal(signals) {
if (typeof AbortSignal.any === "function") {
return AbortSignal.any(signals);
}
const list = [...signals];
if (list.some((signal) => !(signal instanceof AbortSignal))) {
throw new TypeError("Expected AbortSignal");
}
const controller = new AbortController();
for (const signal of list) {
if (signal.aborted) {
controller.abort(signal.reason);
return controller.signal;
}
}
for (const signal of list) {
signal.addEventListener("abort", () => controller.abort(signal.reason), {
once: true,
signal: controller.signal,
});
}
return controller.signal;
}This helper checks for an already-aborted input and preserves its cancellation reason. It covers AbortSignal.any() only. If you also need a fallback for AbortSignal.timeout(), remember that an ordinary timer does not reproduce the native deadline’s active-time behaviour.
The listeners come off only when the combined signal aborts. If a request finishes normally, the listener on the caller’s signal remains until an input aborts, for example when the deadline expires later. The explainer for AbortSignal.any() describes the difficulty of releasing these references correctly in application code. For a long-lived signal shared by many requests, such as a route or session signal, use the manual version with explicit cleanup instead. Once your support policy moves past these browser versions, delete the helper and call the native methods directly.
2. Centred content: align-content in block layout
An empty state has a heading, a sentence and a button. To centre the group vertically in a fixed-height panel, the usual answer was Flexbox.
With align-content: center, the element stays an ordinary block. align-content has worked in flex and grid layouts for years. What became Widely Available is its effect on block containers. The example compares that block with the flex container and shows both stylesheets.
Live example
Keep centred content reachable when it overflows
This example runs in your browser and needs JavaScript. The code it runs is below.
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-block-size: 14rem;
text-align: center;
}.empty-state {
align-content: center;
min-block-size: 14rem;
text-align: center;
}What changes
Dropping display: flex keeps the children in normal flow. We checked the layout behaviour in Chromium 152. The measurements below are from the example at a 1440 px viewport, with Short text selected and Panel height set to 224 px unless stated otherwise. The chips update when you change those settings or the window width.
- The heading and the paragraph each have
0.75remof vertical margin. These collapse into a 12 px gap in the block version. The flex version has a 24 px gap because margins between flex items don’t collapse. If you addedgapor a modifier class to compensate, review that rule when removing the flex container. - With
align-items: center, the flex heading measures 203 px wide. In the block version it fills the available width, 312 px in this setting. The paragraph fills the panel too, andtext-aligncentres the button like any other inline content. Longer translations can use the panel’s full text width. - Switch to Long text and lower Panel height to 80 px. At the same viewport width, the flex heading starts 65 px above the top edge, where scrolling can’t reach it. The block version starts at 0 px and scrolls normally. The cutoff depends on the content and panel size, so check the top chip as you adjust them. The specification makes block alignment fall back to start when the content overflows. Flex and grid need
safe centerto do the same.
Any value other than normal also makes the block container establish an independent formatting context. The first and last child’s margins stop collapsing through the panel’s edges, and floats inside it stay inside. For an empty state that’s usually what you want.
Existing declarations can start working too. Until now align-content on an ordinary block did nothing, so stylesheets can contain it by accident, copied from a flex rule, left over after a refactor or set in a reset. Those declarations now take effect, and the Chrome team’s announcement recommends searching for them. Look for align-content on selectors that aren’t flex or grid containers and remove the ones nobody meant.
An inline <img>, <video> or <canvas> sits on the text baseline, with a small gap below it for descenders. Block alignment centres the whole line, gap included, so the image sits slightly high. In the Chromium 152 check, an 80 px image in a 200 px panel with a 32 px font sat 54 px from the top and 66 px from the bottom. With vertical-align: top on the image it was 60 and 60. The interactive example’s Image option lets you compare that effect at other panel sizes. Temani Afif describes the same effect.
None of this replaces what Flexbox does beyond centring. If the card pushes an action to the bottom, distributes space between items or changes direction on narrow screens, keep the flex container, and check what it’s responsible for before you remove one.
Fallback for older browsers
You can’t detect this feature with a query. Browsers before Chrome 123, Firefox 125 and Safari 17.4 already understand align-content because of Flexbox, so @supports (align-content: center) is true in them as well. The declaration just has no effect on a block.
So you can do one of two things:
-
Do nothing. In an older browser the content is top-aligned, everything is still readable and the button still works. For most empty states and placeholder panels that’s an acceptable fallback.
-
If central placement really matters in older browsers, use Grid, keep it safe, and keep the children at full width:
css.empty-state { display: grid; align-content: start; /* used where `safe` is not supported */ align-content: safe center; min-block-size: 14rem; text-align: center; } .empty-state > button { justify-self: center; }Without an explicit
align-content, grid rows stretch to fill the panel and the three children spread from top to bottom.justify-items: centerwould shrink the heading and paragraph the wayalign-items: centerdid in the flex version, so only the button gets centred on its own. Thesafekeyword prevents the overflow problem above, but it’s newer than grid itself (MDN: overflow position). Where it’s missing, the browser drops that declaration and keepsstart, which gives you top-aligned content again. Grid children are grid items, so their margins don’t collapse, and you manage spacing withgap.
3. Character counts: Intl.Segmenter
A field says “Up to 80 characters” and a counter shows how many are left. A counter based on text.length measures UTF-16 code units, as the native maxlength attribute does. The manual examples also count code points with [...text] and combine emoji sequences with a regular expression.
Intl.Segmenter with granularity: "grapheme" splits text into the units a person sees, selects and deletes. The example compares all four approaches, including truncation. Their implementations are shown below it.
Live example
Count characters without cutting emoji apart
This example runs in your browser and needs JavaScript. The code it runs is below.
export const countLength = (text) => text.length;
export const countCodePoints = (text) => [...text].length;
export function truncateOld(text, max) {
return text.length > max ? `${text.slice(0, max)}…` : text;
}
// The usual home-made fix: glue back what [...text] splits apart. A flag is two regional
// indicators; an emoji may carry a skin tone and a variation selector and be joined to the
// next one with ZWJ; a letter may be followed by combining marks. Every sequence Unicode adds
// needs another branch here.
const CHARACTER =
/\p{RI}{2}|\p{Extended_Pictographic}\p{EMod}?\uFE0F?(?:\u200D\p{Extended_Pictographic}\p{EMod}?\uFE0F?)*|\P{M}\p{M}*/gsu;
export const countWithRegex = (text) => text.match(CHARACTER)?.length ?? 0;
export function truncateWithRegex(text, max) {
const characters = text.match(CHARACTER) ?? [];
return characters.length > max ? `${characters.slice(0, max).join('')}…` : text;
}const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
export function countCharacters(text) {
let count = 0;
for (const _ of graphemes.segment(text)) count++;
return count;
}
export function truncate(text, max) {
let end = 0;
let count = 0;
for (const { index, segment } of graphemes.segment(text)) {
if (count === max) return `${text.slice(0, end)}…`;
end = index + segment.length;
count++;
}
return text;
}What changes
Even the basic counts disagree:
| Text | length | Code points | Graphemes |
|---|---|---|---|
Lisboa | 6 | 6 | 6 |
Café (é as one character) | 4 | 4 | 4 |
Café (e + combining accent) | 5 | 5 | 4 |
| 👍🏽 | 4 | 2 | 1 |
| 👨👩👧 | 8 | 5 | 1 |
| 🇵🇹 | 4 | 2 | 1 |
The two spellings of “Café” look identical, yet a code-unit counter charges them differently against the limit, and a family emoji costs eight. The regular expression gets these table rows right, but the live example also includes Scotland’s flag and decomposed Hangul. The regular expression splits the flag into seven pieces instead of one grapheme, and the Hangul example into six instead of two.
Truncation makes the problem easier to see. truncateOld("Equipa 🇵🇹 Porto", 8) cuts the flag in half and leaves a lone surrogate, which shows up as a broken glyph and isn’t even well-formed text. truncate() returns Equipa 🇵🇹….
You also stop maintaining code. There’s no dependency for grapheme splitting and no home-made emoji regular expression to update every time Unicode adds a sequence, because the rules ship with the browser.
Before you ship the new counter, check these:
- Once the counter shows graphemes, it disagrees with the code-unit limit in
maxlength. The field can stop accepting input while the counter still says there’s room. Either dropmaxlengthand validate in your own code, or keep code units everywhere on purpose. - If the backend counts bytes or code units, it can reject a value the counter accepts. Node.js has
Intl.Segmentertoo, so the same function can run on both sides. Storage limits measured in bytes remain a separate constraint. - Segmentation follows the Unicode data in each runtime, and grapheme rules change between Unicode versions, as Nikita Prokopov explains. A very new emoji sequence can count as more than one grapheme in an older browser or server, so leave some room in the limit instead of relying on exact agreement at the boundary.
- The two spellings of “Café” still have different string values, even though they now count the same.
===, search and uniqueness checks can treat them differently. If that matters, calltext.normalize("NFC")where you compare, and decide separately whether to store the normalized form. - Input methods compose text through intermediate states. Show that the limit has been exceeded and let the person fix it, rather than cutting the value on every keystroke.
Fallback for older browsers
Intl.Segmenter only reached Firefox in version 125, so this is the fallback most likely to matter if you support older Firefox ESR releases or embedded browsers. How you fall back depends on how wrong you can afford to be:
function makeCounter() {
if (typeof Intl.Segmenter === "function") return countCharacters;
return (text) => [...text].length; // code points: close, but see the table above
}Counting code points is right for ordinary text and over-counts emoji sequences and combining marks. For a description field that’s usually fine: the person may get a little less room than promised, and the server, running a current engine, still applies the real rule. If the count has to be exact everywhere, load a grapheme-splitting polyfill only when Intl.Segmenter is missing, so current browsers don’t pay for it.
4. Managing technical debt with a Baseline target in Browserslist
You probably didn’t have these dates in your calendar. Browserslist, the browser-target config that Babel, Autoprefixer and many linters already read, understands Baseline queries (web.dev guide):
# .browserslistrc
baseline widely availableThis query moves by itself as features pass the 30-month mark. We resolved it for three dates with browserslist 4.29.4 and kept the lowest version of each core browser:
| Query date | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| 1 October 2026 | 123 | 124 | 17.4 |
| 17 October 2026 | 123 | 125 | 17.4 |
| 19 October 2026 | 124 | 125 | 17.4 |
Firefox 125 came in on 16 October with Intl.Segmenter and align-content in block layout, and Chrome 124 on 18 October with AbortSignal.timeout(). The mapping lives in the baseline-browser-mapping package that Browserslist depends on, so your target only moves when you update dependencies.
If you’d rather move the target on purpose, pin a date:
baseline widely available on 2026-10-19Raising the date then becomes a pull request, and its code review is a good moment to ask which workarounds the new target makes unnecessary. The three above are what the step from 1 October to 19 October removed.
Check what your build tool actually targets
Many build tools read Browserslist, including webpack, Next.js, Rsbuild and Parcel. Others have their own target setting. Check the version and the effective configuration before treating a Baseline policy as the build’s actual target. These are the defaults documented in October 2026:
| Build tool | Baseline by default | Where to configure it |
|---|---|---|
| Vite 8 | Widely Available as of 1 January 2026 | build.target |
| Vite 7 | Widely Available as of 1 May 2025 | build.target |
| Rsbuild 2 | Widely Available as of 1 May 2025 | Browserslist; Baseline queries supported from 2.1.11 |
| Angular 20–22 | Widely Available; the date depends on the major version | Angular’s support policy and the CLI’s Browserslist config |
| webpack 5 | No fixed Baseline default | Browserslist for the runtime; Babel or SWC for application code |
| Parcel 2 | No Baseline default | Browserslist |
| Next.js | A fixed set of modern browsers | Browserslist |
| esbuild | No; the default is esnext | target |
Vite calls its default build.target value 'baseline-widely-available', but the date is fixed for each major version. It does not advance with the calendar like the Browserslist query. Vite 8’s January 2026 default still includes browsers missing the features that became Widely Available in October. Check the resolved browser versions before removing the workarounds above.
For Vite or esbuild, use the resolved browser versions to set build.target or target. A .browserslistrc file alone does not configure those options. With webpack, keep the runtime target and the Babel or SWC targets in step. The Angular CLI reads Browserslist too, but its framework support policy still sets the outer limit. Syntax transforms also do not supply missing Web APIs such as AbortSignal.any() or Intl.Segmenter. Polyfills remain a separate decision.
Write the target down for people and AI coding tools
A support policy that lives in someone’s head can’t be followed by a new teammate, and an AI coding tool can’t follow it either. Without a stated target, the tool has no way to know whether it may use AbortSignal.any() or should write a manual helper. Keep the target in the repository, where both can read it, and point to it from your agent instructions file (AGENTS.md, CLAUDE.md or similar). For Vite or esbuild, point to the build configuration. For a tool that reads Browserslist, the instruction could be:
## Browser support
Targets are in `.browserslistrc`: Baseline Widely Available, pinned to a date.
Use platform features inside that target directly, without polyfills or fallbacks.
When the date changes, list workarounds the new target makes unnecessary.Linters then turn the policy into a check for both. eslint-plugin-compat reads the Browserslist config and flags JavaScript APIs your targets don’t support, and the ESLint CSS plugin has a use-baseline rule for CSS.
Before, now and fallback
| Before | Now | If you support older browsers | |
|---|---|---|---|
| Request deadline | AbortController + setTimeout + manual listener cleanup | AbortSignal.timeout() + AbortSignal.any() | Feature detection for both APIs; any() fallback shown above |
| Centred content | Flex container just for centring | align-content: center on the block | Accept top alignment, or use grid with safe center |
| Character count | length or code points | Intl.Segmenter graphemes | Code points, or a polyfill loaded on demand |
If you’d like a second pair of eyes on which workarounds in your codebase are worth retiring and which are still doing real work, that’s part of what a Frontend Risk Audit looks at.