Back to insights

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:

FeatureWidely Available sinceFirst versions: Chrome / Edge, Firefox, Safari
AbortSignal.any()19 September 2026116, 124, 17.4
align-content in block layout16 October 2026123, 125, 17.4
Intl.Segmenter16 October 202687, 125, 14.1
AbortSignal.timeout()18 October 2026124, 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.js
// 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.js
// 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():

js
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.

before.css
.empty-state {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  min-block-size: 14rem;
  text-align: center;
}
now.css
.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.75rem of 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 added gap or 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, and text-align centres 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 center to 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:

  1. 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.

  2. 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: center would shrink the heading and paragraph the way align-items: center did in the flex version, so only the button gets centred on its own. The safe keyword 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 keeps start, which gives you top-aligned content again. Grid children are grid items, so their margins don’t collapse, and you manage spacing with gap.

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.

before.js
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;
}
now.js
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:

TextlengthCode pointsGraphemes
Lisboa666
Café (é as one character)444
Café (e + combining accent)554
👍🏽421
👨‍👩‍👧851
🇵🇹421

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 drop maxlength and 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.Segmenter too, 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, call text.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:

js
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):

text
# .browserslistrc
baseline widely available

This 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 dateChrome / EdgeFirefoxSafari
1 October 202612312417.4
17 October 202612312517.4
19 October 202612412517.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:

text
baseline widely available on 2026-10-19

Raising 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 toolBaseline by defaultWhere to configure it
Vite 8Widely Available as of 1 January 2026build.target
Vite 7Widely Available as of 1 May 2025build.target
Rsbuild 2Widely Available as of 1 May 2025Browserslist; Baseline queries supported from 2.1.11
Angular 20–22Widely Available; the date depends on the major versionAngular’s support policy and the CLI’s Browserslist config
webpack 5No fixed Baseline defaultBrowserslist for the runtime; Babel or SWC for application code
Parcel 2No Baseline defaultBrowserslist
Next.jsA fixed set of modern browsersBrowserslist
esbuildNo; the default is esnexttarget

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:

md
## 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

BeforeNowIf you support older browsers
Request deadlineAbortController + setTimeout + manual listener cleanupAbortSignal.timeout() + AbortSignal.any()Feature detection for both APIs; any() fallback shown above
Centred contentFlex container just for centringalign-content: center on the blockAccept top alignment, or use grid with safe center
Character countlength or code pointsIntl.Segmenter graphemesCode 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.

Privacy settings

Choose whether to allow PostHog analytics and session replay. Both remain off until you allow them. You can use the website and contact form without them.

Your saved choice: Not selected

Services and data use

PostHog Cloud EU — optional analytics and session replay

The following functions load only after your permission:

  • Visit and interaction analytics. Page views, selected actions and form submission outcomes help us understand how the website is used and where visitors encounter difficulties.
  • Error and performance monitoring. Browser errors and page performance measurements help us identify technical problems and check fixes.
  • Session replay. Navigation, clicks, scrolling and pointer movements help us investigate interface problems. All page text is masked and the entire contact form is excluded before recording data is sent. Replay does not record your desktop, other applications, camera or microphone.

A random identifier stored in your browser links repeat visits, events and recordings to a pseudonymous profile. These data are not fully anonymous. We do not send your name, email address or form contents to PostHog.

Cloudflare estimates your country from the network connection. We send the country code to PostHog; the original IP address, city and precise location are not stored in analytics events.

We do not sell these data or share them for third parties’ own marketing.

Cloudflare — website operation and security

Cloudflare hosts and delivers the website and protects it from abuse. Turnstile checks browser and connection signals to help prevent automated form submissions. We do not send your form message to the Turnstile widget.

Operational measurements help us monitor technical requests, errors and form processing. We rely on our legitimate interests in keeping the website available, preventing abuse and diagnosing technical faults. We limit this processing to what is necessary for those purposes.

These functions operate independently of your analytics choice. Refusing analytics does not disable them.

Cloudflare also processes and delivers contact-form messages. Permission to use your enquiry to reply is requested separately in the form.

Preferences saved in your browser

We save your selected colour theme and your analytics choice (allow or refuse) in this browser so we can apply them on future visits. These preferences are not used to track visits.

They use local browser storage (localStorage) and work independently of analytics permission.

Your analytics choice is saved for 180 days. This is the duration of your saved choice, not the retention period for analytics data.

Changing your choice and requesting deletion

You can change your choice at any time through Privacy Policy and privacy settings in the footer.

Do not allow stops future analytics and recording. It does not delete data already collected.

To request deletion of analytics linked to this browser, use Request deletion of analytics data on the privacy settings page. The request covers the associated profile, events and session recordings. Deletion is processed asynchronously.

See the Privacy Policy for data retention periods and further information about providers and processing.

Read Privacy Policy ↗

Closing this window with ×, Escape or a click outside saves a refusal and stops analytics and recording.